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
49
internal/store/account_rating.go
Normal file
49
internal/store/account_rating.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// AccountRatingStore owns the composite rating read model and its contribution
|
||||
// ledger.
|
||||
//
|
||||
// The read model is derived: SaveAccountRating writes a recomputed projection,
|
||||
// AccountRatingSignals gathers the raw inputs from the contributing tables, and
|
||||
// the ledger keeps manual adjustments that must survive a recompute.
|
||||
type AccountRatingStore interface {
|
||||
// AccountRating returns the stored projection. Missing rows return
|
||||
// domain.ErrAccountRatingNotFound so callers can distinguish "never computed"
|
||||
// from "computed as zero".
|
||||
AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error)
|
||||
// AccountRatingBatch resolves several users in one round trip. Users without
|
||||
// a row are absent from the map.
|
||||
AccountRatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error)
|
||||
// SaveAccountRating upserts the projection using optimistic concurrency on
|
||||
// the stored version; a stale version reports changed=false.
|
||||
SaveAccountRating(ctx context.Context, rating domain.AccountRating) (stored domain.AccountRating, changed bool, err error)
|
||||
// AccountRatingSignals gathers the raw contribution snapshot for one user,
|
||||
// including the manual total carried from the ledger.
|
||||
AccountRatingSignals(ctx context.Context, userID int64) (domain.AccountRatingSignals, error)
|
||||
// AdjustAccountRating appends a manual adjustment. Replaying the same
|
||||
// CommandKey returns the recorded event and applied=false.
|
||||
AdjustAccountRating(ctx context.Context, req domain.AdjustAccountRatingRequest) (event domain.AccountRatingEvent, applied bool, err error)
|
||||
// ListAccountRatings is the admin leaderboard query with keyset paging.
|
||||
ListAccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error)
|
||||
// AccountRatingEvents returns the ledger for one user, newest first.
|
||||
AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error)
|
||||
// StaleAccountRatings returns user ids whose projection is older than the
|
||||
// given horizon, for the background recompute worker.
|
||||
StaleAccountRatings(ctx context.Context, olderThanUnix int64, limit int) ([]int64, error)
|
||||
// UnratedAccounts returns user ids that have no projection at all, oldest
|
||||
// account first, for the same worker.
|
||||
//
|
||||
// Without this the read model can never populate itself: StaleAccountRatings
|
||||
// walks account_rating, so it can only refresh rows that already exist, and the
|
||||
// very first row for a user would have to come from an operator recomputing
|
||||
// that user by hand. Seeding is what makes the local admin leaderboard useful.
|
||||
//
|
||||
// Deleted accounts and bots are excluded: neither has a rating to show.
|
||||
UnratedAccounts(ctx context.Context, limit int) ([]int64, error)
|
||||
}
|
||||
97
internal/store/bot_verification.go
Normal file
97
internal/store/bot_verification.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// BotVerificationStore owns third-party verification: the icon catalogue, verifier
|
||||
// status, the granted marks and the application queue in front of them.
|
||||
//
|
||||
// Reads on the projection path (PeerVerification / PeerVerificationBatch) are on
|
||||
// every peer serialisation, so they must be cheap. The wire model carries one
|
||||
// BotVerification, and the store therefore permits exactly one mark per peer.
|
||||
type BotVerificationStore interface {
|
||||
// --- icon catalogue ---
|
||||
|
||||
// UpsertVerificationIcon adds or updates a catalogue entry by document id.
|
||||
UpsertVerificationIcon(ctx context.Context, icon domain.VerificationIcon) (domain.VerificationIcon, error)
|
||||
// SetVerificationIconActive retires or restores an entry. Marks already granted
|
||||
// with it keep rendering: the icon id is denormalised onto the mark.
|
||||
SetVerificationIconActive(ctx context.Context, iconID int64, active bool) (domain.VerificationIcon, error)
|
||||
// VerificationIcon reads one entry by id.
|
||||
VerificationIcon(ctx context.Context, iconID int64) (domain.VerificationIcon, error)
|
||||
// VerificationIconByDocument reads one entry by its custom emoji document id.
|
||||
VerificationIconByDocument(ctx context.Context, documentID int64) (domain.VerificationIcon, error)
|
||||
// ListVerificationIcons lists the catalogue, newest first.
|
||||
ListVerificationIcons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error)
|
||||
|
||||
// --- verifier status ---
|
||||
|
||||
// UpsertBotVerifierSettings grants or updates verifier status. Optimistic
|
||||
// locking on the stored version keeps two operators from clobbering each other.
|
||||
UpsertBotVerifierSettings(ctx context.Context, settings domain.BotVerifierSettings) (domain.BotVerifierSettings, error)
|
||||
// SetBotVerifierEnabled flips the operator kill switch. Existing marks stay,
|
||||
// but the verifier can grant nothing new and its settings stop being projected.
|
||||
SetBotVerifierEnabled(ctx context.Context, botID int64, enabled bool) (domain.BotVerifierSettings, error)
|
||||
// DeleteBotVerifierSettings removes verifier status; its marks cascade away with
|
||||
// it, because a mark whose verifier no longer exists has nothing to render.
|
||||
DeleteBotVerifierSettings(ctx context.Context, botID int64) (bool, error)
|
||||
// BotVerifierSettings reads one verifier's status, enabled or not.
|
||||
BotVerifierSettings(ctx context.Context, botID int64) (domain.BotVerifierSettings, error)
|
||||
// BotVerifierSettingsBatch resolves several bots in one round trip for the
|
||||
// botInfo projection; bots without verifier status are absent from the map.
|
||||
BotVerifierSettingsBatch(ctx context.Context, botIDs []int64) (map[int64]domain.BotVerifierSettings, error)
|
||||
// ListBotVerifiers lists verifier bots for the admin panel.
|
||||
ListBotVerifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error)
|
||||
|
||||
// --- granted marks ---
|
||||
|
||||
// GrantCustomVerification creates or updates the peer's mark. A different
|
||||
// verifier replaces the current mark rather than leaving hidden fallback state.
|
||||
// The icon is taken from the verifier's settings at grant time and the caller
|
||||
// has already resolved the description through
|
||||
// domain.BotVerifierSettings.DescriptionFor.
|
||||
GrantCustomVerification(ctx context.Context, mark domain.CustomVerification) (domain.CustomVerification, bool, error)
|
||||
// RevokeCustomVerification removes this verifier's mark from the peer and
|
||||
// reports whether anything was removed, so a repeated revoke is a no-op.
|
||||
RevokeCustomVerification(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error)
|
||||
// CustomVerification reads one verifier's mark on a peer.
|
||||
CustomVerification(ctx context.Context, verifierBotID int64, peer domain.Peer) (domain.CustomVerification, error)
|
||||
// PeerVerification returns the peer's single mark. Missing marks report
|
||||
// domain.ErrCustomVerificationNotFound.
|
||||
PeerVerification(ctx context.Context, peer domain.Peer) (domain.CustomVerification, error)
|
||||
// PeerVerificationBatch resolves the projection for many peers at once. This is
|
||||
// the call on the hot serialisation path, so peers without a mark are simply
|
||||
// absent instead of erroring.
|
||||
PeerVerificationBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer]domain.CustomVerification, error)
|
||||
// CountCustomVerifications reports how many peers a verifier has marked, for the
|
||||
// per-verifier bound.
|
||||
CountCustomVerifications(ctx context.Context, verifierBotID int64) (int, error)
|
||||
// ListCustomVerifications is the admin listing query with keyset paging.
|
||||
ListCustomVerifications(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error)
|
||||
|
||||
// --- application queue ---
|
||||
|
||||
// CreateCustomVerificationRequest files an application. A pending application on
|
||||
// the same (verifier, peer) reports domain.ErrCustomVerificationRequestExists.
|
||||
CreateCustomVerificationRequest(ctx context.Context, req domain.CustomVerificationRequest) (domain.CustomVerificationRequest, error)
|
||||
// DecideCustomVerificationRequest moves an application through its status
|
||||
// machine. approve=true grants the mark in the same transaction through the
|
||||
// supplied callback, so an approved application can never exist without its
|
||||
// mark; revoke removes it the same way.
|
||||
DecideCustomVerificationRequest(ctx context.Context, requestID int64, version int64, status domain.CustomVerificationRequestStatus, decidedBy, reason, note string, apply func(ctx context.Context, req domain.CustomVerificationRequest) error) (domain.CustomVerificationRequest, bool, error)
|
||||
// CustomVerificationRequest reads one application.
|
||||
CustomVerificationRequest(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error)
|
||||
// PendingCustomVerificationRequest returns the live application for a
|
||||
// (verifier, peer) pair, if any.
|
||||
PendingCustomVerificationRequest(ctx context.Context, verifierBotID int64, peer domain.Peer) (domain.CustomVerificationRequest, error)
|
||||
// ListCustomVerificationRequests is the review-queue query with keyset paging.
|
||||
ListCustomVerificationRequests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error)
|
||||
// CustomVerificationRequestsForApplicant returns an applicant's own history for
|
||||
// the verifier bot's /status command.
|
||||
CustomVerificationRequestsForApplicant(ctx context.Context, applicantUserID int64, limit int) ([]domain.CustomVerificationRequest, error)
|
||||
// CustomVerificationRequestCounts is the queue summary by status.
|
||||
CustomVerificationRequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error)
|
||||
}
|
||||
60
internal/store/collectible_username.go
Normal file
60
internal/store/collectible_username.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// UsernameRegistryStore reads a peer's full username list. The list is the
|
||||
// projection source for the TL usernames vector, so every caller sees the same
|
||||
// order the client will render: editable slot first, then collectibles.
|
||||
type UsernameRegistryStore interface {
|
||||
// PeerUsernames returns the peer's registry rows in projection order.
|
||||
PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error)
|
||||
// PeerUsernamesBatch resolves several peers in one round trip; peers absent
|
||||
// from the result simply hold no usernames.
|
||||
PeerUsernamesBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error)
|
||||
// SetUsernameActive toggles one collectible row. It never touches the
|
||||
// editable slot and returns domain.ErrUsernameNotCollectible if asked to.
|
||||
SetUsernameActive(ctx context.Context, peer domain.Peer, username string, active bool) (bool, error)
|
||||
// ReorderUsernames rewrites collectible sort order. order must be a
|
||||
// permutation of the peer's collectible usernames.
|
||||
ReorderUsernames(ctx context.Context, peer domain.Peer, order []string) (bool, error)
|
||||
// DeactivateAllUsernames clears the active flag on every collectible row and
|
||||
// reports whether anything changed. The editable slot is left alone.
|
||||
DeactivateAllUsernames(ctx context.Context, peer domain.Peer) (bool, error)
|
||||
}
|
||||
|
||||
// CollectibleUsernameStore owns the collectible asset lifecycle: minting into the
|
||||
// operator vault, assigning to a holder, returning to the vault and burning.
|
||||
//
|
||||
// Every mutation is expected to be atomic with the corresponding username
|
||||
// registry row, so an asset can never be owned without being resolvable and can
|
||||
// never be resolvable without being owned.
|
||||
type CollectibleUsernameStore interface {
|
||||
// MintCollectibleUsername creates the asset. A request carrying an owner
|
||||
// assigns it in the same transaction. Replaying the same CommandKey returns
|
||||
// the original asset and reports created=false.
|
||||
MintCollectibleUsername(ctx context.Context, req domain.MintCollectibleUsernameRequest) (asset domain.CollectibleUsername, created bool, err error)
|
||||
// TransferCollectibleUsername moves the asset to req.To, out of the vault or
|
||||
// from the current holder. Replay by CommandKey is a no-op returning the
|
||||
// current state with changed=false.
|
||||
TransferCollectibleUsername(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (asset domain.CollectibleUsername, changed bool, err error)
|
||||
// RevokeCollectibleUsername returns the asset to the vault, or burns it when
|
||||
// req.Burn is set. Burning releases the name back to the free pool.
|
||||
RevokeCollectibleUsername(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (asset domain.CollectibleUsername, changed bool, err error)
|
||||
// DeleteCollectibleUsername removes the live asset for a name completely --
|
||||
// registry row, asset and provenance -- and frees the name for any use. A
|
||||
// replay finds nothing live left and reports deleted=false without an error,
|
||||
// because the record a command key would key on is gone.
|
||||
DeleteCollectibleUsername(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (deleted bool, err error)
|
||||
// CollectibleUsername looks the asset up by name.
|
||||
CollectibleUsername(ctx context.Context, username string) (domain.CollectibleUsername, error)
|
||||
// CollectibleUsernameByID looks the asset up by identity.
|
||||
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
|
||||
// ListCollectibleUsernames is the admin listing query with keyset paging.
|
||||
ListCollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
|
||||
// CollectibleUsernameTransfers returns the provenance log, newest first.
|
||||
CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -170,7 +170,7 @@ func (s *AccountLifecycleStore) ExecuteAccountDeletion(ctx context.Context, user
|
|||
if err := purgeDeletedAccountPrivateState(ctx, tx, userID, now); err != nil {
|
||||
return domain.AccountDeletionResult{}, err
|
||||
}
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, ""); err != nil {
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, "", ""); err != nil {
|
||||
return domain.AccountDeletionResult{}, fmt.Errorf("release deleted account username: %w", err)
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
|
|
|
|||
557
internal/store/postgres/account_rating.go
Normal file
557
internal/store/postgres/account_rating.go
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
// AccountRatingStore is the PostgreSQL implementation of the composite account
|
||||
// rating read model and its contribution ledger.
|
||||
//
|
||||
// account_rating is derived state: it is always rebuildable from the contributing
|
||||
// tables plus the 'manual' rows of account_rating_events, which is exactly what
|
||||
// AccountRatingSignals gathers. Writes use optimistic concurrency on the stored
|
||||
// version so a background recompute and an admin adjustment cannot silently
|
||||
// overwrite each other.
|
||||
type AccountRatingStore struct {
|
||||
db sqlcgen.DBTX
|
||||
}
|
||||
|
||||
// NewAccountRatingStore builds the store on a pgx pool or transaction.
|
||||
func NewAccountRatingStore(db sqlcgen.DBTX) *AccountRatingStore {
|
||||
return &AccountRatingStore{db: db}
|
||||
}
|
||||
|
||||
var _ store.AccountRatingStore = (*AccountRatingStore)(nil)
|
||||
|
||||
const (
|
||||
defaultAccountRatingListLimit = 50
|
||||
maxAccountRatingListLimit = 200
|
||||
)
|
||||
|
||||
const accountRatingColumns = `user_id, level, stars, current_level_stars, next_level_stars,
|
||||
stars_component, activity_component, penalty_component, manual_component,
|
||||
pending_stars, pending_date, computed_at, updated_at, version`
|
||||
|
||||
// accountRatingColumnsQualified is the same projection for queries that join, so
|
||||
// the shared column names stay unambiguous.
|
||||
const accountRatingColumnsQualified = `r.user_id, r.level, r.stars, r.current_level_stars, r.next_level_stars,
|
||||
r.stars_component, r.activity_component, r.penalty_component, r.manual_component,
|
||||
r.pending_stars, r.pending_date, r.computed_at, r.updated_at, r.version`
|
||||
|
||||
// AccountRating returns the stored projection, distinguishing "never computed"
|
||||
// from "computed as zero".
|
||||
func (s *AccountRatingStore) AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.AccountRating{}, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
rating, err := scanAccountRating(s.db.QueryRow(ctx, `
|
||||
SELECT `+accountRatingColumns+` FROM account_rating WHERE user_id = $1`, userID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, fmt.Errorf("get account rating: %w", err)
|
||||
}
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
// AccountRatingBatch resolves several users in one round trip.
|
||||
func (s *AccountRatingStore) AccountRatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
out := make(map[int64]domain.AccountRating, len(userIDs))
|
||||
filtered := make([]int64, 0, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if userID > 0 {
|
||||
filtered = append(filtered, userID)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT `+accountRatingColumns+` FROM account_rating WHERE user_id = ANY($1::bigint[])`, filtered)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list account ratings batch: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
rating, err := scanAccountRating(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan account rating batch: %w", err)
|
||||
}
|
||||
out[rating.UserID] = rating
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate account ratings batch: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveAccountRating upserts the projection under optimistic concurrency: the
|
||||
// caller submits the version it intends to write (prev.Version + 1, which is what
|
||||
// domain.ResolveAccountRatingPending produces), and the update only lands when the
|
||||
// stored row is still one version behind. A stale write reports changed=false and
|
||||
// returns the row that won, so the caller can recompute instead of retrying blind.
|
||||
func (s *AccountRatingStore) SaveAccountRating(ctx context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.AccountRating{}, false, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if rating.UserID <= 0 || rating.Level < 0 || rating.Level > domain.MaxAccountRatingLevel ||
|
||||
rating.CurrentLevelStars < 0 || rating.StarsComponent < 0 ||
|
||||
rating.ActivityComponent < 0 || rating.PenaltyComponent < 0 {
|
||||
return domain.AccountRating{}, false, domain.ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
if rating.Version <= 0 {
|
||||
rating.Version = 1
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if rating.ComputedAt.IsZero() {
|
||||
rating.ComputedAt = now
|
||||
}
|
||||
if rating.UpdatedAt.IsZero() {
|
||||
rating.UpdatedAt = now
|
||||
}
|
||||
// The schema pairs pending_stars with pending_date; a half-filled pending
|
||||
// record is normalised away rather than rejected by the CHECK at runtime.
|
||||
if rating.PendingStars == 0 || rating.PendingDate.IsZero() {
|
||||
rating.PendingStars = 0
|
||||
rating.PendingDate = time.Time{}
|
||||
}
|
||||
var nextLevelStars any
|
||||
if rating.HasNextLevel && rating.NextLevelStars > rating.CurrentLevelStars {
|
||||
nextLevelStars = rating.NextLevelStars
|
||||
}
|
||||
var pendingDate any
|
||||
if rating.PendingStars != 0 {
|
||||
pendingDate = rating.PendingDate.UTC()
|
||||
}
|
||||
stored, err := scanAccountRating(s.db.QueryRow(ctx, `
|
||||
INSERT INTO account_rating (
|
||||
user_id, level, stars, current_level_stars, next_level_stars,
|
||||
stars_component, activity_component, penalty_component, manual_component,
|
||||
pending_stars, pending_date, computed_at, updated_at, version
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
level = EXCLUDED.level,
|
||||
stars = EXCLUDED.stars,
|
||||
current_level_stars = EXCLUDED.current_level_stars,
|
||||
next_level_stars = EXCLUDED.next_level_stars,
|
||||
stars_component = EXCLUDED.stars_component,
|
||||
activity_component = EXCLUDED.activity_component,
|
||||
penalty_component = EXCLUDED.penalty_component,
|
||||
manual_component = EXCLUDED.manual_component,
|
||||
pending_stars = EXCLUDED.pending_stars,
|
||||
pending_date = EXCLUDED.pending_date,
|
||||
computed_at = EXCLUDED.computed_at,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
version = EXCLUDED.version
|
||||
WHERE account_rating.version = EXCLUDED.version - 1
|
||||
RETURNING `+accountRatingColumns,
|
||||
rating.UserID, rating.Level, rating.Stars, rating.CurrentLevelStars, nextLevelStars,
|
||||
rating.StarsComponent, rating.ActivityComponent, rating.PenaltyComponent, rating.ManualComponent,
|
||||
rating.PendingStars, pendingDate, rating.ComputedAt.UTC(), rating.UpdatedAt.UTC(), rating.Version,
|
||||
))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// The guard rejected the write; report the row that is actually stored.
|
||||
current, getErr := s.AccountRating(ctx, rating.UserID)
|
||||
if getErr != nil {
|
||||
return domain.AccountRating{}, false, getErr
|
||||
}
|
||||
return current, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, false, fmt.Errorf("save account rating: %w", err)
|
||||
}
|
||||
return stored, true, nil
|
||||
}
|
||||
|
||||
// AccountRatingSignals gathers the raw contribution snapshot for one user.
|
||||
//
|
||||
// Sources, and why each one:
|
||||
//
|
||||
// stars received / spent stars_transactions, split by the sign of amount: that
|
||||
// ledger is the single authoritative record of Stars
|
||||
// movement for a user, and stars_transactions_user_id_idx
|
||||
// (user_id, id DESC) bounds the scan to the user's rows.
|
||||
// gifts received peer_star_gifts for the user peer, restricted to
|
||||
// lifecycle_status = 'active' -- the weight rewards gifts
|
||||
// actually held, not ones converted, burned or exported
|
||||
// away. peer_star_gifts_owner_profile_order_idx is the
|
||||
// partial index on exactly that predicate and leads with
|
||||
// (owner_peer_type, owner_peer_id).
|
||||
// moderation cases moderation_cases against this user peer, restricted to
|
||||
// the statuses that follow a *violation* decision:
|
||||
// 'action_pending' (violation decided, actions running),
|
||||
// 'action_failed' (violation decided, action delivery
|
||||
// broke) and 'resolved' (violation decided and actions
|
||||
// applied). 'dismissed' covers both no_violation and a
|
||||
// granted appeal, and 'open'/'in_review'/'appeal_review'
|
||||
// are undecided, so none of them penalise the account.
|
||||
// scam / fake users.scam / users.fake, the peer flags 0136 added.
|
||||
// account age users.created_at, floored to whole days.
|
||||
// messages sent message_boxes, counted through
|
||||
// message_boxes_private_sender_live_idx
|
||||
// (message_sender_id, private_message_id) WHERE NOT
|
||||
// deleted. This is the cheapest trustworthy source: the
|
||||
// index leads with the sender and is coverable, so the
|
||||
// count needs no heap access. Every private message
|
||||
// materialises one box per participant, hence
|
||||
// count(DISTINCT private_message_id) rather than
|
||||
// count(*). Channel posts are deliberately excluded --
|
||||
// channel_messages has no sender-leading index, so
|
||||
// attributing them would cost a full table scan.
|
||||
// manual sum of account_rating_events.amount where
|
||||
// kind = 'manual', which is the part of the score that
|
||||
// must survive a full recompute.
|
||||
func (s *AccountRatingStore) AccountRatingSignals(ctx context.Context, userID int64) (domain.AccountRatingSignals, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.AccountRatingSignals{}, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return domain.AccountRatingSignals{}, domain.ErrUserNotFound
|
||||
}
|
||||
signals := domain.AccountRatingSignals{UserID: userID}
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
COALESCE((SELECT sum(amount) FROM stars_transactions WHERE user_id = u.id AND amount > 0), 0),
|
||||
COALESCE((SELECT -sum(amount) FROM stars_transactions WHERE user_id = u.id AND amount < 0), 0),
|
||||
COALESCE((
|
||||
SELECT count(DISTINCT private_message_id) FROM message_boxes
|
||||
WHERE message_sender_id = u.id AND NOT deleted
|
||||
), 0),
|
||||
GREATEST(0, FLOOR(EXTRACT(EPOCH FROM ($2::timestamptz - u.created_at)) / 86400))::bigint,
|
||||
COALESCE((
|
||||
SELECT count(*) FROM peer_star_gifts
|
||||
WHERE owner_peer_type = 'user' AND owner_peer_id = u.id AND lifecycle_status = 'active'
|
||||
), 0),
|
||||
COALESCE((
|
||||
SELECT count(*) FROM moderation_cases
|
||||
WHERE target_peer_type = 'user' AND target_peer_id = u.id
|
||||
AND status IN ('action_pending', 'action_failed', 'resolved')
|
||||
), 0),
|
||||
u.scam,
|
||||
u.fake,
|
||||
COALESCE((
|
||||
SELECT sum(amount) FROM account_rating_events
|
||||
WHERE user_id = u.id AND kind = 'manual'
|
||||
), 0)
|
||||
FROM users u
|
||||
WHERE u.id = $1`, userID, time.Now().UTC()).Scan(
|
||||
&signals.StarsReceived, &signals.StarsSpent, &signals.MessagesSent,
|
||||
&signals.AccountAgeDays, &signals.GiftsReceived, &signals.ModerationCases,
|
||||
&signals.Scam, &signals.Fake, &signals.Manual,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AccountRatingSignals{}, domain.ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AccountRatingSignals{}, fmt.Errorf("gather account rating signals: %w", err)
|
||||
}
|
||||
return signals, nil
|
||||
}
|
||||
|
||||
// AdjustAccountRating appends a manual adjustment to the ledger. It does not
|
||||
// recompute the projection: the caller pairs it with SaveAccountRating so the new
|
||||
// manual total is folded in through the same formula as every other signal.
|
||||
// Replaying the same CommandKey returns the recorded event and applied=false.
|
||||
func (s *AccountRatingStore) AdjustAccountRating(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.AccountRatingEvent{}, false, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.Actor = strings.TrimSpace(req.Actor)
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.AccountRatingEvent{}, false, err
|
||||
}
|
||||
var event domain.AccountRatingEvent
|
||||
applied := false
|
||||
err := withTx(ctx, s.db, "adjust account rating", func(tx pgx.Tx) error {
|
||||
if existing, found, err := accountRatingEventByCommandKey(ctx, tx, req.CommandKey); err != nil {
|
||||
return err
|
||||
} else if found {
|
||||
event = existing
|
||||
return nil
|
||||
}
|
||||
event = domain.AccountRatingEvent{
|
||||
UserID: req.UserID,
|
||||
Kind: domain.AccountRatingEventManual,
|
||||
Amount: req.Amount,
|
||||
Reason: req.Reason,
|
||||
Actor: req.Actor,
|
||||
CommandKey: req.CommandKey,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
// DO NOTHING on the partial command_key index closes the window between the
|
||||
// replay lookup and the insert: a concurrent retry of the same command
|
||||
// records nothing and falls back to reading the row that won.
|
||||
err := tx.QueryRow(ctx, `
|
||||
INSERT INTO account_rating_events (user_id, kind, amount, reason, actor, command_key, created_at)
|
||||
VALUES ($1,'manual',$2,$3,$4,NULLIF($5,''),$6)
|
||||
ON CONFLICT (command_key) WHERE command_key IS NOT NULL DO NOTHING
|
||||
RETURNING id`, event.UserID, event.Amount, event.Reason, event.Actor, event.CommandKey, event.CreatedAt).
|
||||
Scan(&event.ID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
existing, found, lookupErr := accountRatingEventByCommandKey(ctx, tx, req.CommandKey)
|
||||
if lookupErr != nil {
|
||||
return lookupErr
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("insert account rating adjustment: conflicting command %q vanished", req.CommandKey)
|
||||
}
|
||||
event = existing
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert account rating adjustment: %w", err)
|
||||
}
|
||||
applied = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.AccountRatingEvent{}, false, err
|
||||
}
|
||||
return event, applied, nil
|
||||
}
|
||||
|
||||
// ListAccountRatings is the admin leaderboard query. The order matches
|
||||
// account_rating_leaderboard_idx (level DESC, stars DESC, user_id) and BeforeID is
|
||||
// a keyset cursor: the cursor row's own (level, stars) are read back so paging
|
||||
// stays consistent across the compound order instead of only over user ids.
|
||||
func (s *AccountRatingStore) ListAccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if filter.MinLevel < 0 || filter.MinLevel > domain.MaxAccountRatingLevel {
|
||||
return nil, domain.ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
limit := filter.Limit
|
||||
if limit <= 0 {
|
||||
limit = defaultAccountRatingListLimit
|
||||
}
|
||||
if limit > maxAccountRatingListLimit {
|
||||
limit = maxAccountRatingListLimit
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH cursor_row AS (
|
||||
SELECT level AS c_level, stars AS c_stars, user_id AS c_user_id
|
||||
FROM account_rating WHERE $3 <> 0 AND user_id = $3
|
||||
)
|
||||
SELECT `+accountRatingColumnsQualified+`
|
||||
FROM account_rating r
|
||||
LEFT JOIN cursor_row c ON true
|
||||
WHERE r.level >= $1
|
||||
AND ($2 = 0 OR r.user_id = $2)
|
||||
AND (
|
||||
c.c_user_id IS NULL
|
||||
OR r.level < c.c_level
|
||||
OR (r.level = c.c_level AND r.stars < c.c_stars)
|
||||
OR (r.level = c.c_level AND r.stars = c.c_stars AND r.user_id > c.c_user_id)
|
||||
)
|
||||
ORDER BY r.level DESC, r.stars DESC, r.user_id
|
||||
LIMIT $4`, filter.MinLevel, filter.UserID, filter.BeforeID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list account ratings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.AccountRating, 0, limit)
|
||||
for rows.Next() {
|
||||
rating, err := scanAccountRating(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan account rating: %w", err)
|
||||
}
|
||||
out = append(out, rating)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate account ratings: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AccountRatingEvents returns the ledger for one user, newest first, over
|
||||
// account_rating_events_user_idx (user_id, id DESC).
|
||||
func (s *AccountRatingStore) AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultAccountRatingListLimit
|
||||
}
|
||||
if limit > maxAccountRatingListLimit {
|
||||
limit = maxAccountRatingListLimit
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at
|
||||
FROM account_rating_events
|
||||
WHERE user_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT $2`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list account rating events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.AccountRatingEvent, 0, limit)
|
||||
for rows.Next() {
|
||||
event, err := scanAccountRatingEvent(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan account rating event: %w", err)
|
||||
}
|
||||
out = append(out, event)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate account rating events: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// StaleAccountRatings returns the users whose projection is older than the
|
||||
// horizon, ordered so the walk follows account_rating_stale_idx
|
||||
// (computed_at, user_id) exactly.
|
||||
func (s *AccountRatingStore) StaleAccountRatings(ctx context.Context, olderThanUnix int64, limit int) ([]int64, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if olderThanUnix <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultAccountRatingListLimit
|
||||
}
|
||||
if limit > maxAccountRatingListLimit {
|
||||
limit = maxAccountRatingListLimit
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT user_id FROM account_rating
|
||||
WHERE computed_at < to_timestamp($1)
|
||||
ORDER BY computed_at, user_id
|
||||
LIMIT $2`, olderThanUnix, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list stale account ratings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]int64, 0, limit)
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
return nil, fmt.Errorf("scan stale account rating: %w", err)
|
||||
}
|
||||
out = append(out, userID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate stale account ratings: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UnratedAccounts returns accounts that have no projection yet, oldest account
|
||||
// first so the walk is stable and every account is eventually reached.
|
||||
//
|
||||
// Three kinds of account are skipped, per domain.RatableAccount: bots, which do
|
||||
// not transact on their own behalf; the built-in service accounts, which are
|
||||
// infrastructure -- and note that the platform account is not flagged is_bot, so
|
||||
// excluding bots alone would still have seeded it; and deleted accounts, which are
|
||||
// tombstones whose every profile field has already been cleared.
|
||||
func (s *AccountRatingStore) UnratedAccounts(ctx context.Context, limit int) ([]int64, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultAccountRatingListLimit
|
||||
}
|
||||
if limit > maxAccountRatingListLimit {
|
||||
limit = maxAccountRatingListLimit
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT u.id FROM users u
|
||||
WHERE NOT u.is_bot
|
||||
AND u.deleted_at IS NULL
|
||||
AND u.id <> ALL($2::bigint[])
|
||||
AND NOT EXISTS (SELECT 1 FROM account_rating r WHERE r.user_id = u.id)
|
||||
ORDER BY u.created_at, u.id
|
||||
LIMIT $1`, limit, domain.SystemUserIDs())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list unrated accounts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]int64, 0, limit)
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
return nil, fmt.Errorf("scan unrated account: %w", err)
|
||||
}
|
||||
out = append(out, userID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate unrated accounts: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func accountRatingEventByCommandKey(ctx context.Context, db sqlcgen.DBTX, commandKey string) (domain.AccountRatingEvent, bool, error) {
|
||||
if commandKey == "" {
|
||||
return domain.AccountRatingEvent{}, false, nil
|
||||
}
|
||||
event, err := scanAccountRatingEvent(db.QueryRow(ctx, `
|
||||
SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at
|
||||
FROM account_rating_events WHERE command_key = $1`, commandKey))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AccountRatingEvent{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AccountRatingEvent{}, false, fmt.Errorf("lookup account rating command: %w", err)
|
||||
}
|
||||
return event, true, nil
|
||||
}
|
||||
|
||||
func scanAccountRating(row pgx.Row) (domain.AccountRating, error) {
|
||||
var rating domain.AccountRating
|
||||
var nextLevelStars pgtype.Int8
|
||||
var pendingDate pgtype.Timestamptz
|
||||
if err := row.Scan(&rating.UserID, &rating.Level, &rating.Stars, &rating.CurrentLevelStars,
|
||||
&nextLevelStars, &rating.StarsComponent, &rating.ActivityComponent,
|
||||
&rating.PenaltyComponent, &rating.ManualComponent, &rating.PendingStars,
|
||||
&pendingDate, &rating.ComputedAt, &rating.UpdatedAt, &rating.Version); err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
if nextLevelStars.Valid {
|
||||
rating.NextLevelStars = nextLevelStars.Int64
|
||||
rating.HasNextLevel = true
|
||||
}
|
||||
if pendingDate.Valid {
|
||||
rating.PendingDate = pendingDate.Time.UTC()
|
||||
}
|
||||
rating.ComputedAt = rating.ComputedAt.UTC()
|
||||
rating.UpdatedAt = rating.UpdatedAt.UTC()
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func scanAccountRatingEvent(row pgx.Row) (domain.AccountRatingEvent, error) {
|
||||
var event domain.AccountRatingEvent
|
||||
var kind string
|
||||
if err := row.Scan(&event.ID, &event.UserID, &kind, &event.Amount, &event.Reason,
|
||||
&event.Actor, &event.CommandKey, &event.CreatedAt); err != nil {
|
||||
return domain.AccountRatingEvent{}, err
|
||||
}
|
||||
event.Kind = domain.AccountRatingEventKind(kind)
|
||||
event.CreatedAt = event.CreatedAt.UTC()
|
||||
return event, nil
|
||||
}
|
||||
462
internal/store/postgres/account_rating_integration_test.go
Normal file
462
internal/store/postgres/account_rating_integration_test.go
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// ratingTestUser inserts a user row with an explicit creation date so the account
|
||||
// age signal is deterministic.
|
||||
func ratingTestUser(t *testing.T, pool *pgxpool.Pool, seed int64, createdAt time.Time) int64 {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var id int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO users (access_hash, phone, first_name, created_at, updated_at)
|
||||
VALUES ($1, $2, 'rating test', $3, $3)
|
||||
RETURNING id`, seed, fmt.Sprintf("%d", seed), createdAt).Scan(&id); err != nil {
|
||||
t.Fatalf("insert rating test user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
// ratingTestCatalogRevision publishes a throwaway star gift so peer_star_gifts
|
||||
// rows can satisfy their catalog revision foreign key.
|
||||
func ratingTestCatalogRevision(t *testing.T, pool *pgxpool.Pool) (revisionID, giftID int64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
docID := time.Now().UnixNano() & 0x7fffffffffffffff
|
||||
entry, err := NewStarGiftStore(pool).CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Stars: 50, ConvertStars: 50, Enabled: true,
|
||||
Document: domain.Document{
|
||||
ID: docID, AccessHash: docID + 1, MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
|
||||
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
|
||||
},
|
||||
Blob: domain.FileBlob{
|
||||
LocationKey: "doc:" + fmt.Sprint(docID), Backend: domain.MediaBackendLocalFS,
|
||||
ObjectKey: "rating-star-gift", Size: 4, SHA256: make([]byte, 32),
|
||||
MimeType: "application/x-tgsticker",
|
||||
},
|
||||
Animation: domain.StarGiftAnimation{
|
||||
JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`),
|
||||
SHA256: make([]byte, 32), SourceFormat: domain.StarGiftAnimationTGS, Width: 512, Height: 512,
|
||||
},
|
||||
Actor: "test", CommandID: "rating-star-gift-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create catalog revision: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT id FROM star_gift_catalog_revisions WHERE gift_id = $1 ORDER BY revision DESC LIMIT 1`,
|
||||
entry.Gift.ID).Scan(&revisionID); err != nil {
|
||||
t.Fatalf("read catalog revision id: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx := context.Background()
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM star_gift_catalog WHERE gift_id = $1`, entry.Gift.ID)
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM star_gift_catalog_revisions WHERE gift_id = $1`, entry.Gift.ID)
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM file_blobs WHERE location_key = $1`, "doc:"+fmt.Sprint(docID))
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM documents WHERE id = $1`, docID)
|
||||
})
|
||||
return revisionID, entry.Gift.ID
|
||||
}
|
||||
|
||||
// TestAccountRatingSaveVersionConflict covers the optimistic write: a first save
|
||||
// creates the row, a stale version is refused without an error, and the next
|
||||
// version wins.
|
||||
func TestAccountRatingSaveVersionConflict(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
userID := ratingTestUser(t, pool, 3_100_000_000+seed, time.Now().UTC().AddDate(0, 0, -30))
|
||||
|
||||
if _, err := store.AccountRating(ctx, userID); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("missing rating err = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
signals := domain.AccountRatingSignals{UserID: userID, StarsReceived: 900, MessagesSent: 10, AccountAgeDays: 30}
|
||||
computed := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
|
||||
stored, changed, err := store.SaveAccountRating(ctx, computed)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("first save changed=%v err=%v", changed, err)
|
||||
}
|
||||
if stored.Version != 1 || stored.Stars != computed.Stars || stored.Level != computed.Level ||
|
||||
stored.HasNextLevel != computed.HasNextLevel || stored.NextLevelStars != computed.NextLevelStars {
|
||||
t.Fatalf("stored = %+v want %+v", stored, computed)
|
||||
}
|
||||
read, err := store.AccountRating(ctx, userID)
|
||||
if err != nil || read != stored {
|
||||
t.Fatalf("read = %+v stored = %+v err=%v", read, stored, err)
|
||||
}
|
||||
|
||||
// A second writer that still believes version 0 is stale: no error, no write.
|
||||
stale := computed
|
||||
stale.Stars = 999_999
|
||||
conflicted, changed, err := store.SaveAccountRating(ctx, stale)
|
||||
if err != nil || changed {
|
||||
t.Fatalf("stale save changed=%v err=%v", changed, err)
|
||||
}
|
||||
if conflicted.Stars != stored.Stars || conflicted.Version != 1 {
|
||||
t.Fatalf("conflicted = %+v, stored row must win", conflicted)
|
||||
}
|
||||
|
||||
// The recompute that carries the right version applies, including a pending
|
||||
// delta that must round-trip through the paired pending_stars/pending_date.
|
||||
next := domain.ResolveAccountRatingPending(stored,
|
||||
domain.ComputeAccountRating(domain.AccountRatingSignals{UserID: userID, StarsReceived: 40_000}, domain.DefaultAccountRatingWeights(), now),
|
||||
time.Hour, now)
|
||||
if next.PendingStars == 0 || next.PendingDate.IsZero() {
|
||||
t.Fatalf("expected a parked pending delta, got %+v", next)
|
||||
}
|
||||
applied, changed, err := store.SaveAccountRating(ctx, next)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("versioned save changed=%v err=%v", changed, err)
|
||||
}
|
||||
if applied.Version != 2 || applied.PendingStars != next.PendingStars ||
|
||||
!applied.PendingDate.Equal(next.PendingDate.UTC()) {
|
||||
t.Fatalf("applied = %+v want pending %d at %v", applied, next.PendingStars, next.PendingDate)
|
||||
}
|
||||
pending, ok := applied.PendingLevel()
|
||||
if !ok || pending.Stars <= applied.Stars {
|
||||
t.Fatalf("pending projection = %+v ok=%v", pending, ok)
|
||||
}
|
||||
|
||||
batch, err := store.AccountRatingBatch(ctx, []int64{userID, userID + 1})
|
||||
if err != nil || len(batch) != 1 || batch[userID].Version != 2 {
|
||||
t.Fatalf("batch = %+v err=%v", batch, err)
|
||||
}
|
||||
list, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{UserID: userID, Limit: 10})
|
||||
if err != nil || len(list) != 1 || list[0].UserID != userID {
|
||||
t.Fatalf("list = %+v err=%v", list, err)
|
||||
}
|
||||
stale2, err := store.StaleAccountRatings(ctx, now.Add(time.Minute).Unix(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("stale: %v", err)
|
||||
}
|
||||
if !containsInt64(stale2, userID) {
|
||||
t.Fatalf("stale ratings %v must contain %d", stale2, userID)
|
||||
}
|
||||
fresh, err := store.StaleAccountRatings(ctx, now.Add(-time.Hour).Unix(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("stale fresh: %v", err)
|
||||
}
|
||||
if containsInt64(fresh, userID) {
|
||||
t.Fatalf("rating computed at %v must not be stale before it", applied.ComputedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountRatingAdjustmentIdempotency covers the manual ledger: a replayed
|
||||
// command key records nothing new and the manual total feeds the recompute.
|
||||
func TestAccountRatingAdjustmentIdempotency(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
userID := ratingTestUser(t, pool, 3_200_000_000+seed, time.Now().UTC().AddDate(0, 0, -10))
|
||||
key := fmt.Sprintf("adjust-%d", seed)
|
||||
|
||||
req := domain.AdjustAccountRatingRequest{
|
||||
UserID: userID, Amount: 750, Reason: "community award", Actor: "ops", CommandKey: key,
|
||||
}
|
||||
event, applied, err := store.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.CommandKey != key {
|
||||
t.Fatalf("event = %+v", event)
|
||||
}
|
||||
replay, applied, err := store.AdjustAccountRating(ctx, req)
|
||||
if err != nil || applied || replay.ID != event.ID {
|
||||
t.Fatalf("replay applied=%v event=%+v err=%v", applied, replay, err)
|
||||
}
|
||||
// A second, distinct adjustment accumulates rather than replacing.
|
||||
if _, applied, err := store.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
|
||||
UserID: userID, Amount: -250, Reason: "partial revoke", Actor: "ops",
|
||||
CommandKey: key + "-b",
|
||||
}); err != nil || !applied {
|
||||
t.Fatalf("second adjust applied=%v err=%v", applied, err)
|
||||
}
|
||||
events, err := store.AccountRatingEvents(ctx, userID, 10)
|
||||
if err != nil || len(events) != 2 || events[0].Amount != -250 || events[1].Amount != 750 {
|
||||
t.Fatalf("events = %+v err=%v", events, err)
|
||||
}
|
||||
if _, _, err := store.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
|
||||
UserID: userID, Amount: 0, CommandKey: key + "-c",
|
||||
}); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("zero adjustment err = %v, want ErrAccountRatingAdjustmentInvalid", err)
|
||||
}
|
||||
signals, err := store.AccountRatingSignals(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("signals: %v", err)
|
||||
}
|
||||
if signals.Manual != 500 {
|
||||
t.Fatalf("manual signal = %d, want 500", signals.Manual)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountRatingSignalsSources pins where each contribution comes from: the
|
||||
// Stars ledger sign split, saved gifts, upheld moderation cases, the peer flags,
|
||||
// account age and the private-message count.
|
||||
func TestAccountRatingSignalsSources(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
createdAt := time.Now().UTC().AddDate(0, 0, -45)
|
||||
userID := ratingTestUser(t, pool, 3_300_000_000+seed, createdAt)
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO stars_transactions (user_id, amount, reason, date)
|
||||
VALUES ($1, 1500, 'gift', 0), ($1, 500, 'reaction', 0), ($1, -400, 'purchase', 0)`, userID); err != nil {
|
||||
t.Fatalf("seed stars: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM stars_transactions WHERE user_id = $1`, userID)
|
||||
})
|
||||
revisionID, giftID := ratingTestCatalogRevision(t, pool)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO peer_star_gifts (owner_peer_type, owner_peer_id, msg_id, gift_id, gift_date, catalog_revision_id, lifecycle_status, converted)
|
||||
VALUES ('user', $1, 1, $2, 0, $3, 'active', false),
|
||||
('user', $1, 2, $2, 0, $3, 'active', false),
|
||||
('user', $1, 3, $2, 0, $3, 'converted', true)`, userID, giftID, revisionID); err != nil {
|
||||
t.Fatalf("seed gifts: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(),
|
||||
`DELETE FROM peer_star_gifts WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, userID)
|
||||
})
|
||||
// Only statuses that follow a violation decision count; 'dismissed' (which
|
||||
// covers no_violation and a granted appeal) and undecided states do not.
|
||||
now := time.Now().UTC()
|
||||
for _, status := range []string{"resolved", "action_failed", "dismissed", "open"} {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO moderation_cases (
|
||||
target_peer_type, target_peer_id, status, severity, report_count,
|
||||
distinct_reporter_count, first_report_at, last_report_at, created_at, updated_at
|
||||
) VALUES ('user', $1, $2, 1, 1, 1, $3, $3, $3, $3)`, userID, status, now); err != nil {
|
||||
t.Fatalf("seed moderation case %s: %v", status, err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(),
|
||||
`DELETE FROM moderation_cases WHERE target_peer_type = 'user' AND target_peer_id = $1`, userID)
|
||||
})
|
||||
if _, err := pool.Exec(ctx, `UPDATE users SET scam = true WHERE id = $1`, userID); err != nil {
|
||||
t.Fatalf("set scam: %v", err)
|
||||
}
|
||||
// Two private messages, each materialised as the sender's and the recipient's
|
||||
// box: the distinct count must report two, not four.
|
||||
peerID := ratingTestUser(t, pool, 3_350_000_000+seed, createdAt)
|
||||
for i := 1; i <= 2; i++ {
|
||||
var messageID int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO private_messages (sender_user_id, recipient_user_id, message_date, body)
|
||||
VALUES ($1, $2, 0, 'hi')
|
||||
RETURNING id`, userID, peerID).Scan(&messageID); err != nil {
|
||||
t.Fatalf("seed private message: %v", err)
|
||||
}
|
||||
for _, box := range []struct {
|
||||
owner int64
|
||||
peer int64
|
||||
outgoing bool
|
||||
}{{userID, peerID, true}, {peerID, userID, false}} {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO message_boxes (
|
||||
owner_user_id, box_id, private_message_id, message_sender_id, peer_type, peer_id,
|
||||
from_user_id, message_date, outgoing, body
|
||||
) VALUES ($1, $2, $3, $4, 'user', $5, $4, 0, $6, 'hi')`,
|
||||
box.owner, i, messageID, userID, box.peer, box.outgoing); err != nil {
|
||||
t.Fatalf("seed message box: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM message_boxes WHERE message_sender_id = $1`, userID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM private_messages WHERE sender_user_id = $1`, userID)
|
||||
})
|
||||
|
||||
signals, err := store.AccountRatingSignals(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("signals: %v", err)
|
||||
}
|
||||
if signals.UserID != userID || signals.StarsReceived != 2000 || signals.StarsSpent != 400 ||
|
||||
signals.GiftsReceived != 2 || signals.ModerationCases != 2 || !signals.Scam || signals.Fake ||
|
||||
signals.MessagesSent != 2 || signals.AccountAgeDays != 45 || signals.Manual != 0 {
|
||||
t.Fatalf("signals = %+v", signals)
|
||||
}
|
||||
rating := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
|
||||
if rating.PenaltyComponent == 0 || rating.StarsComponent != 2000+100 {
|
||||
t.Fatalf("computed rating = %+v", rating)
|
||||
}
|
||||
if _, err := store.AccountRatingSignals(ctx, userID+7_000_000); !errors.Is(err, domain.ErrUserNotFound) {
|
||||
t.Fatalf("signals for unknown user err = %v, want ErrUserNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountRatingLeaderboardPaging covers the keyset walk over
|
||||
// (level DESC, stars DESC, user_id).
|
||||
func TestAccountRatingLeaderboardPaging(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
now := time.Now().UTC()
|
||||
scores := []int64{100, 400, 900, 1600}
|
||||
ids := make([]int64, 0, len(scores))
|
||||
for i, score := range scores {
|
||||
userID := ratingTestUser(t, pool, 3_400_000_000+seed+int64(i), now.AddDate(0, 0, -1))
|
||||
ids = append(ids, userID)
|
||||
rating := domain.ComputeAccountRating(domain.AccountRatingSignals{UserID: userID, Manual: score},
|
||||
domain.DefaultAccountRatingWeights(), now)
|
||||
if _, changed, err := store.SaveAccountRating(ctx, rating); err != nil || !changed {
|
||||
t.Fatalf("save rating %d: changed=%v err=%v", userID, changed, err)
|
||||
}
|
||||
}
|
||||
page, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: 2, Limit: 2})
|
||||
if err != nil || len(page) != 2 {
|
||||
t.Fatalf("first page = %+v err=%v", page, err)
|
||||
}
|
||||
if page[0].Level < page[1].Level {
|
||||
t.Fatalf("leaderboard must be level-descending: %+v", page)
|
||||
}
|
||||
next, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{
|
||||
MinLevel: 2, BeforeID: page[len(page)-1].UserID, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second page: %v", err)
|
||||
}
|
||||
for _, item := range next {
|
||||
if item.UserID == page[0].UserID || item.UserID == page[1].UserID {
|
||||
t.Fatalf("keyset page repeated user %d", item.UserID)
|
||||
}
|
||||
if item.Level > page[len(page)-1].Level {
|
||||
t.Fatalf("keyset page went backwards: %+v after %+v", item, page)
|
||||
}
|
||||
}
|
||||
if _, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: -1}); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("negative level filter must be rejected")
|
||||
}
|
||||
_ = ids
|
||||
}
|
||||
|
||||
// TestAccountRatingUnratedAccountsSeedsTheReadModel proves the SQL behind the
|
||||
// bootstrap pass: only accounts with no projection are returned, bots and deleted
|
||||
// tombstones are excluded, the walk is oldest-account-first, and a user drops out of
|
||||
// the candidate set the moment a projection exists.
|
||||
//
|
||||
// Without this query the read model can never populate itself -- StaleAccountRatings
|
||||
// walks account_rating and so cannot return a user who is not in it.
|
||||
func TestAccountRatingUnratedAccountsSeedsTheReadModel(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
base := time.Now().UTC().Add(-72 * time.Hour).Truncate(time.Second)
|
||||
|
||||
oldest := ratingTestUser(t, pool, 4_100_000_000+seed, base)
|
||||
middle := ratingTestUser(t, pool, 4_200_000_000+seed, base.Add(time.Hour))
|
||||
newest := ratingTestUser(t, pool, 4_300_000_000+seed, base.Add(2*time.Hour))
|
||||
bot := ratingTestUser(t, pool, 4_400_000_000+seed, base.Add(3*time.Hour))
|
||||
deleted := ratingTestUser(t, pool, 4_500_000_000+seed, base.Add(4*time.Hour))
|
||||
|
||||
if _, err := pool.Exec(ctx, `UPDATE users SET is_bot = true WHERE id = $1`, bot); err != nil {
|
||||
t.Fatalf("mark bot: %v", err)
|
||||
}
|
||||
// A deleted account is a tombstone: every profile field is already cleared, so
|
||||
// there is no rating to show and no reason to compute one.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE users SET deleted_at = now(), deletion_source = 'manual', deletion_reason = 'test',
|
||||
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
|
||||
verified = false, support = false, premium_expires_at = NULL,
|
||||
emoji_status_document_id = 0, emoji_status_until = 0,
|
||||
emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb,
|
||||
color_set = false, color = 0, color_background_emoji_id = 0,
|
||||
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
|
||||
birthday_day = 0, birthday_month = 0, birthday_year = 0,
|
||||
personal_channel_id = 0, last_seen_at = 0, account_delete_at = NULL
|
||||
WHERE id = $1`, deleted); err != nil {
|
||||
t.Fatalf("mark deleted: %v", err)
|
||||
}
|
||||
|
||||
// The table is shared with every other account in the test database, so assert
|
||||
// on relative order and membership rather than on an exact page.
|
||||
positions := func(t *testing.T) (map[int64]int, []int64) {
|
||||
t.Helper()
|
||||
ids, err := store.UnratedAccounts(ctx, maxAccountRatingListLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("UnratedAccounts: %v", err)
|
||||
}
|
||||
index := make(map[int64]int, len(ids))
|
||||
for i, id := range ids {
|
||||
index[id] = i
|
||||
}
|
||||
return index, ids
|
||||
}
|
||||
|
||||
index, ids := positions(t)
|
||||
for _, id := range []int64{oldest, middle, newest} {
|
||||
if _, ok := index[id]; !ok {
|
||||
t.Fatalf("account %d with no projection is absent from %d candidates", id, len(ids))
|
||||
}
|
||||
}
|
||||
if _, ok := index[bot]; ok {
|
||||
t.Fatalf("bot %d was offered as a rating candidate", bot)
|
||||
}
|
||||
if _, ok := index[deleted]; ok {
|
||||
t.Fatalf("deleted account %d was offered as a rating candidate", deleted)
|
||||
}
|
||||
// The service accounts are infrastructure. The platform account in particular is
|
||||
// NOT flagged is_bot, so excluding bots alone would still have seeded it -- which
|
||||
// is exactly how it got a rating.
|
||||
for _, serviceID := range domain.SystemUserIDs() {
|
||||
if _, ok := index[serviceID]; ok {
|
||||
t.Fatalf("service account %d was offered as a rating candidate", serviceID)
|
||||
}
|
||||
}
|
||||
if !(index[oldest] < index[middle] && index[middle] < index[newest]) {
|
||||
t.Fatalf("candidate order = oldest %d, middle %d, newest %d; want oldest first",
|
||||
index[oldest], index[middle], index[newest])
|
||||
}
|
||||
|
||||
// Seeding one account removes it from the candidate set, so the pass converges
|
||||
// instead of offering the same user every cycle.
|
||||
if _, changed, err := store.SaveAccountRating(ctx, domain.AccountRating{
|
||||
UserID: middle, Level: 1, Stars: 150,
|
||||
CurrentLevelStars: domain.AccountRatingLevelThreshold(1),
|
||||
ComputedAt: time.Now().UTC(), Version: 1,
|
||||
}); err != nil || !changed {
|
||||
t.Fatalf("seed projection = %v changed=%v", err, changed)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM account_rating WHERE user_id = $1`, middle)
|
||||
})
|
||||
index, _ = positions(t)
|
||||
if _, ok := index[middle]; ok {
|
||||
t.Fatalf("account %d is still a candidate after being seeded", middle)
|
||||
}
|
||||
if _, ok := index[oldest]; !ok {
|
||||
t.Fatalf("seeding %d removed unrelated candidate %d", middle, oldest)
|
||||
}
|
||||
|
||||
// The limit is honoured, so one cycle can never walk the whole users table.
|
||||
capped, err := store.UnratedAccounts(ctx, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("UnratedAccounts with a limit: %v", err)
|
||||
}
|
||||
if len(capped) != 2 {
|
||||
t.Fatalf("limited candidates = %d, want 2", len(capped))
|
||||
}
|
||||
}
|
||||
|
|
@ -70,7 +70,7 @@ func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profi
|
|||
return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: insert user: %w", err)
|
||||
}
|
||||
if usernameLower := strings.ToLower(row.Username); usernameLower != "" {
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, usernameLower); err != nil {
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, row.Username, usernameLower); err != nil {
|
||||
return domain.User{}, domain.BotProfile{}, err
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +139,7 @@ func (s *BotStore) DeleteBotAccount(ctx context.Context, botUserID int64) (domai
|
|||
if err := purgeDeletedAccountPrivateState(ctx, tx, botUserID, now); err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, botUserID, ""); err != nil {
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, botUserID, "", ""); err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: release username: %w", err)
|
||||
}
|
||||
// Drop the bots row so the token can no longer authenticate a login.
|
||||
|
|
|
|||
1416
internal/store/postgres/bot_verification.go
Normal file
1416
internal/store/postgres/bot_verification.go
Normal file
File diff suppressed because it is too large
Load diff
1068
internal/store/postgres/bot_verification_integration_test.go
Normal file
1068
internal/store/postgres/bot_verification_integration_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,6 +2,7 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -123,7 +124,12 @@ SELECT peer_id
|
|||
FROM peer_usernames
|
||||
WHERE username_lower = $1 AND peer_type = 'channel'
|
||||
`, publicUsername)
|
||||
requirePlanContains(t, usernameLookupPlan, "peer_usernames_pkey")
|
||||
// 0150 adds peer_usernames_peer_order_idx, which covers this lookup as an
|
||||
// index-only scan; either index is an acceptable plan, a partition scan is not.
|
||||
if !strings.Contains(usernameLookupPlan, "peer_usernames_pkey") &&
|
||||
!strings.Contains(usernameLookupPlan, "peer_usernames_peer_order_idx") {
|
||||
t.Fatalf("username lookup plan = %s, want a peer_usernames index scan", usernameLookupPlan)
|
||||
}
|
||||
requirePlanNotMatches(t, usernameLookupPlan, `channels_p\d+`)
|
||||
|
||||
usernameChannelDetailPlan := explainText(t, ctx, tx, `
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChan
|
|||
if strings.EqualFold(channel.Username, username) {
|
||||
return domain.Channel{}, domain.ErrChannelNotModified
|
||||
}
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, req.ChannelID, usernameLower); err != nil {
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, req.ChannelID, username, usernameLower); err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, req.ChannelID, username); err != nil {
|
||||
|
|
@ -426,7 +426,7 @@ func (s *ChannelStore) SetChannelUsernameAdmin(ctx context.Context, channelID in
|
|||
if strings.EqualFold(channel.Username, username) {
|
||||
return channel, nil
|
||||
}
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, channelID, usernameLower); err != nil {
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, channelID, username, usernameLower); err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, channelID, username); err != nil {
|
||||
|
|
@ -517,7 +517,17 @@ func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerU
|
|||
}
|
||||
return domain.Channel{}, false, fmt.Errorf("resolve public channel username channel: %w", err)
|
||||
}
|
||||
if !publicPreviewableChannel(ch) || !strings.EqualFold(ch.Username, usernameLower) {
|
||||
if !publicPreviewableChannel(ch) {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
// A collectible row is authoritative for its own name: the channel's scalar
|
||||
// username column only ever mirrors the editable slot, so re-checking it here
|
||||
// would make collectible names unresolvable. The scalar comparison stays for
|
||||
// the editable slot, where it guards against a stale registry row.
|
||||
if !owner.collectible && !strings.EqualFold(ch.Username, usernameLower) {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
if owner.collectible && !owner.active {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
return ch, true, nil
|
||||
|
|
|
|||
|
|
@ -71,8 +71,8 @@ func TestChannelStoreResolvePublicUsernameRejectsStaleIndex(t *testing.T) {
|
|||
t.Fatalf("clear username: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO peer_usernames (username_lower, peer_type, peer_id)
|
||||
VALUES ($1,'channel',$2)
|
||||
INSERT INTO peer_usernames (username_lower, username, peer_type, peer_id, active, editable, sort_order)
|
||||
VALUES ($1,$1,'channel',$2,true,true,0)
|
||||
ON CONFLICT (username_lower) DO UPDATE SET peer_type = EXCLUDED.peer_type, peer_id = EXCLUDED.peer_id, updated_at = now()
|
||||
`, strings.ToLower(publicUsername), publicChannel.ID); err != nil {
|
||||
t.Fatalf("insert stale username index: %v", err)
|
||||
|
|
|
|||
750
internal/store/postgres/collectible_username.go
Normal file
750
internal/store/postgres/collectible_username.go
Normal file
|
|
@ -0,0 +1,750 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
// CollectibleUsernameStore is the PostgreSQL implementation of the collectible
|
||||
// username registry and the asset lifecycle behind it.
|
||||
//
|
||||
// The asset (collectible_usernames) and its registry projection (peer_usernames)
|
||||
// are always written in one transaction: an asset can never be owned without
|
||||
// being resolvable, and a resolvable collectible row always has a live owner.
|
||||
// The asset row is locked with SELECT ... FOR UPDATE before any mutation, and the
|
||||
// name itself is locked through the registry row, so two concurrent commands on
|
||||
// the same name or the same asset serialise instead of interleaving.
|
||||
type CollectibleUsernameStore struct {
|
||||
db sqlcgen.DBTX
|
||||
}
|
||||
|
||||
// NewCollectibleUsernameStore builds the store on a pgx pool or transaction.
|
||||
func NewCollectibleUsernameStore(db sqlcgen.DBTX) *CollectibleUsernameStore {
|
||||
return &CollectibleUsernameStore{db: db}
|
||||
}
|
||||
|
||||
var (
|
||||
_ store.UsernameRegistryStore = (*CollectibleUsernameStore)(nil)
|
||||
_ store.CollectibleUsernameStore = (*CollectibleUsernameStore)(nil)
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCollectibleUsernameListLimit = 50
|
||||
maxCollectibleUsernameListLimit = 200
|
||||
)
|
||||
|
||||
// collectibleUsernameColumns is the asset projection shared by every reader.
|
||||
const collectibleUsernameColumns = `id, username, status, owner_peer_type, owner_peer_id,
|
||||
purchase_date, currency, amount, crypto_currency, crypto_amount, url,
|
||||
original_owner_peer_type, original_owner_peer_id, transfer_count, version,
|
||||
created_at, updated_at`
|
||||
|
||||
// PeerUsernames returns the peer's registry rows in projection order.
|
||||
func (s *CollectibleUsernameStore) PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("collectible username store is not configured")
|
||||
}
|
||||
return listPeerUsernames(ctx, s.db, peer)
|
||||
}
|
||||
|
||||
// PeerUsernamesBatch resolves several peers in one round trip.
|
||||
func (s *CollectibleUsernameStore) PeerUsernamesBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("collectible username store is not configured")
|
||||
}
|
||||
return listPeerUsernamesBatch(ctx, s.db, peers)
|
||||
}
|
||||
|
||||
// SetUsernameActive toggles one collectible row. The editable slot is rejected:
|
||||
// the client owns it through account.updateUsername, not through this path.
|
||||
func (s *CollectibleUsernameStore) SetUsernameActive(ctx context.Context, peer domain.Peer, username string, active bool) (bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return false, fmt.Errorf("collectible username store is not configured")
|
||||
}
|
||||
username = domain.NormalizeUsername(username)
|
||||
if peer.Type == "" || peer.ID <= 0 || username == "" {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
usernameLower := strings.ToLower(username)
|
||||
changed := false
|
||||
err := withTx(ctx, s.db, "set collectible username active", func(tx pgx.Tx) error {
|
||||
current, err := lockPeerUsernamesTx(ctx, tx, peer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE peer_usernames SET active = $4, updated_at = now()
|
||||
WHERE username_lower = $1 AND peer_type = $2 AND peer_id = $3
|
||||
AND collectible_id IS NOT NULL AND active <> $4`,
|
||||
usernameLower, string(peer.Type), peer.ID, active)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update collectible username active: %w", err)
|
||||
}
|
||||
changed = tag.RowsAffected() > 0
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// ReorderUsernames rewrites the peer's username sort order. order carries every
|
||||
// active username the peer has, the editable slot included -- see
|
||||
// domain.ValidateUsernameReorder -- so the editable row is repositioned like any
|
||||
// other and a collectible may end up first.
|
||||
func (s *CollectibleUsernameStore) ReorderUsernames(ctx context.Context, peer domain.Peer, order []string) (bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return false, fmt.Errorf("collectible username store is not configured")
|
||||
}
|
||||
if peer.Type == "" || peer.ID <= 0 {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
changed := false
|
||||
err := withTx(ctx, s.db, "reorder collectible usernames", func(tx pgx.Tx) error {
|
||||
current, err := lockPeerUsernamesTx(ctx, tx, peer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := domain.ApplyUsernameReorder(current, order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
previous := make(map[string]int, len(current))
|
||||
for _, item := range current {
|
||||
previous[strings.ToLower(item.Username)] = item.SortOrder
|
||||
}
|
||||
// Renumbering always happens; "changed" is about what a client can see.
|
||||
changed = !domain.SameUsernameOrder(current, next)
|
||||
for _, item := range next {
|
||||
key := strings.ToLower(item.Username)
|
||||
if key == "" || previous[key] == item.SortOrder {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE peer_usernames SET sort_order = $4, updated_at = now()
|
||||
WHERE username_lower = $1 AND peer_type = $2 AND peer_id = $3`,
|
||||
key, string(peer.Type), peer.ID, item.SortOrder); err != nil {
|
||||
return fmt.Errorf("update username sort order: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// DeactivateAllUsernames clears the active flag on every collectible row, which
|
||||
// is what losing a public surface does to the peer's collectible names. The
|
||||
// editable slot keeps its own flag.
|
||||
func (s *CollectibleUsernameStore) DeactivateAllUsernames(ctx context.Context, peer domain.Peer) (bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return false, fmt.Errorf("collectible username store is not configured")
|
||||
}
|
||||
if peer.Type == "" || peer.ID <= 0 {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE peer_usernames SET active = false, updated_at = now()
|
||||
WHERE peer_type = $1 AND peer_id = $2 AND collectible_id IS NOT NULL AND active`,
|
||||
string(peer.Type), peer.ID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("deactivate collectible usernames: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
// MintCollectibleUsername creates the asset, optionally assigning it in the same
|
||||
// transaction. A non-empty CommandKey makes the mint replay-safe: the recorded
|
||||
// provenance row carries the key, so a retry returns the original asset.
|
||||
func (s *CollectibleUsernameStore) MintCollectibleUsername(ctx context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.CollectibleUsername{}, false, fmt.Errorf("collectible username store is not configured")
|
||||
}
|
||||
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 domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
usernameLower := strings.ToLower(req.Username)
|
||||
var asset domain.CollectibleUsername
|
||||
created := false
|
||||
err := withTx(ctx, s.db, "mint collectible username", func(tx pgx.Tx) error {
|
||||
if replayed, found, err := replayCollectibleUsernameCommand(ctx, tx, req.CommandKey); err != nil {
|
||||
return err
|
||||
} else if found {
|
||||
asset = replayed
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
purchaseDate := req.PurchaseDate.UTC()
|
||||
if req.PurchaseDate.IsZero() {
|
||||
purchaseDate = now
|
||||
}
|
||||
// The registry row locks the name against editable usernames. Occupancy of
|
||||
// the asset itself is decided by the live rows only: 0152 narrowed
|
||||
// uniqueness to status <> 'burned', so a retired name can be issued again
|
||||
// while its burned rows stay as provenance.
|
||||
if _, found, err := getPeerUsernameOwner(ctx, tx, usernameLower, true); err != nil {
|
||||
return err
|
||||
} else if found {
|
||||
return domain.ErrUsernameOccupied
|
||||
}
|
||||
var existing int64
|
||||
switch err := tx.QueryRow(ctx, `
|
||||
SELECT id FROM collectible_usernames
|
||||
WHERE username_lower = $1 AND status <> 'burned' FOR UPDATE`, usernameLower).Scan(&existing); {
|
||||
case err == nil:
|
||||
return domain.ErrUsernameOccupied
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
default:
|
||||
return fmt.Errorf("lock collectible username: %w", err)
|
||||
}
|
||||
owner := req.Owner
|
||||
status := domain.CollectibleUsernameStatusVault
|
||||
if owner.Type != "" {
|
||||
status = domain.CollectibleUsernameStatusOwned
|
||||
count, err := countPeerCollectibleUsernamesTx(ctx, tx, string(owner.Type), owner.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= domain.MaxPeerCollectibleUsernames {
|
||||
return domain.ErrCollectibleUsernameLimit
|
||||
}
|
||||
}
|
||||
var collectibleID int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO collectible_usernames
|
||||
(username, username_lower, status, owner_peer_type, owner_peer_id, purchase_date,
|
||||
currency, amount, crypto_currency, crypto_amount, url,
|
||||
original_owner_peer_type, original_owner_peer_id, transfer_count, version,
|
||||
created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$4,$5,0,1,$12,$12)
|
||||
RETURNING id`,
|
||||
req.Username, usernameLower, string(status), string(owner.Type), owner.ID, purchaseDate,
|
||||
req.Currency, req.Amount, req.CryptoCurrency, req.CryptoAmount, req.URL, now,
|
||||
).Scan(&collectibleID); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return domain.ErrUsernameOccupied
|
||||
}
|
||||
return fmt.Errorf("insert collectible username: %w", err)
|
||||
}
|
||||
if owner.Type != "" {
|
||||
if err := insertCollectiblePeerUsernameTx(ctx, tx, string(owner.Type), owner.ID,
|
||||
req.Username, usernameLower, collectibleID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// A mint that assigns an owner records a single 'mint' row carrying the
|
||||
// recipient: the command key is unique, so one command owns one row.
|
||||
if err := insertCollectibleUsernameTransferTx(ctx, tx, collectibleUsernameTransfer{
|
||||
collectibleID: collectibleID,
|
||||
kind: domain.CollectibleUsernameKindMint,
|
||||
to: owner,
|
||||
currency: req.Currency,
|
||||
amount: req.Amount,
|
||||
actor: req.Actor,
|
||||
reason: req.Reason,
|
||||
commandKey: req.CommandKey,
|
||||
createdAt: now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
loaded, err := collectibleUsernameByIDTx(ctx, tx, collectibleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
asset = loaded
|
||||
created = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
return asset, created, nil
|
||||
}
|
||||
|
||||
// TransferCollectibleUsername moves the asset to req.To, out of the vault or from
|
||||
// the current holder. The old registry row is removed and the new one inserted in
|
||||
// the same transaction, so the name never resolves to the wrong peer.
|
||||
func (s *CollectibleUsernameStore) TransferCollectibleUsername(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.CollectibleUsername{}, false, fmt.Errorf("collectible username store is not configured")
|
||||
}
|
||||
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 domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
usernameLower := strings.ToLower(req.Username)
|
||||
var asset domain.CollectibleUsername
|
||||
changed := false
|
||||
err := withTx(ctx, s.db, "transfer collectible username", func(tx pgx.Tx) error {
|
||||
if replayed, found, err := replayCollectibleUsernameCommand(ctx, tx, req.CommandKey); err != nil {
|
||||
return err
|
||||
} else if found {
|
||||
asset = replayed
|
||||
return nil
|
||||
}
|
||||
current, err := lockCollectibleUsernameTx(ctx, tx, usernameLower)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.Status == domain.CollectibleUsernameStatusBurned {
|
||||
return domain.ErrCollectibleUsernameBurned
|
||||
}
|
||||
if current.Owned() && current.Owner == req.To {
|
||||
asset = current
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := deleteCollectiblePeerUsernameTx(ctx, tx, current.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := countPeerCollectibleUsernamesTx(ctx, tx, string(req.To.Type), req.To.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= domain.MaxPeerCollectibleUsernames {
|
||||
return domain.ErrCollectibleUsernameLimit
|
||||
}
|
||||
if err := insertCollectiblePeerUsernameTx(ctx, tx, string(req.To.Type), req.To.ID,
|
||||
current.Username, usernameLower, current.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
// The original owner is the first holder and is recorded once: a name that
|
||||
// left the vault keeps its provenance across every later move and the burn.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE collectible_usernames
|
||||
SET status = 'owned',
|
||||
owner_peer_type = $2,
|
||||
owner_peer_id = $3,
|
||||
original_owner_peer_type = CASE WHEN original_owner_peer_type = '' THEN $2 ELSE original_owner_peer_type END,
|
||||
original_owner_peer_id = CASE WHEN original_owner_peer_type = '' THEN $3 ELSE original_owner_peer_id END,
|
||||
transfer_count = transfer_count + 1,
|
||||
version = version + 1,
|
||||
updated_at = $4
|
||||
WHERE id = $1`, current.ID, string(req.To.Type), req.To.ID, now); err != nil {
|
||||
return fmt.Errorf("update transferred collectible username: %w", err)
|
||||
}
|
||||
if err := insertCollectibleUsernameTransferTx(ctx, tx, collectibleUsernameTransfer{
|
||||
collectibleID: current.ID,
|
||||
kind: domain.CollectibleUsernameKindTransfer,
|
||||
from: current.Owner,
|
||||
to: req.To,
|
||||
actor: req.Actor,
|
||||
reason: req.Reason,
|
||||
commandKey: req.CommandKey,
|
||||
createdAt: now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
loaded, err := collectibleUsernameByIDTx(ctx, tx, current.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
asset = loaded
|
||||
changed = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
return asset, changed, nil
|
||||
}
|
||||
|
||||
// RevokeCollectibleUsername returns the asset to the vault, or burns it when
|
||||
// req.Burn is set. Either way the registry row goes away, so the name stops
|
||||
// resolving to the former holder; a burn additionally retires the asset.
|
||||
func (s *CollectibleUsernameStore) RevokeCollectibleUsername(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.CollectibleUsername{}, false, fmt.Errorf("collectible username store is not configured")
|
||||
}
|
||||
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 domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
usernameLower := strings.ToLower(req.Username)
|
||||
var asset domain.CollectibleUsername
|
||||
changed := false
|
||||
err := withTx(ctx, s.db, "revoke collectible username", func(tx pgx.Tx) error {
|
||||
if replayed, found, err := replayCollectibleUsernameCommand(ctx, tx, req.CommandKey); err != nil {
|
||||
return err
|
||||
} else if found {
|
||||
asset = replayed
|
||||
return nil
|
||||
}
|
||||
current, err := lockCollectibleUsernameTx(ctx, tx, usernameLower)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.Status == domain.CollectibleUsernameStatusBurned {
|
||||
return domain.ErrCollectibleUsernameBurned
|
||||
}
|
||||
if !req.Burn && !current.Owned() {
|
||||
// Already in the vault: nothing to release, nothing to record.
|
||||
asset = current
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := deleteCollectiblePeerUsernameTx(ctx, tx, current.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
status := domain.CollectibleUsernameStatusVault
|
||||
kind := domain.CollectibleUsernameKindRevoke
|
||||
if req.Burn {
|
||||
status = domain.CollectibleUsernameStatusBurned
|
||||
kind = domain.CollectibleUsernameKindBurn
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE collectible_usernames
|
||||
SET status = $2,
|
||||
owner_peer_type = '',
|
||||
owner_peer_id = 0,
|
||||
version = version + 1,
|
||||
updated_at = $3
|
||||
WHERE id = $1`, current.ID, string(status), now); err != nil {
|
||||
return fmt.Errorf("update revoked collectible username: %w", err)
|
||||
}
|
||||
if err := insertCollectibleUsernameTransferTx(ctx, tx, collectibleUsernameTransfer{
|
||||
collectibleID: current.ID,
|
||||
kind: kind,
|
||||
from: current.Owner,
|
||||
actor: req.Actor,
|
||||
reason: req.Reason,
|
||||
commandKey: req.CommandKey,
|
||||
createdAt: now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
loaded, err := collectibleUsernameByIDTx(ctx, tx, current.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
asset = loaded
|
||||
changed = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
return asset, changed, nil
|
||||
}
|
||||
|
||||
// DeleteCollectibleUsername removes the live asset for a name outright: the
|
||||
// registry row, the asset and its provenance log all go away, and the name
|
||||
// becomes free for any use. This is the operator's escape hatch for a mistaken
|
||||
// issue, as opposed to Revoke+Burn, which retires an asset but keeps its history.
|
||||
//
|
||||
// The command key cannot make this idempotent -- a replay has no record left to
|
||||
// return -- so a second call simply reports deleted=false once no live asset
|
||||
// remains.
|
||||
func (s *CollectibleUsernameStore) DeleteCollectibleUsername(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return false, fmt.Errorf("collectible username store is not configured")
|
||||
}
|
||||
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
|
||||
}
|
||||
usernameLower := strings.ToLower(req.Username)
|
||||
deleted := false
|
||||
err := withTx(ctx, s.db, "delete collectible username", func(tx pgx.Tx) error {
|
||||
var id int64
|
||||
switch err := tx.QueryRow(ctx, `
|
||||
SELECT id FROM collectible_usernames
|
||||
WHERE username_lower = $1 AND status <> 'burned'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE`, usernameLower).Scan(&id); {
|
||||
case err == nil:
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
// Either the name was never issued, or only burned history remains.
|
||||
// Both are "nothing live to delete" rather than an error, so a repeated
|
||||
// command stays safe.
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("lock collectible username for delete: %w", err)
|
||||
}
|
||||
if err := deleteCollectiblePeerUsernameTx(ctx, tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
// collectible_username_transfers references the asset with ON DELETE
|
||||
// CASCADE, so the provenance rows go with it.
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM collectible_usernames WHERE id = $1`, id); err != nil {
|
||||
return fmt.Errorf("delete collectible username: %w", err)
|
||||
}
|
||||
deleted = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// CollectibleUsername looks the asset up by name. A live asset wins; when the
|
||||
// name only has burned rows the newest one is returned, because the provenance of
|
||||
// a retired name still has to be inspectable.
|
||||
func (s *CollectibleUsernameStore) CollectibleUsername(ctx context.Context, username string) (domain.CollectibleUsername, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.CollectibleUsername{}, fmt.Errorf("collectible username store is not configured")
|
||||
}
|
||||
usernameLower := strings.ToLower(domain.NormalizeUsername(username))
|
||||
if usernameLower == "" {
|
||||
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
asset, err := scanCollectibleUsername(s.db.QueryRow(ctx, `
|
||||
SELECT `+collectibleUsernameColumns+`
|
||||
FROM collectible_usernames
|
||||
WHERE username_lower = $1
|
||||
ORDER BY (status <> 'burned') DESC, id DESC
|
||||
LIMIT 1`, usernameLower))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, fmt.Errorf("get collectible username: %w", err)
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
// CollectibleUsernameByID looks the asset up by identity.
|
||||
func (s *CollectibleUsernameStore) CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.CollectibleUsername{}, fmt.Errorf("collectible username store is not configured")
|
||||
}
|
||||
if id <= 0 {
|
||||
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
asset, err := collectibleUsernameByIDTx(ctx, s.db, id)
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, err
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
// ListCollectibleUsernames is the admin listing query with keyset paging on the
|
||||
// asset id, which matches the (status, id DESC) and (owner, id DESC) indexes.
|
||||
func (s *CollectibleUsernameStore) ListCollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("collectible username store is not configured")
|
||||
}
|
||||
if filter.Status != "" && !filter.Status.Valid() {
|
||||
return nil, domain.ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
if filter.Owner.Type != "" && filter.Owner.ID <= 0 {
|
||||
return nil, domain.ErrCollectibleUsernameStateInvalid
|
||||
}
|
||||
limit := filter.Limit
|
||||
if limit <= 0 {
|
||||
limit = defaultCollectibleUsernameListLimit
|
||||
}
|
||||
if limit > maxCollectibleUsernameListLimit {
|
||||
limit = maxCollectibleUsernameListLimit
|
||||
}
|
||||
query := strings.ToLower(domain.NormalizeUsername(filter.Query))
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT `+collectibleUsernameColumns+`
|
||||
FROM collectible_usernames
|
||||
WHERE ($1 = '' OR status = $1)
|
||||
AND ($2 = '' OR (owner_peer_type = $2 AND owner_peer_id = $3))
|
||||
AND ($4 = '' OR username_lower LIKE $5 || '%')
|
||||
AND ($6 = 0 OR id < $6)
|
||||
ORDER BY id DESC
|
||||
LIMIT $7`,
|
||||
string(filter.Status), string(filter.Owner.Type), filter.Owner.ID,
|
||||
query, escapeLike(query), filter.BeforeID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list collectible usernames: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.CollectibleUsername, 0, limit)
|
||||
for rows.Next() {
|
||||
asset, err := scanCollectibleUsername(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan collectible username: %w", err)
|
||||
}
|
||||
out = append(out, asset)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate collectible usernames: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CollectibleUsernameTransfers returns the provenance log, newest first.
|
||||
func (s *CollectibleUsernameStore) CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("collectible username store is not configured")
|
||||
}
|
||||
if collectibleID <= 0 {
|
||||
return nil, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultCollectibleUsernameListLimit
|
||||
}
|
||||
if limit > maxCollectibleUsernameListLimit {
|
||||
limit = maxCollectibleUsernameListLimit
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, collectible_id, kind, from_peer_type, from_peer_id, to_peer_type, to_peer_id,
|
||||
currency, amount, actor, reason, COALESCE(command_key, ''), created_at
|
||||
FROM collectible_username_transfers
|
||||
WHERE collectible_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT $2`, collectibleID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list collectible username transfers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.CollectibleUsernameTransfer, 0, limit)
|
||||
for rows.Next() {
|
||||
var item domain.CollectibleUsernameTransfer
|
||||
var kind, fromType, toType string
|
||||
if err := rows.Scan(&item.ID, &item.CollectibleID, &kind, &fromType, &item.From.ID,
|
||||
&toType, &item.To.ID, &item.Currency, &item.Amount, &item.Actor, &item.Reason,
|
||||
&item.CommandKey, &item.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan collectible username transfer: %w", err)
|
||||
}
|
||||
item.Kind = domain.CollectibleUsernameTransferKind(kind)
|
||||
item.From.Type = domain.PeerType(fromType)
|
||||
item.To.Type = domain.PeerType(toType)
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate collectible username transfers: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type collectibleUsernameTransfer struct {
|
||||
collectibleID int64
|
||||
kind domain.CollectibleUsernameTransferKind
|
||||
from domain.Peer
|
||||
to domain.Peer
|
||||
currency string
|
||||
amount int64
|
||||
actor string
|
||||
reason string
|
||||
commandKey string
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
func insertCollectibleUsernameTransferTx(ctx context.Context, tx pgx.Tx, entry collectibleUsernameTransfer) error {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO collectible_username_transfers
|
||||
(collectible_id, kind, from_peer_type, from_peer_id, to_peer_type, to_peer_id,
|
||||
currency, amount, actor, reason, command_key, created_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NULLIF($11,''),$12)`,
|
||||
entry.collectibleID, string(entry.kind), string(entry.from.Type), entry.from.ID,
|
||||
string(entry.to.Type), entry.to.ID, entry.currency, entry.amount,
|
||||
entry.actor, entry.reason, entry.commandKey, entry.createdAt); err != nil {
|
||||
return fmt.Errorf("insert collectible username transfer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// replayCollectibleUsernameCommand resolves an already-recorded command key to
|
||||
// the asset it touched, which is what makes mint/transfer/revoke retry-safe.
|
||||
//
|
||||
// The transaction-scoped advisory lock serialises commands sharing a key, so two
|
||||
// concurrent retries cannot both pass the lookup and race on the unique
|
||||
// command_key index -- the second one waits and then observes the recorded row.
|
||||
func replayCollectibleUsernameCommand(ctx context.Context, tx pgx.Tx, commandKey string) (domain.CollectibleUsername, bool, error) {
|
||||
if commandKey == "" {
|
||||
return domain.CollectibleUsername{}, false, nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
SELECT pg_advisory_xact_lock(hashtextextended('collectible-username:' || $1::text, 0))`, commandKey); err != nil {
|
||||
return domain.CollectibleUsername{}, false, fmt.Errorf("lock collectible username command: %w", err)
|
||||
}
|
||||
var collectibleID int64
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT collectible_id FROM collectible_username_transfers WHERE command_key = $1`, commandKey).Scan(&collectibleID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.CollectibleUsername{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, fmt.Errorf("lookup collectible username command: %w", err)
|
||||
}
|
||||
asset, err := collectibleUsernameByIDTx(ctx, tx, collectibleID)
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, false, err
|
||||
}
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
// lockCollectibleUsernameTx locks the row a name currently resolves to. After
|
||||
// 0151 one name can carry several burned rows plus at most one live row, so the
|
||||
// live row wins and the newest burned row is the fallback. That keeps a mutation
|
||||
// of a retired name reporting ErrCollectibleUsernameBurned instead of degrading
|
||||
// to a not-found.
|
||||
func lockCollectibleUsernameTx(ctx context.Context, tx pgx.Tx, usernameLower string) (domain.CollectibleUsername, error) {
|
||||
asset, err := scanCollectibleUsername(tx.QueryRow(ctx, `
|
||||
SELECT `+collectibleUsernameColumns+`
|
||||
FROM collectible_usernames
|
||||
WHERE username_lower = $1
|
||||
ORDER BY (status <> 'burned') DESC, id DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE`, usernameLower))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, fmt.Errorf("lock collectible username: %w", err)
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func collectibleUsernameByIDTx(ctx context.Context, db sqlcgen.DBTX, id int64) (domain.CollectibleUsername, error) {
|
||||
asset, err := scanCollectibleUsername(db.QueryRow(ctx, `
|
||||
SELECT `+collectibleUsernameColumns+`
|
||||
FROM collectible_usernames WHERE id = $1`, id))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.CollectibleUsername{}, fmt.Errorf("get collectible username by id: %w", err)
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func scanCollectibleUsername(row pgx.Row) (domain.CollectibleUsername, error) {
|
||||
var asset domain.CollectibleUsername
|
||||
var status, ownerType, originalOwnerType string
|
||||
if err := row.Scan(&asset.ID, &asset.Username, &status, &ownerType, &asset.Owner.ID,
|
||||
&asset.PurchaseDate, &asset.Currency, &asset.Amount, &asset.CryptoCurrency,
|
||||
&asset.CryptoAmount, &asset.URL, &originalOwnerType, &asset.OriginalOwner.ID,
|
||||
&asset.TransferCount, &asset.Version, &asset.CreatedAt, &asset.UpdatedAt); err != nil {
|
||||
return domain.CollectibleUsername{}, err
|
||||
}
|
||||
asset.Status = domain.CollectibleUsernameStatus(status)
|
||||
asset.Owner.Type = domain.PeerType(ownerType)
|
||||
asset.OriginalOwner.Type = domain.PeerType(originalOwnerType)
|
||||
return asset, nil
|
||||
}
|
||||
614
internal/store/postgres/collectible_username_integration_test.go
Normal file
614
internal/store/postgres/collectible_username_integration_test.go
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// collectibleTestUser inserts a bare user row and returns its user peer. The
|
||||
// registry rows for collectibles reference no peer table, but the editable-slot
|
||||
// assertions and the peer-deletion trigger both need a real user.
|
||||
func collectibleTestUser(t *testing.T, pool *pgxpool.Pool, seed int64, username string) domain.Peer {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var id int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO users (access_hash, phone, first_name, username)
|
||||
VALUES ($1, $2, 'collectible test', $3)
|
||||
RETURNING id`, seed, fmt.Sprintf("%d", seed), username).Scan(&id); err != nil {
|
||||
t.Fatalf("insert collectible test user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
|
||||
})
|
||||
return domain.Peer{Type: domain.PeerTypeUser, ID: id}
|
||||
}
|
||||
|
||||
// setEditableUsername installs an editable registry row through the same helper
|
||||
// the client-driven username path uses.
|
||||
func setEditableUsername(t *testing.T, pool *pgxpool.Pool, peer domain.Peer, username string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin editable username: %v", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, peer.ID, username, lowerASCII(username)); err != nil {
|
||||
t.Fatalf("set editable username %q: %v", username, err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
t.Fatalf("commit editable username: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func lowerASCII(s string) string {
|
||||
out := []byte(s)
|
||||
for i := range out {
|
||||
if out[i] >= 'A' && out[i] <= 'Z' {
|
||||
out[i] += 'a' - 'A'
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func cleanupCollectible(t *testing.T, pool *pgxpool.Pool, usernameLower string) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(),
|
||||
`DELETE FROM collectible_usernames WHERE username_lower = $1`, usernameLower)
|
||||
})
|
||||
}
|
||||
|
||||
func mintRequest(username string, owner domain.Peer, commandKey string) domain.MintCollectibleUsernameRequest {
|
||||
return domain.MintCollectibleUsernameRequest{
|
||||
Username: username,
|
||||
Owner: owner,
|
||||
PurchaseDate: time.Now().UTC().Truncate(time.Second),
|
||||
Currency: domain.CollectibleCurrencyStars,
|
||||
Amount: 5000,
|
||||
URL: "https://fragment.example/" + username,
|
||||
Actor: "ops",
|
||||
Reason: "integration test",
|
||||
CommandKey: commandKey,
|
||||
}
|
||||
}
|
||||
|
||||
func registryRows(t *testing.T, pool *pgxpool.Pool, peer domain.Peer) []domain.Username {
|
||||
t.Helper()
|
||||
list, err := listPeerUsernames(context.Background(), pool, peer)
|
||||
if err != nil {
|
||||
t.Fatalf("list peer usernames: %v", err)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// TestCollectibleUsernameMintIntoVault covers a vault mint: the asset exists, the
|
||||
// name is not projected into any peer's registry, and the provenance log records
|
||||
// the mint.
|
||||
func TestCollectibleUsernameMintIntoVault(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewCollectibleUsernameStore(pool)
|
||||
name := fmt.Sprintf("vault%d", time.Now().UnixNano()%1_000_000)
|
||||
cleanupCollectible(t, pool, lowerASCII(name))
|
||||
|
||||
asset, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, domain.Peer{}, ""))
|
||||
if err != nil || !created {
|
||||
t.Fatalf("mint into vault created=%v err=%v", created, err)
|
||||
}
|
||||
if asset.Status != domain.CollectibleUsernameStatusVault || asset.Owned() ||
|
||||
asset.Version != 1 || asset.TransferCount != 0 || asset.Username != name {
|
||||
t.Fatalf("vault asset = %+v", asset)
|
||||
}
|
||||
if err := asset.Validate(); err != nil {
|
||||
t.Fatalf("vault asset invariants: %v", err)
|
||||
}
|
||||
var registry int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM peer_usernames WHERE collectible_id = $1`, asset.ID).Scan(®istry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if registry != 0 {
|
||||
t.Fatalf("vault asset must not be projected, got %d registry rows", registry)
|
||||
}
|
||||
transfers, err := store.CollectibleUsernameTransfers(ctx, asset.ID, 10)
|
||||
if err != nil || len(transfers) != 1 || transfers[0].Kind != domain.CollectibleUsernameKindMint {
|
||||
t.Fatalf("transfers=%+v err=%v", transfers, err)
|
||||
}
|
||||
// A vault revoke has nothing to release and must stay a no-op.
|
||||
same, changed, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{Username: name})
|
||||
if err != nil || changed || same.Version != asset.Version {
|
||||
t.Fatalf("vault revoke changed=%v asset=%+v err=%v", changed, same, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectibleUsernameMintWithOwnerAndReplay covers a mint that assigns the
|
||||
// asset immediately, and the command-key replay that must return the recorded
|
||||
// state instead of minting twice.
|
||||
func TestCollectibleUsernameMintWithOwnerAndReplay(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewCollectibleUsernameStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
owner := collectibleTestUser(t, pool, 2_100_000_000+seed, "")
|
||||
name := fmt.Sprintf("owned%d", seed)
|
||||
cleanupCollectible(t, pool, lowerASCII(name))
|
||||
key := fmt.Sprintf("mint-%d", seed)
|
||||
|
||||
asset, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, owner, key))
|
||||
if err != nil || !created {
|
||||
t.Fatalf("mint with owner created=%v err=%v", created, err)
|
||||
}
|
||||
if asset.Status != domain.CollectibleUsernameStatusOwned || asset.Owner != owner ||
|
||||
asset.OriginalOwner != owner || asset.TransferCount != 0 {
|
||||
t.Fatalf("owned asset = %+v", asset)
|
||||
}
|
||||
if err := asset.Validate(); err != nil {
|
||||
t.Fatalf("owned asset invariants: %v", err)
|
||||
}
|
||||
list := registryRows(t, pool, owner)
|
||||
if len(list) != 1 || list[0].Username != name || list[0].Editable ||
|
||||
!list[0].Active || list[0].CollectibleID != asset.ID {
|
||||
t.Fatalf("registry = %+v", list)
|
||||
}
|
||||
|
||||
replay, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, owner, key))
|
||||
if err != nil || created || replay.ID != asset.ID || replay.Version != asset.Version {
|
||||
t.Fatalf("replay created=%v asset=%+v err=%v", created, replay, err)
|
||||
}
|
||||
var assets int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM collectible_usernames WHERE username_lower = $1`, lowerASCII(name)).Scan(&assets); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if assets != 1 {
|
||||
t.Fatalf("replay must not mint again, got %d assets", assets)
|
||||
}
|
||||
|
||||
// The same name cannot be minted twice, even under a different command key.
|
||||
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, domain.Peer{}, key+"-again")); !errors.Is(err, domain.ErrUsernameOccupied) {
|
||||
t.Fatalf("duplicate mint err = %v, want ErrUsernameOccupied", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectibleUsernameMintRejectsOccupiedEditableName proves the collectible
|
||||
// registry and the editable slot share one occupancy namespace.
|
||||
func TestCollectibleUsernameMintRejectsOccupiedEditableName(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewCollectibleUsernameStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
holder := collectibleTestUser(t, pool, 2_200_000_000+seed, "")
|
||||
name := fmt.Sprintf("taken%d", seed)
|
||||
setEditableUsername(t, pool, holder, name)
|
||||
cleanupCollectible(t, pool, lowerASCII(name))
|
||||
|
||||
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, domain.Peer{}, "")); !errors.Is(err, domain.ErrUsernameOccupied) {
|
||||
t.Fatalf("mint over editable name err = %v, want ErrUsernameOccupied", err)
|
||||
}
|
||||
if _, err := store.CollectibleUsername(ctx, name); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
|
||||
t.Fatalf("rejected mint must leave no asset, err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectibleUsernameTransferPreservesRecipientEditableSlot is the core
|
||||
// regression: moving an asset must not disturb either peer's editable username.
|
||||
func TestCollectibleUsernameTransferPreservesRecipientEditableSlot(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewCollectibleUsernameStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
from := collectibleTestUser(t, pool, 2_300_000_000+seed, "")
|
||||
to := collectibleTestUser(t, pool, 2_400_000_000+seed, "")
|
||||
fromEditable := fmt.Sprintf("sender%d", seed)
|
||||
toEditable := fmt.Sprintf("recip%d", seed)
|
||||
setEditableUsername(t, pool, from, fromEditable)
|
||||
setEditableUsername(t, pool, to, toEditable)
|
||||
name := fmt.Sprintf("moved%d", seed)
|
||||
cleanupCollectible(t, pool, lowerASCII(name))
|
||||
|
||||
asset, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, from, ""))
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
key := fmt.Sprintf("transfer-%d", seed)
|
||||
moved, changed, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
|
||||
Username: name, To: to, Actor: "ops", Reason: "sold", CommandKey: key,
|
||||
})
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("transfer changed=%v err=%v", changed, err)
|
||||
}
|
||||
if moved.Owner != to || moved.OriginalOwner != from || moved.TransferCount != 1 ||
|
||||
moved.Version != asset.Version+1 {
|
||||
t.Fatalf("transferred asset = %+v", moved)
|
||||
}
|
||||
|
||||
fromList := registryRows(t, pool, from)
|
||||
if len(fromList) != 1 || fromList[0].Username != fromEditable || !fromList[0].Editable {
|
||||
t.Fatalf("sender registry = %+v, editable slot must survive", fromList)
|
||||
}
|
||||
toList := registryRows(t, pool, to)
|
||||
if len(toList) != 2 || toList[0].Username != toEditable || !toList[0].Editable ||
|
||||
toList[1].Username != name || !toList[1].Collectible() {
|
||||
t.Fatalf("recipient registry = %+v", toList)
|
||||
}
|
||||
|
||||
replay, changed, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
|
||||
Username: name, To: to, CommandKey: key,
|
||||
})
|
||||
if err != nil || changed || replay.Version != moved.Version {
|
||||
t.Fatalf("transfer replay changed=%v asset=%+v err=%v", changed, replay, err)
|
||||
}
|
||||
|
||||
// Revoking back to the vault releases the recipient's registry row only.
|
||||
vaulted, changed, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
|
||||
Username: name, Actor: "ops", Reason: "recalled", CommandKey: key + "-revoke",
|
||||
})
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("revoke changed=%v err=%v", changed, err)
|
||||
}
|
||||
if vaulted.Status != domain.CollectibleUsernameStatusVault || vaulted.Owned() ||
|
||||
vaulted.OriginalOwner != from {
|
||||
t.Fatalf("revoked asset = %+v", vaulted)
|
||||
}
|
||||
toList = registryRows(t, pool, to)
|
||||
if len(toList) != 1 || toList[0].Username != toEditable {
|
||||
t.Fatalf("recipient registry after revoke = %+v", toList)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectibleUsernameBurnReleasesName covers a burn: the asset is retired and
|
||||
// the name stops resolving, so an ordinary peer can claim it as its editable slot.
|
||||
func TestCollectibleUsernameBurnReleasesName(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewCollectibleUsernameStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
holder := collectibleTestUser(t, pool, 2_500_000_000+seed, "")
|
||||
claimer := collectibleTestUser(t, pool, 2_600_000_000+seed, "")
|
||||
name := fmt.Sprintf("burned%d", seed)
|
||||
cleanupCollectible(t, pool, lowerASCII(name))
|
||||
|
||||
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
burned, changed, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
|
||||
Username: name, Burn: true, Actor: "ops", Reason: "abuse",
|
||||
})
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("burn changed=%v err=%v", changed, err)
|
||||
}
|
||||
if burned.Status != domain.CollectibleUsernameStatusBurned || burned.Owned() {
|
||||
t.Fatalf("burned asset = %+v", burned)
|
||||
}
|
||||
if list := registryRows(t, pool, holder); len(list) != 0 {
|
||||
t.Fatalf("holder registry after burn = %+v", list)
|
||||
}
|
||||
// The freed name is claimable as an ordinary editable username.
|
||||
setEditableUsername(t, pool, claimer, name)
|
||||
if list := registryRows(t, pool, claimer); len(list) != 1 || !list[0].Editable || list[0].Username != name {
|
||||
t.Fatalf("claimer registry = %+v", list)
|
||||
}
|
||||
// Every further mutation of a burned asset is refused.
|
||||
if _, _, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
|
||||
Username: name, To: holder,
|
||||
}); !errors.Is(err, domain.ErrCollectibleUsernameBurned) {
|
||||
t.Fatalf("transfer of burned asset err = %v, want ErrCollectibleUsernameBurned", err)
|
||||
}
|
||||
if _, _, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
|
||||
Username: name, Burn: true,
|
||||
}); !errors.Is(err, domain.ErrCollectibleUsernameBurned) {
|
||||
t.Fatalf("re-burn err = %v, want ErrCollectibleUsernameBurned", err)
|
||||
}
|
||||
if _, _, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
|
||||
Username: fmt.Sprintf("ghost%d", seed), To: holder,
|
||||
}); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
|
||||
t.Fatalf("transfer of unknown asset err = %v, want ErrCollectibleUsernameNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectibleUsernamePeerLimit covers the per-peer bound on both entry paths:
|
||||
// minting straight to the holder and transferring into it.
|
||||
func TestCollectibleUsernamePeerLimit(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewCollectibleUsernameStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
holder := collectibleTestUser(t, pool, 2_700_000_000+seed, "")
|
||||
prefix := fmt.Sprintf("lim%d", seed)
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(),
|
||||
`DELETE FROM collectible_usernames WHERE username_lower LIKE $1 || '%'`, lowerASCII(prefix))
|
||||
})
|
||||
for i := 0; i < domain.MaxPeerCollectibleUsernames; i++ {
|
||||
name := fmt.Sprintf("%sn%02d", prefix, i)
|
||||
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); err != nil {
|
||||
t.Fatalf("mint %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if list := registryRows(t, pool, holder); len(list) != domain.MaxPeerCollectibleUsernames {
|
||||
t.Fatalf("registry size = %d", len(list))
|
||||
}
|
||||
overflow := prefix + "over"
|
||||
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(overflow, holder, "")); !errors.Is(err, domain.ErrCollectibleUsernameLimit) {
|
||||
t.Fatalf("mint over limit err = %v, want ErrCollectibleUsernameLimit", err)
|
||||
}
|
||||
if _, err := store.CollectibleUsername(ctx, overflow); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
|
||||
t.Fatalf("rejected mint must not leave an asset: %v", err)
|
||||
}
|
||||
vaulted, _, err := store.MintCollectibleUsername(ctx, mintRequest(prefix+"vault", domain.Peer{}, ""))
|
||||
if err != nil {
|
||||
t.Fatalf("mint vault asset: %v", err)
|
||||
}
|
||||
if _, _, err := store.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
|
||||
Username: vaulted.Username, To: holder,
|
||||
}); !errors.Is(err, domain.ErrCollectibleUsernameLimit) {
|
||||
t.Fatalf("transfer over limit err = %v, want ErrCollectibleUsernameLimit", err)
|
||||
}
|
||||
// The refused transfer must not have released the asset from the vault.
|
||||
after, err := store.CollectibleUsername(ctx, vaulted.Username)
|
||||
if err != nil || after.Status != domain.CollectibleUsernameStatusVault {
|
||||
t.Fatalf("vault asset after refused transfer = %+v err=%v", after, err)
|
||||
}
|
||||
owned, err := store.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
|
||||
Owner: holder, Status: domain.CollectibleUsernameStatusOwned, Limit: 100,
|
||||
})
|
||||
if err != nil || len(owned) != domain.MaxPeerCollectibleUsernames {
|
||||
t.Fatalf("list owned = %d err=%v", len(owned), err)
|
||||
}
|
||||
prefixed, err := store.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
|
||||
Query: prefix + "n0", Limit: 100,
|
||||
})
|
||||
if err != nil || len(prefixed) != 10 {
|
||||
t.Fatalf("list by prefix = %d err=%v", len(prefixed), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectibleUsernameRegistryToggleAndReorder covers the registry-only
|
||||
// surface: activation, ordering and the bulk deactivation, none of which may
|
||||
// touch the editable slot.
|
||||
func TestCollectibleUsernameRegistryToggleAndReorder(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewCollectibleUsernameStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
holder := collectibleTestUser(t, pool, 2_800_000_000+seed, "")
|
||||
editable := fmt.Sprintf("edit%d", seed)
|
||||
setEditableUsername(t, pool, holder, editable)
|
||||
first := fmt.Sprintf("alpha%d", seed)
|
||||
second := fmt.Sprintf("beta%d", seed)
|
||||
cleanupCollectible(t, pool, lowerASCII(first))
|
||||
cleanupCollectible(t, pool, lowerASCII(second))
|
||||
for _, name := range []string{first, second} {
|
||||
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); err != nil {
|
||||
t.Fatalf("mint %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
changed, err := store.SetUsernameActive(ctx, holder, first, false)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("deactivate collectible changed=%v err=%v", changed, err)
|
||||
}
|
||||
if _, err := store.SetUsernameActive(ctx, holder, editable, false); !errors.Is(err, domain.ErrUsernameNotCollectible) {
|
||||
t.Fatalf("toggling the editable slot err = %v, want ErrUsernameNotCollectible", err)
|
||||
}
|
||||
// first is inactive now, so a client would send only the active names; the
|
||||
// editable slot is one of them and listing it is required, not rejected.
|
||||
changed, err = store.ReorderUsernames(ctx, holder, []string{editable, second})
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("reorder changed=%v err=%v", changed, err)
|
||||
}
|
||||
list := registryRows(t, pool, holder)
|
||||
if len(list) != 3 || list[0].Username != editable || list[1].Username != second || list[2].Username != first {
|
||||
t.Fatalf("registry order = %+v", list)
|
||||
}
|
||||
// Re-sending the order the peer already has is a no-op a client can repeat.
|
||||
if changed, err := store.ReorderUsernames(ctx, holder, []string{editable, second}); err != nil || changed {
|
||||
t.Fatalf("repeat reorder changed=%v err=%v", changed, err)
|
||||
}
|
||||
// A collectible may be promoted above the editable slot, which is what makes
|
||||
// it the peer's primary username for clients.
|
||||
changed, err = store.ReorderUsernames(ctx, holder, []string{second, editable})
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("promote collectible changed=%v err=%v", changed, err)
|
||||
}
|
||||
list = registryRows(t, pool, holder)
|
||||
if len(list) != 3 || list[0].Username != second || list[1].Username != editable {
|
||||
t.Fatalf("registry order after promoting a collectible = %+v", list)
|
||||
}
|
||||
if domain.ActiveUsername(list) != second {
|
||||
t.Fatalf("active username after promoting a collectible = %q, want %q", domain.ActiveUsername(list), second)
|
||||
}
|
||||
changed, err = store.ReorderUsernames(ctx, holder, []string{editable, second})
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("restore order changed=%v err=%v", changed, err)
|
||||
}
|
||||
// An order that omits an active username is still rejected, and so is one
|
||||
// naming something the peer does not own.
|
||||
if _, err := store.ReorderUsernames(ctx, holder, []string{second}); !errors.Is(err, domain.ErrUsernameOrderInvalid) {
|
||||
t.Fatalf("partial reorder err = %v, want ErrUsernameOrderInvalid", err)
|
||||
}
|
||||
if _, err := store.ReorderUsernames(ctx, holder, []string{editable, second, "nobodyowns" + editable}); !errors.Is(err, domain.ErrUsernameOrderInvalid) {
|
||||
t.Fatalf("reorder with a foreign name err = %v, want ErrUsernameOrderInvalid", err)
|
||||
}
|
||||
changed, err = store.DeactivateAllUsernames(ctx, holder)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("deactivate all changed=%v err=%v", changed, err)
|
||||
}
|
||||
list = registryRows(t, pool, holder)
|
||||
if len(list) != 3 || !list[0].Active || list[1].Active || list[2].Active {
|
||||
t.Fatalf("registry after deactivate all = %+v", list)
|
||||
}
|
||||
batch, err := store.PeerUsernamesBatch(ctx, []domain.Peer{holder, {Type: domain.PeerTypeUser, ID: holder.ID + 1}})
|
||||
if err != nil || len(batch[holder]) != 3 {
|
||||
t.Fatalf("batch = %+v err=%v", batch, err)
|
||||
}
|
||||
if domain.ActiveUsername(batch[holder]) != editable {
|
||||
t.Fatalf("active username = %q", domain.ActiveUsername(batch[holder]))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectibleUsernameEditableEditKeepsCollectibles pins the peer_username.go
|
||||
// surgery: rewriting or clearing the editable slot must leave collectible rows
|
||||
// and their assets untouched.
|
||||
func TestCollectibleUsernameEditableEditKeepsCollectibles(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewCollectibleUsernameStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
holder := collectibleTestUser(t, pool, 2_900_000_000+seed, "")
|
||||
name := fmt.Sprintf("keep%d", seed)
|
||||
cleanupCollectible(t, pool, lowerASCII(name))
|
||||
asset, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, ""))
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
setEditableUsername(t, pool, holder, fmt.Sprintf("one%d", seed))
|
||||
setEditableUsername(t, pool, holder, fmt.Sprintf("two%d", seed))
|
||||
setEditableUsername(t, pool, holder, "")
|
||||
list := registryRows(t, pool, holder)
|
||||
if len(list) != 1 || list[0].CollectibleID != asset.ID {
|
||||
t.Fatalf("registry after editable churn = %+v", list)
|
||||
}
|
||||
stored, err := store.CollectibleUsernameByID(ctx, asset.ID)
|
||||
if err != nil || stored.Owner != holder {
|
||||
t.Fatalf("asset after editable churn = %+v err=%v", stored, err)
|
||||
}
|
||||
// The editable slot may not duplicate a name the peer holds as a collectible.
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, holder.ID, name, lowerASCII(name)); !errors.Is(err, domain.ErrUsernameOccupied) {
|
||||
t.Fatalf("editable slot over own collectible err = %v, want ErrUsernameOccupied", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectibleUsernameReissueAfterBurn covers migration 0152: uniqueness now
|
||||
// spans live assets only, so a burned name can be issued again while its burned
|
||||
// rows remain as provenance.
|
||||
func TestCollectibleUsernameReissueAfterBurn(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewCollectibleUsernameStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
holder := collectibleTestUser(t, pool, 3_500_000_000+seed, "")
|
||||
name := fmt.Sprintf("reissue%d", seed)
|
||||
cleanupCollectible(t, pool, lowerASCII(name))
|
||||
|
||||
first, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, ""))
|
||||
if err != nil {
|
||||
t.Fatalf("first mint: %v", err)
|
||||
}
|
||||
if _, _, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
|
||||
Username: name, Burn: true, Actor: "ops", Reason: "retire",
|
||||
}); err != nil {
|
||||
t.Fatalf("burn: %v", err)
|
||||
}
|
||||
|
||||
second, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, ""))
|
||||
if err != nil || !created {
|
||||
t.Fatalf("reissue: created=%v err=%v", created, err)
|
||||
}
|
||||
if second.ID == first.ID {
|
||||
t.Fatalf("reissue reused asset id %d", second.ID)
|
||||
}
|
||||
// Both rows coexist: the live one is what the name resolves to.
|
||||
live, err := store.CollectibleUsername(ctx, name)
|
||||
if err != nil || live.ID != second.ID {
|
||||
t.Fatalf("lookup after reissue = %+v err=%v, want live asset %d", live, err, second.ID)
|
||||
}
|
||||
burned, err := store.CollectibleUsernameByID(ctx, first.ID)
|
||||
if err != nil || burned.Status != domain.CollectibleUsernameStatusBurned {
|
||||
t.Fatalf("burned provenance row = %+v err=%v", burned, err)
|
||||
}
|
||||
var rowCount int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM collectible_usernames WHERE username_lower = $1`,
|
||||
lowerASCII(name)).Scan(&rowCount); err != nil {
|
||||
t.Fatalf("count rows: %v", err)
|
||||
}
|
||||
if rowCount != 2 {
|
||||
t.Fatalf("rows for reissued name = %d, want 2", rowCount)
|
||||
}
|
||||
// The live asset still blocks another mint.
|
||||
if _, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); !errors.Is(err, domain.ErrUsernameOccupied) {
|
||||
t.Fatalf("mint over live asset err = %v, want ErrUsernameOccupied", err)
|
||||
}
|
||||
if list := registryRows(t, pool, holder); len(list) != 1 || list[0].CollectibleID != second.ID {
|
||||
t.Fatalf("holder registry after reissue = %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectibleUsernameDelete covers the hard delete: asset, registry row and
|
||||
// provenance all disappear and the name becomes fully free.
|
||||
func TestCollectibleUsernameDelete(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewCollectibleUsernameStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
holder := collectibleTestUser(t, pool, 3_600_000_000+seed, "")
|
||||
name := fmt.Sprintf("mistake%d", seed)
|
||||
cleanupCollectible(t, pool, lowerASCII(name))
|
||||
editable := fmt.Sprintf("keep%d", seed)
|
||||
setEditableUsername(t, pool, holder, editable)
|
||||
|
||||
asset, _, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, ""))
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
deleted, err := store.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
|
||||
Username: "@" + name, Actor: "ops", Reason: "issued by mistake",
|
||||
})
|
||||
if err != nil || !deleted {
|
||||
t.Fatalf("delete: deleted=%v err=%v", deleted, err)
|
||||
}
|
||||
if _, err := store.CollectibleUsernameByID(ctx, asset.ID); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
|
||||
t.Fatalf("asset after delete err = %v, want not found", err)
|
||||
}
|
||||
// ON DELETE CASCADE took the provenance rows with the asset.
|
||||
var transfers int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM collectible_username_transfers WHERE collectible_id = $1`,
|
||||
asset.ID).Scan(&transfers); err != nil {
|
||||
t.Fatalf("count transfers: %v", err)
|
||||
}
|
||||
if transfers != 0 {
|
||||
t.Fatalf("provenance rows after delete = %d, want 0", transfers)
|
||||
}
|
||||
// The holder keeps its editable slot and loses only the collectible row.
|
||||
list := registryRows(t, pool, holder)
|
||||
if len(list) != 1 || !list[0].Editable || list[0].Username != editable {
|
||||
t.Fatalf("holder registry after delete = %+v", list)
|
||||
}
|
||||
// The name is free again, with no burned history left behind.
|
||||
if _, created, err := store.MintCollectibleUsername(ctx, mintRequest(name, holder, "")); err != nil || !created {
|
||||
t.Fatalf("mint after delete: created=%v err=%v", created, err)
|
||||
}
|
||||
// Deleting a name that has no live asset is a no-op, not an error.
|
||||
if _, _, err := store.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
|
||||
Username: name, Burn: true, Actor: "ops", Reason: "retire",
|
||||
}); err != nil {
|
||||
t.Fatalf("burn before repeat delete: %v", err)
|
||||
}
|
||||
deleted, err = store.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
|
||||
Username: name, Actor: "ops", Reason: "again",
|
||||
})
|
||||
if err != nil || deleted {
|
||||
t.Fatalf("delete of burned-only name = %v err=%v, want (false, nil)", deleted, err)
|
||||
}
|
||||
deleted, err = store.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
|
||||
Username: fmt.Sprintf("absent%d", seed), Actor: "ops", Reason: "again",
|
||||
})
|
||||
if err != nil || deleted {
|
||||
t.Fatalf("delete of unknown name = %v err=%v, want (false, nil)", deleted, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -99,13 +99,15 @@ WITH desired (
|
|||
EXCLUDED.about, EXCLUDED.is_bot, EXCLUDED.bot_info_version
|
||||
)
|
||||
)
|
||||
INSERT INTO peer_usernames (username_lower, peer_type, peer_id)
|
||||
SELECT lower(username), 'user', id
|
||||
INSERT INTO peer_usernames (username_lower, username, peer_type, peer_id, active, editable, sort_order)
|
||||
SELECT lower(username), username, 'user', id, true, true, 0
|
||||
FROM desired
|
||||
ON CONFLICT (peer_type, peer_id) DO UPDATE SET
|
||||
ON CONFLICT (peer_type, peer_id) WHERE editable DO UPDATE SET
|
||||
username_lower = EXCLUDED.username_lower,
|
||||
username = EXCLUDED.username,
|
||||
updated_at = now()
|
||||
WHERE peer_usernames.username_lower IS DISTINCT FROM EXCLUDED.username_lower
|
||||
WHERE (peer_usernames.username_lower, peer_usernames.username)
|
||||
IS DISTINCT FROM (EXCLUDED.username_lower, EXCLUDED.username)
|
||||
`, u.ID, u.AccessHash, u.Phone, u.FirstName, u.LastName, u.Username, u.CountryCode, u.Verified, u.Support, u.About, u.Bot, u.BotInfoVersion); err != nil {
|
||||
return fmt.Errorf("ensure official system user: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,25 +18,40 @@ const (
|
|||
peerUsernameTypeChannel = "channel"
|
||||
)
|
||||
|
||||
// peerUsernameColumns is the registry projection shared by every reader. The
|
||||
// collectible id is coalesced so domain.Username keeps a plain int64 zero for
|
||||
// the editable slot.
|
||||
const peerUsernameColumns = `username, active, editable, sort_order, COALESCE(collectible_id, 0)`
|
||||
|
||||
type peerUsernameOwner struct {
|
||||
peerType string
|
||||
peerID int64
|
||||
// collectible marks a row backed by a collectible asset. Such a row is never
|
||||
// editable, so the client-driven username path must not reuse or delete it.
|
||||
collectible bool
|
||||
// active mirrors the registry flag. An inactive name stays occupied for
|
||||
// uniqueness purposes but must not resolve to its holder.
|
||||
active bool
|
||||
}
|
||||
|
||||
func (o peerUsernameOwner) matches(peerType string, peerID int64) bool {
|
||||
return o.peerType == peerType && o.peerID == peerID
|
||||
}
|
||||
|
||||
// getPeerUsernameOwner resolves the holder of a name across the whole registry:
|
||||
// username uniqueness is global and covers collectible rows as well as editable
|
||||
// ones, so occupancy checks and username resolution keep a single source of
|
||||
// truth.
|
||||
func getPeerUsernameOwner(ctx context.Context, db sqlcgen.DBTX, usernameLower string, forUpdate bool) (peerUsernameOwner, bool, error) {
|
||||
if usernameLower == "" {
|
||||
return peerUsernameOwner{}, false, nil
|
||||
}
|
||||
query := `SELECT peer_type, peer_id FROM peer_usernames WHERE username_lower = $1`
|
||||
query := `SELECT peer_type, peer_id, collectible_id IS NOT NULL, active FROM peer_usernames WHERE username_lower = $1`
|
||||
if forUpdate {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
var owner peerUsernameOwner
|
||||
err := db.QueryRow(ctx, query, usernameLower).Scan(&owner.peerType, &owner.peerID)
|
||||
err := db.QueryRow(ctx, query, usernameLower).Scan(&owner.peerType, &owner.peerID, &owner.collectible, &owner.active)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return peerUsernameOwner{}, false, nil
|
||||
|
|
@ -54,25 +69,35 @@ func peerUsernameAvailable(ctx context.Context, db sqlcgen.DBTX, usernameLower,
|
|||
return owner.matches(peerType, peerID), nil
|
||||
}
|
||||
|
||||
func replacePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, usernameLower string) error {
|
||||
// replacePeerUsernameTx rewrites the peer's editable username slot. username is
|
||||
// the display form (original case) and usernameLower its registry key; an empty
|
||||
// pair clears the slot.
|
||||
//
|
||||
// Only the editable row is replaced. Collectible rows belong to assets in
|
||||
// collectible_usernames and must survive every client-driven username edit,
|
||||
// otherwise account.updateUsername would silently release a minted asset.
|
||||
func replacePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, username, usernameLower string) error {
|
||||
if usernameLower != "" {
|
||||
owner, found, err := getPeerUsernameOwner(ctx, tx, usernameLower, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found && !owner.matches(peerType, peerID) {
|
||||
// A collectible row occupies the name even for its own holder: the
|
||||
// editable slot cannot duplicate a name the peer already holds as an asset.
|
||||
if found && (!owner.matches(peerType, peerID) || owner.collectible) {
|
||||
return domain.ErrUsernameOccupied
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM peer_usernames WHERE peer_type = $1 AND peer_id = $2`, peerType, peerID); err != nil {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM peer_usernames WHERE peer_type = $1 AND peer_id = $2 AND editable`, peerType, peerID); err != nil {
|
||||
return fmt.Errorf("delete peer username: %w", err)
|
||||
}
|
||||
if usernameLower == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO peer_usernames (username_lower, peer_type, peer_id)
|
||||
VALUES ($1, $2, $3)`, usernameLower, peerType, peerID); err != nil {
|
||||
INSERT INTO peer_usernames (username_lower, peer_type, peer_id, username, active, editable, sort_order, collectible_id)
|
||||
VALUES ($1, $2, $3, $4, true, true, 0, NULL)`, usernameLower, peerType, peerID, username); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return domain.ErrUsernameOccupied
|
||||
}
|
||||
|
|
@ -81,13 +106,164 @@ VALUES ($1, $2, $3)`, usernameLower, peerType, peerID); err != nil {
|
|||
return nil
|
||||
}
|
||||
|
||||
// deletePeerUsernameTx clears the peer's editable slot only. Collectible rows are
|
||||
// released through the asset lifecycle (revoke/burn) or by the peer-deletion
|
||||
// trigger, never by an editable-slot edit.
|
||||
func deletePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64) error {
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM peer_usernames WHERE peer_type = $1 AND peer_id = $2`, peerType, peerID); err != nil {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM peer_usernames WHERE peer_type = $1 AND peer_id = $2 AND editable`, peerType, peerID); err != nil {
|
||||
return fmt.Errorf("delete peer username: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listPeerUsernames returns the peer's registry rows in projection order:
|
||||
// editable slot first, then collectibles by stored sort order.
|
||||
func listPeerUsernames(ctx context.Context, db sqlcgen.DBTX, peer domain.Peer) ([]domain.Username, error) {
|
||||
if peer.Type == "" || peer.ID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT `+peerUsernameColumns+`
|
||||
FROM peer_usernames
|
||||
WHERE peer_type = $1 AND peer_id = $2
|
||||
ORDER BY editable DESC, sort_order, username_lower`, string(peer.Type), peer.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list peer usernames: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.Username, 0, 4)
|
||||
for rows.Next() {
|
||||
var item domain.Username
|
||||
if err := rows.Scan(&item.Username, &item.Active, &item.Editable, &item.SortOrder, &item.CollectibleID); err != nil {
|
||||
return nil, fmt.Errorf("scan peer username: %w", err)
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate peer usernames: %w", err)
|
||||
}
|
||||
return domain.SortUsernames(out), nil
|
||||
}
|
||||
|
||||
// listPeerUsernamesBatch resolves several peers in one round trip.
|
||||
func listPeerUsernamesBatch(ctx context.Context, db sqlcgen.DBTX, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
|
||||
out := make(map[domain.Peer][]domain.Username, len(peers))
|
||||
types := make([]string, 0, len(peers))
|
||||
ids := make([]int64, 0, len(peers))
|
||||
seen := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
if peer.Type == "" || peer.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[peer]; dup {
|
||||
continue
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
types = append(types, string(peer.Type))
|
||||
ids = append(ids, peer.ID)
|
||||
}
|
||||
if len(types) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT peer_type, peer_id, `+peerUsernameColumns+`
|
||||
FROM peer_usernames
|
||||
WHERE (peer_type, peer_id) IN (SELECT t, i FROM unnest($1::text[], $2::bigint[]) AS s(t, i))
|
||||
ORDER BY peer_type, peer_id, editable DESC, sort_order, username_lower`, types, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list peer usernames batch: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var peer domain.Peer
|
||||
var peerType string
|
||||
var item domain.Username
|
||||
if err := rows.Scan(&peerType, &peer.ID, &item.Username, &item.Active, &item.Editable,
|
||||
&item.SortOrder, &item.CollectibleID); err != nil {
|
||||
return nil, fmt.Errorf("scan peer username batch: %w", err)
|
||||
}
|
||||
peer.Type = domain.PeerType(peerType)
|
||||
out[peer] = append(out[peer], item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate peer usernames batch: %w", err)
|
||||
}
|
||||
for peer, list := range out {
|
||||
out[peer] = domain.SortUsernames(list)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// lockPeerUsernamesTx reads the peer's registry rows with row locks so a toggle
|
||||
// or reorder validated against the list cannot race a concurrent asset move.
|
||||
func lockPeerUsernamesTx(ctx context.Context, tx pgx.Tx, peer domain.Peer) ([]domain.Username, error) {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT `+peerUsernameColumns+`
|
||||
FROM peer_usernames
|
||||
WHERE peer_type = $1 AND peer_id = $2
|
||||
ORDER BY username_lower
|
||||
FOR UPDATE`, string(peer.Type), peer.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock peer usernames: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.Username, 0, 4)
|
||||
for rows.Next() {
|
||||
var item domain.Username
|
||||
if err := rows.Scan(&item.Username, &item.Active, &item.Editable, &item.SortOrder, &item.CollectibleID); err != nil {
|
||||
return nil, fmt.Errorf("scan locked peer username: %w", err)
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate locked peer usernames: %w", err)
|
||||
}
|
||||
return domain.SortUsernames(out), nil
|
||||
}
|
||||
|
||||
// countPeerCollectibleUsernamesTx bounds the collectible rows a peer may hold.
|
||||
func countPeerCollectibleUsernamesTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64) (int, error) {
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT count(*) FROM peer_usernames
|
||||
WHERE peer_type = $1 AND peer_id = $2 AND collectible_id IS NOT NULL`, peerType, peerID).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("count peer collectible usernames: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// insertCollectiblePeerUsernameTx projects an owned asset into the registry. The
|
||||
// row sorts after every collectible the peer already holds and always carries
|
||||
// collectible_id, which the schema forbids on an editable row.
|
||||
func insertCollectiblePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, username, usernameLower string, collectibleID int64) error {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO peer_usernames (username_lower, peer_type, peer_id, username, active, editable, sort_order, collectible_id, updated_at)
|
||||
SELECT $1, $2, $3, $4, true, false, LEAST(
|
||||
COALESCE((
|
||||
SELECT max(sort_order) + 1 FROM peer_usernames
|
||||
WHERE peer_type = $2 AND peer_id = $3 AND collectible_id IS NOT NULL
|
||||
), 0), $6::int
|
||||
), $5, now()`,
|
||||
usernameLower, peerType, peerID, username, collectibleID, domain.MaxUsernameSortOrder); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return domain.ErrUsernameOccupied
|
||||
}
|
||||
return fmt.Errorf("insert collectible peer username: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteCollectiblePeerUsernameTx removes the registry projection of an asset,
|
||||
// releasing the name for anyone else the moment the asset stops being owned.
|
||||
func deleteCollectiblePeerUsernameTx(ctx context.Context, tx pgx.Tx, collectibleID int64) error {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM peer_usernames WHERE collectible_id = $1`, collectibleID); err != nil {
|
||||
return fmt.Errorf("delete collectible peer username: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isUniqueConstraint(err error, constraintName string) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation && pgErr.ConstraintName == constraintName
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("migrate star gift lifecycle schema: %v", err)
|
||||
}
|
||||
if status.Dirty || status.Empty || status.Version != 150 {
|
||||
t.Fatalf("migration status = %+v, want clean version 150", status)
|
||||
if status.Dirty || status.Empty || status.Version != 158 {
|
||||
t.Fatalf("migration status = %+v, want clean version 158", status)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,266 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// TestStarGiftResaleClearsSellerProfileStatePostgres pins the full seller-side
|
||||
// teardown of a resale: a collectible that was worn as an emoji status and
|
||||
// pinned to the profile must, the moment somebody buys it, stop being worn, stop
|
||||
// being pinned, leave the seller's saved-gift list, and produce the three
|
||||
// durable seller-visible updates the clients need to converge on that
|
||||
// (user_emoji_status for the cleared status, new_message for the sale card,
|
||||
// edit_message for the retired older card).
|
||||
//
|
||||
// This is the "my sold gift is still on my profile" report: everything below is
|
||||
// server state and server push, so a client still showing the gift after this
|
||||
// test passes is showing its own cache, not our state.
|
||||
func TestStarGiftResaleClearsSellerProfileStatePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
now := int(time.Now().Unix())
|
||||
users := NewUserStore(pool)
|
||||
seller := createTestUser(t, ctx, users, "+1882"+suffix+"01", "ResaleSeller", "")
|
||||
buyer := createTestUser(t, ctx, users, "+1882"+suffix+"02", "ResaleBuyer2", "")
|
||||
sellerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: seller.ID}
|
||||
|
||||
stars := NewStarsStore(pool)
|
||||
for _, u := range []domain.User{seller, buyer} {
|
||||
if _, _, err := stars.EnsureGrant(ctx, u.ID, 10000, now); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
gifts := NewStarGiftStore(pool)
|
||||
base := time.Now().UnixNano() & 0x7ffffffffffff000
|
||||
entry, err := gifts.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Title: "ResaleSync " + suffix, Stars: 50, ConvertStars: 20, Enabled: true,
|
||||
Document: collectibleTestDocument(base, "resale-sync.tgs"),
|
||||
Blob: collectibleTestBlob(base, "resale-sync"), Animation: collectibleTestAnimation("resale-sync.tgs"),
|
||||
Actor: "integration", CommandID: "resale-sync-catalog-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("catalog: %v", err)
|
||||
}
|
||||
if _, err := gifts.PublishCollectibleRevision(ctx, domain.StarGiftCollectibleWrite{
|
||||
GiftID: entry.Gift.ID, UpgradeStars: 100, SupplyTotal: 20, SlugPrefix: "rsy-" + suffix,
|
||||
Models: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Base", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(base+1, "model.tgs"), Blob: collectibleTestBlobPtr(base+1, "model"), Animation: collectibleTestAnimationPtr("model.tgs")},
|
||||
{Kind: domain.StarGiftCollectibleModel, Name: "Base Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestDocumentPtr(base+4, "model-two.tgs"), Blob: collectibleTestBlobPtr(base+4, "model-two"), Animation: collectibleTestAnimationPtr("model-two.tgs")},
|
||||
},
|
||||
Patterns: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(base+3, "pattern.tgs"), Blob: collectibleTestBlobPtr(base+3, "pattern"), Animation: collectibleTestAnimationPtr("pattern.tgs")},
|
||||
{Kind: domain.StarGiftCollectiblePattern, Name: "Orbit Two", RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000,
|
||||
Document: collectibleTestPatternDocumentPtr(base+5, "pattern-two.tgs"), Blob: collectibleTestBlobPtr(base+5, "pattern-two"), Animation: collectibleTestAnimationPtr("pattern-two.tgs")},
|
||||
},
|
||||
Backdrops: []domain.StarGiftCollectibleAttribute{
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Night", BackdropID: 77, CenterColor: 0x112233, EdgeColor: 0x223344, PatternColor: 0x334455, TextColor: 0xffffff, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
|
||||
{Kind: domain.StarGiftCollectibleBackdrop, Name: "Day", BackdropID: 78, CenterColor: 0xaabbcc, EdgeColor: 0x778899, PatternColor: 0xddeeff, TextColor: 0x111111, RarityKind: domain.StarGiftRarityPermille, RarityPermille: 1000},
|
||||
},
|
||||
Actor: "integration", CommandID: "resale-sync-pool-" + suffix,
|
||||
}); err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
|
||||
messages := NewMessageStore(pool)
|
||||
lifecycle := NewStarGiftLifecycleStore(pool, messages, 1_000_000, WithStarGiftMarketPolicy(domain.StarGiftMarketPolicy{
|
||||
StarsProceedsPermille: 900, TONProceedsPermille: 900,
|
||||
}))
|
||||
upgrades := NewStarGiftUpgradeStore(pool, messages, WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
|
||||
TransferStars: 25, DropOriginalDetailsStars: 25, OfferMinStars: 1, CraftChancePermille: 500,
|
||||
}))
|
||||
|
||||
purchase := issueLifecyclePurchaseForm(t, ctx, lifecycle, domain.StarGiftPurchaseRequest{
|
||||
BuyerUserID: seller.ID, To: sellerPeer, GiftID: entry.Gift.ID, IncludeUpgrade: true,
|
||||
CommandKey: "resale-sync-purchase-" + suffix, Date: now,
|
||||
})
|
||||
bought, err := lifecycle.PurchaseStarGift(ctx, purchase)
|
||||
if err != nil {
|
||||
t.Fatalf("purchase: %v", err)
|
||||
}
|
||||
upgraded, err := upgrades.UpgradeStarGift(ctx, domain.StarGiftUpgradeRequest{
|
||||
UserID: seller.ID, Ref: domain.SavedStarGiftRef{Owner: sellerPeer, MsgID: bought.Saved.MsgID},
|
||||
RequirePrepaid: true, KeepOriginalDetails: true, CommandKey: "resale-sync-upgrade-" + suffix, Date: now + 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upgrade: %v", err)
|
||||
}
|
||||
|
||||
// Wear it.
|
||||
selected, valid := domain.CollectibleEmojiStatus(upgraded.Unique)
|
||||
if !valid {
|
||||
t.Fatalf("cannot wear: %+v", upgraded.Unique)
|
||||
}
|
||||
if _, err := users.UpdateEmojiStatus(ctx, seller.ID, domain.UserEmojiStatus{
|
||||
DocumentID: selected.DocumentID, Collectible: selected,
|
||||
}); err != nil {
|
||||
t.Fatalf("wear: %v", err)
|
||||
}
|
||||
// Pin it to the profile.
|
||||
if err := gifts.SetPinned(ctx, sellerPeer, []int64{upgraded.Saved.ID}); err != nil {
|
||||
t.Fatalf("pin: %v", err)
|
||||
}
|
||||
|
||||
var pinnedOrder int
|
||||
if err := pool.QueryRow(ctx, `SELECT pinned_order FROM peer_star_gifts WHERE id=$1`, upgraded.Saved.ID).Scan(&pinnedOrder); err != nil {
|
||||
t.Fatalf("read pin: %v", err)
|
||||
}
|
||||
if pinnedOrder == 0 {
|
||||
t.Fatalf("gift was not pinned before the sale")
|
||||
}
|
||||
|
||||
listed, err := lifecycle.SetStarGiftListing(ctx, domain.StarGiftListingRequest{
|
||||
ActorUserID: seller.ID, Ref: domain.SavedStarGiftRef{Owner: sellerPeer, MsgID: upgraded.Saved.MsgID},
|
||||
Amount: &domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 500}, Date: now + 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
|
||||
// The seller's profile-visible state right before the sale.
|
||||
beforePage, err := gifts.ListByOwner(ctx, sellerPeer, true, "", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("list before: %v", err)
|
||||
}
|
||||
if len(beforePage.Gifts) != 1 {
|
||||
t.Fatalf("seller saved gifts before the sale = %d, want 1", len(beforePage.Gifts))
|
||||
}
|
||||
|
||||
sellerPtsBefore, err := NewUpdateEventStore(pool).MaxContiguousPts(ctx, seller.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("pts before: %v", err)
|
||||
}
|
||||
|
||||
resold, err := lifecycle.PurchaseResaleStarGift(ctx, domain.StarGiftResalePurchaseRequest{
|
||||
BuyerUserID: buyer.ID, Slug: listed.Slug, To: domain.Peer{Type: domain.PeerTypeUser, ID: buyer.ID},
|
||||
Amount: domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: 500}, FormID: 31001,
|
||||
CommandKey: "resale-sync-resale-" + suffix, Date: now + 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resale: %v", err)
|
||||
}
|
||||
buyerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: buyer.ID}
|
||||
if resold.Unique.Owner != buyerPeer || resold.Saved.Owner != buyerPeer {
|
||||
t.Fatalf("resale ownership = unique %+v saved %+v, want %+v", resold.Unique.Owner, resold.Saved.Owner, buyerPeer)
|
||||
}
|
||||
|
||||
// The gift is no longer pinned to the seller's profile.
|
||||
if err := pool.QueryRow(ctx, `SELECT pinned_order FROM peer_star_gifts WHERE id=$1`, upgraded.Saved.ID).Scan(&pinnedOrder); err != nil {
|
||||
t.Fatalf("read pin after: %v", err)
|
||||
}
|
||||
if pinnedOrder != 0 {
|
||||
t.Fatalf("sold gift is still pinned to the seller's profile: pinned_order = %d", pinnedOrder)
|
||||
}
|
||||
|
||||
// The seller no longer wears it: the lifecycle trigger cleared the status.
|
||||
var docID int64
|
||||
var collectibleID *int64
|
||||
if err := pool.QueryRow(ctx, `SELECT emoji_status_document_id,emoji_status_collectible_id FROM users WHERE id=$1`,
|
||||
seller.ID).Scan(&docID, &collectibleID); err != nil {
|
||||
t.Fatalf("read status: %v", err)
|
||||
}
|
||||
if docID != 0 || collectibleID != nil {
|
||||
t.Fatalf("seller still wears the sold collectible: document_id=%d collectible_id=%v", docID, collectibleID)
|
||||
}
|
||||
|
||||
// And it has left the seller's saved-gift list entirely.
|
||||
afterPage, err := gifts.ListByOwner(ctx, sellerPeer, true, "", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("list after: %v", err)
|
||||
}
|
||||
if len(afterPage.Gifts) != 0 {
|
||||
t.Fatalf("seller still owns %d saved gifts after the sale", len(afterPage.Gifts))
|
||||
}
|
||||
buyerPage, err := gifts.ListByOwner(ctx, buyerPeer, true, "", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("buyer list after: %v", err)
|
||||
}
|
||||
if len(buyerPage.Gifts) != 1 || buyerPage.Gifts[0].ID != upgraded.Saved.ID || buyerPage.Gifts[0].PinnedOrder != 0 {
|
||||
t.Fatalf("buyer saved gifts = %+v, want exactly the unpinned sold gift", buyerPage.Gifts)
|
||||
}
|
||||
|
||||
// Every seller-visible consequence is also a durable update the clients can
|
||||
// converge on, in contiguous pts order and with no gap.
|
||||
sellerPtsAfter, err := NewUpdateEventStore(pool).MaxContiguousPts(ctx, seller.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("pts after: %v", err)
|
||||
}
|
||||
rows, err := pool.Query(ctx, `SELECT pts,event_type FROM user_update_events
|
||||
WHERE user_id=$1 AND pts>$2 ORDER BY pts`, seller.ID, sellerPtsBefore)
|
||||
if err != nil {
|
||||
t.Fatalf("events: %v", err)
|
||||
}
|
||||
kinds := make([]string, 0, 3)
|
||||
pointers := make([]int, 0, 3)
|
||||
for rows.Next() {
|
||||
var pts int
|
||||
var kind string
|
||||
if err := rows.Scan(&pts, &kind); err != nil {
|
||||
rows.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
pointers = append(pointers, pts)
|
||||
kinds = append(kinds, kind)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{
|
||||
string(domain.UpdateEventUserEmojiStatus),
|
||||
string(domain.UpdateEventNewMessage),
|
||||
string(domain.UpdateEventEditMessage),
|
||||
}
|
||||
if len(kinds) != len(want) {
|
||||
t.Fatalf("seller update events = %v (pts %v), want %v", kinds, pointers, want)
|
||||
}
|
||||
for i, kind := range want {
|
||||
if kinds[i] != kind {
|
||||
t.Fatalf("seller update events = %v, want %v", kinds, want)
|
||||
}
|
||||
if pointers[i] != sellerPtsBefore+1+i {
|
||||
t.Fatalf("seller update pts = %v, want contiguous from %d", pointers, sellerPtsBefore+1)
|
||||
}
|
||||
}
|
||||
if sellerPtsAfter != sellerPtsBefore+len(want) {
|
||||
t.Fatalf("seller contiguous pts = %d, want %d", sellerPtsAfter, sellerPtsBefore+len(want))
|
||||
}
|
||||
// Each of them is also queued for online dispatch, and none of them is
|
||||
// suppressed by the buyer's origin session.
|
||||
var dispatched int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
|
||||
WHERE target_user_id=$1 AND pts>$2 AND exclude_auth_key_id=0 AND exclude_session_id=0`,
|
||||
seller.ID, sellerPtsBefore).Scan(&dispatched); err != nil {
|
||||
t.Fatalf("dispatch rows: %v", err)
|
||||
}
|
||||
if dispatched != len(want) {
|
||||
t.Fatalf("seller dispatch rows = %d, want %d", dispatched, len(want))
|
||||
}
|
||||
// The cleared status must serialise as an explicit empty status, not as a
|
||||
// dropped update: a client that never sees it keeps rendering the sold gift.
|
||||
events, err := NewUpdateEventStore(pool).ListAfter(ctx, seller.ID, sellerPtsBefore, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("read events: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, event := range events {
|
||||
if event.Type != domain.UpdateEventUserEmojiStatus {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if event.UserID != seller.ID || !event.EmojiStatus.Empty() || !event.EmojiStatus.Valid() {
|
||||
t.Fatalf("cleared emoji status event = %+v, want a valid empty status for %d", event, seller.ID)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no user_emoji_status event in %+v", events)
|
||||
}
|
||||
}
|
||||
|
|
@ -97,13 +97,30 @@ func (s *UserStore) ByUsername(ctx context.Context, username string) (domain.Use
|
|||
row, err := s.q.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.User{}, false, nil
|
||||
// The scalar users.username column only holds the editable slot, so a
|
||||
// collectible username resolves through the registry instead. This is a
|
||||
// fallback rather than the primary path: the fast lookup above stays
|
||||
// untouched for every pre-existing username.
|
||||
return s.byCollectibleUsername(ctx, strings.ToLower(username))
|
||||
}
|
||||
return domain.User{}, false, fmt.Errorf("get user by username: %w", err)
|
||||
}
|
||||
return userFromModel(row), true, nil
|
||||
}
|
||||
|
||||
// byCollectibleUsername resolves an active collectible username to its holder.
|
||||
// An inactive (client-hidden) name stays occupied but must not resolve.
|
||||
func (s *UserStore) byCollectibleUsername(ctx context.Context, usernameLower string) (domain.User, bool, error) {
|
||||
owner, found, err := getPeerUsernameOwner(ctx, s.db, usernameLower, false)
|
||||
if err != nil {
|
||||
return domain.User{}, false, fmt.Errorf("get user by collectible username: %w", err)
|
||||
}
|
||||
if !found || !owner.collectible || !owner.active || owner.peerType != peerUsernameTypeUser {
|
||||
return domain.User{}, false, nil
|
||||
}
|
||||
return s.ByID(ctx, owner.peerID)
|
||||
}
|
||||
|
||||
func (s *UserStore) CheckUsername(ctx context.Context, userID int64, username string) (bool, error) {
|
||||
usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
||||
if usernameLower == "" {
|
||||
|
|
@ -210,7 +227,7 @@ func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username s
|
|||
}
|
||||
return domain.User{}, fmt.Errorf("lock user for username update: %w", err)
|
||||
}
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, usernameLower); err != nil {
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, username, usernameLower); err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
row, err := qtx.UpdateUserUsername(ctx, sqlcgen.UpdateUserUsernameParams{
|
||||
|
|
@ -280,7 +297,7 @@ func (s *UserStore) Create(ctx context.Context, u domain.User) (domain.User, err
|
|||
}
|
||||
usernameLower := strings.ToLower(row.Username)
|
||||
if usernameLower != "" {
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, usernameLower); err != nil {
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, row.Username, usernameLower); err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1342
internal/store/postgres/verification.go
Normal file
1342
internal/store/postgres/verification.go
Normal file
File diff suppressed because it is too large
Load diff
967
internal/store/postgres/verification_integration_test.go
Normal file
967
internal/store/postgres/verification_integration_test.go
Normal file
|
|
@ -0,0 +1,967 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// verificationTestUser inserts a throwaway user row and registers the cleanup for
|
||||
// everything the verification tables may hang off it. Events reference the
|
||||
// application with ON DELETE RESTRICT, so the timeline has to go first.
|
||||
func verificationTestUser(t *testing.T, pool *pgxpool.Pool) int64 {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
var id int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO users (access_hash, phone, first_name)
|
||||
VALUES ($1, $2, 'verification test')
|
||||
RETURNING id`, time.Now().UnixNano()&0x7fffffffffffffff, "9"+suffix).Scan(&id); err != nil {
|
||||
t.Fatalf("insert verification test user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx := context.Background()
|
||||
_, _ = pool.Exec(cleanupCtx, `
|
||||
DELETE FROM verification_application_events
|
||||
WHERE application_id IN (
|
||||
SELECT id FROM verification_applications
|
||||
WHERE applicant_user_id = $1 OR target_id = $1
|
||||
)`, id)
|
||||
_, _ = pool.Exec(cleanupCtx, `
|
||||
DELETE FROM verification_notification_outbox
|
||||
WHERE application_id IN (
|
||||
SELECT id FROM verification_applications
|
||||
WHERE applicant_user_id = $1 OR target_id = $1
|
||||
)`, id)
|
||||
_, _ = pool.Exec(cleanupCtx, `
|
||||
DELETE FROM verification_applications
|
||||
WHERE applicant_user_id = $1 OR target_id = $1`, id)
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, id)
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
// verificationTestDraft is a payload that clears domain.ValidateForSubmission.
|
||||
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
|
||||
}
|
||||
|
||||
func verificationUserVerified(t *testing.T, pool *pgxpool.Pool, userID int64) bool {
|
||||
t.Helper()
|
||||
var verified bool
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT verified FROM users WHERE id = $1`, userID).Scan(&verified); err != nil {
|
||||
t.Fatalf("read verified flag: %v", err)
|
||||
}
|
||||
return verified
|
||||
}
|
||||
|
||||
// verificationTxApply is the callback shape the app layer is expected to use: the
|
||||
// peer flag is written through the transaction that is deciding the application,
|
||||
// so the two writes commit or roll back together.
|
||||
func verificationTxApply(_ *testing.T) func(context.Context, domain.VerificationApplication) error {
|
||||
return func(ctx context.Context, app domain.VerificationApplication) error {
|
||||
tx, ok := VerificationTxFromContext(ctx)
|
||||
if !ok {
|
||||
return fmt.Errorf("decision context carries no transaction")
|
||||
}
|
||||
if _, err := NewUserStore(tx).SetVerified(ctx, app.TargetID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerificationDraftLifecyclePostgres covers the applicant path against the
|
||||
// real schema: a draft is opened once and resumed on the next /start, the payload
|
||||
// round-trips, and only a complete application reaches the queue.
|
||||
func TestVerificationDraftLifecyclePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
s := NewVerificationStore(pool)
|
||||
applicant := verificationTestUser(t, pool)
|
||||
target := verificationTestUser(t, pool)
|
||||
|
||||
req := verificationTestRequest(applicant, domain.VerificationTargetBot, target, "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.SubmittedAt.IsZero() || !app.ReviewedAt.IsZero() || app.ReviewerAdminID != "" {
|
||||
t.Fatal("fresh draft carries review metadata")
|
||||
}
|
||||
|
||||
resumed, created, err := s.CreateVerificationDraft(ctx,
|
||||
verificationTestRequest(applicant, domain.VerificationTargetBot, target, "AlphaBot"))
|
||||
if err != nil || created {
|
||||
t.Fatalf("resume draft: created=%v err=%v", created, err)
|
||||
}
|
||||
if resumed.ID != app.ID {
|
||||
t.Fatalf("resumed draft = %d, want %d", resumed.ID, app.ID)
|
||||
}
|
||||
|
||||
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.SocialLinks[0] != "https://t.me/example" {
|
||||
t.Fatalf("saved draft = v%d social=%v press=%v", saved.Version, saved.SocialLinks, saved.PressLinks)
|
||||
}
|
||||
if !saved.UpdatedAt.Equal(saved.UpdatedAt.UTC()) || saved.UpdatedAt.Before(saved.CreatedAt) {
|
||||
t.Fatalf("updated_at = %v, created_at = %v", saved.UpdatedAt, saved.CreatedAt)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
reread, err := s.VerificationApplication(ctx, app.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if reread.Description != saved.Description || reread.AdditionalNote != saved.AdditionalNote ||
|
||||
reread.Category != "media" || reread.CorrelationID != fmt.Sprintf("corr-%d", target) {
|
||||
t.Fatalf("payload did not round-trip: %+v", reread)
|
||||
}
|
||||
|
||||
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, applicant); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
||||
t.Fatalf("draft after submit err = %v, want ErrVerificationApplicationNotFound", err)
|
||||
}
|
||||
active, err := s.ActiveVerificationApplicationForTarget(ctx, domain.VerificationTargetBot, target)
|
||||
if err != nil || active.ID != app.ID {
|
||||
t.Fatalf("active for target = %d err=%v", active.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerificationActiveTargetUniquenessPostgres is the partial unique index at
|
||||
// work, including the cancelled-draft case the submitted_at CHECK constrains.
|
||||
func TestVerificationActiveTargetUniquenessPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
s := NewVerificationStore(pool)
|
||||
first := verificationTestUser(t, pool)
|
||||
second := verificationTestUser(t, pool)
|
||||
third := verificationTestUser(t, pool)
|
||||
target := verificationTestUser(t, pool)
|
||||
|
||||
app := submittedVerificationApplication(t, s, first, domain.VerificationTargetChannel, target, "beta")
|
||||
if _, _, err := s.CreateVerificationDraft(ctx,
|
||||
verificationTestRequest(second, domain.VerificationTargetChannel, target, "beta")); !errors.Is(err, domain.ErrVerificationApplicationExists) {
|
||||
t.Fatalf("second application err = %v, want ErrVerificationApplicationExists", err)
|
||||
}
|
||||
// The same numeric id in the user namespace is a different target.
|
||||
namespaced, _, err := s.CreateVerificationDraft(ctx,
|
||||
verificationTestRequest(second, domain.VerificationTargetBot, target, "betabot"))
|
||||
if err != nil {
|
||||
t.Fatalf("other namespace draft: %v", err)
|
||||
}
|
||||
// One draft per applicant: naming another target resumes the same
|
||||
// conversation instead of tripping the applicant-draft unique index.
|
||||
resumed, created, err := s.CreateVerificationDraft(ctx,
|
||||
verificationTestRequest(second, domain.VerificationTargetBot, third, "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, app.ID, app.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 != "" || cancelled.ReviewerAdminID != "" || !cancelled.ReviewedAt.IsZero() {
|
||||
t.Fatalf("cancel wrote decision metadata: %+v", cancelled)
|
||||
}
|
||||
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(third, domain.VerificationTargetChannel, target, "beta")); err != nil {
|
||||
t.Fatalf("draft after cancellation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerificationCancelledDraftPostgres pins the one place the schema forces the
|
||||
// store's hand: a draft that is withdrawn before submission still needs
|
||||
// submitted_at, because "status <> 'draft'" requires it.
|
||||
func TestVerificationCancelledDraftPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
s := NewVerificationStore(pool)
|
||||
applicant := verificationTestUser(t, pool)
|
||||
target := verificationTestUser(t, pool)
|
||||
|
||||
draft, _, err := s.CreateVerificationDraft(ctx,
|
||||
verificationTestRequest(applicant, domain.VerificationTargetBot, target, "iota"))
|
||||
if err != nil {
|
||||
t.Fatalf("create draft: %v", err)
|
||||
}
|
||||
cancelled, err := s.CancelVerificationApplication(ctx, draft.ID, draft.Version, "never mind")
|
||||
if err != nil {
|
||||
t.Fatalf("cancel draft: %v", err)
|
||||
}
|
||||
if cancelled.SubmittedAt.IsZero() {
|
||||
t.Fatal("cancelled draft has no submitted_at, which the CHECK forbids")
|
||||
}
|
||||
events, err := s.VerificationApplicationEvents(ctx, draft.ID, 10)
|
||||
if err != nil || len(events) != 2 {
|
||||
t.Fatalf("history = %d rows err=%v, want created + cancelled", len(events), err)
|
||||
}
|
||||
if events[0].Kind != domain.VerificationEventCancelled ||
|
||||
events[0].FromStatus != domain.VerificationStatusDraft ||
|
||||
events[0].ToStatus != domain.VerificationStatusCancelled ||
|
||||
events[0].Reason != "never mind" {
|
||||
t.Fatalf("cancelled event = %+v", events[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerificationApproveWritesPeerFlagPostgres is the core invariant: the peer
|
||||
// flag and the decision share one transaction, so a failing callback leaves
|
||||
// neither behind and a successful one cannot be observed without the other.
|
||||
func TestVerificationApproveWritesPeerFlagPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
s := NewVerificationStore(pool)
|
||||
applicant := verificationTestUser(t, pool)
|
||||
target := verificationTestUser(t, pool)
|
||||
app := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetBot, target, "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" || !claimed.ReviewedAt.IsZero() {
|
||||
t.Fatalf("claimed = %+v", claimed)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// The callback sets the flag through the decision transaction and then fails,
|
||||
// so the rollback has to take the flag with it.
|
||||
failing := errors.New("notification pipeline unavailable")
|
||||
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
|
||||
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a",
|
||||
}, true, func(ctx context.Context, decided domain.VerificationApplication) error {
|
||||
if err := verificationTxApply(t)(ctx, decided); err != nil {
|
||||
return err
|
||||
}
|
||||
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 verificationUserVerified(t, pool, target) {
|
||||
t.Fatal("rolled-back approval left the target verified")
|
||||
}
|
||||
pending, err := s.PendingVerificationNotifications(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(verificationRowsFor(pending, app.ID)) != 0 {
|
||||
t.Fatalf("outbox after failed approve = %+v, want empty", pending)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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, verificationTxApply(t))
|
||||
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 ||
|
||||
approved.InternalNote != "checked the press coverage" {
|
||||
t.Fatalf("approved = %+v", approved)
|
||||
}
|
||||
if !verificationUserVerified(t, pool, target) {
|
||||
t.Fatal("approved application whose target is not verified")
|
||||
}
|
||||
|
||||
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.Error("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, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
mine := verificationRowsFor(pending, app.ID)
|
||||
if len(mine) != 1 || mine[0].Kind != "approved" || mine[0].RecipientUserID != applicant {
|
||||
t.Fatalf("outbox = %+v, want exactly one approved row for the applicant", mine)
|
||||
}
|
||||
if mine[0].Application.ID != app.ID || mine[0].Application.TargetUsername != "gamma" ||
|
||||
mine[0].Application.Status != domain.VerificationStatusApproved {
|
||||
t.Fatalf("outbox row carries no application context: %+v", mine[0].Application)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerificationRejectAndCooldownPostgres covers the rejection path and the
|
||||
// cooldown lookup the re-application check is measured from.
|
||||
func TestVerificationRejectAndCooldownPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
s := NewVerificationStore(pool)
|
||||
applicant := verificationTestUser(t, pool)
|
||||
other := verificationTestUser(t, pool)
|
||||
target := verificationTestUser(t, pool)
|
||||
app := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetChannel, target, "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 == "" ||
|
||||
rejected.ReviewedAt.IsZero() {
|
||||
t.Fatalf("rejected = %+v", rejected)
|
||||
}
|
||||
if verificationUserVerified(t, pool, target) {
|
||||
t.Fatal("rejection verified the target")
|
||||
}
|
||||
|
||||
cooldown, err := s.LastVerificationRejection(ctx, applicant, domain.VerificationTargetChannel, target)
|
||||
if err != nil || cooldown.ID != app.ID {
|
||||
t.Fatalf("cooldown lookup = %d err=%v", cooldown.ID, err)
|
||||
}
|
||||
if _, err := s.LastVerificationRejection(ctx, other, domain.VerificationTargetChannel, target); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
||||
t.Fatalf("cooldown for another applicant err = %v, want ErrVerificationApplicationNotFound", err)
|
||||
}
|
||||
|
||||
second := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetChannel, target, "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, applicant, domain.VerificationTargetChannel, target)
|
||||
if err != nil || cooldown.ID != second.ID {
|
||||
t.Fatalf("newest rejection = %d err=%v, want %d", cooldown.ID, err, second.ID)
|
||||
}
|
||||
history, err := s.VerificationApplicationsForApplicant(ctx, applicant, 10)
|
||||
if err != nil || len(history) != 2 || history[0].ID != second.ID || history[1].ID != app.ID {
|
||||
t.Fatalf("applicant history = %+v err=%v", history, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerificationConcurrentDecisionPostgres runs two reviewers at the same time
|
||||
// on the same version. Exactly one decision, exactly one notification, and the
|
||||
// loser is told it lost instead of silently overwriting the winner.
|
||||
func TestVerificationConcurrentDecisionPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
s := NewVerificationStore(pool)
|
||||
applicant := verificationTestUser(t, pool)
|
||||
target := verificationTestUser(t, pool)
|
||||
app := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetBot, target, "epsilon")
|
||||
|
||||
var mu sync.Mutex
|
||||
calls := 0
|
||||
apply := func(ctx context.Context, decided domain.VerificationApplication) error {
|
||||
mu.Lock()
|
||||
calls++
|
||||
mu.Unlock()
|
||||
return verificationTxApply(t)(ctx, decided)
|
||||
}
|
||||
results := make([]error, 2)
|
||||
var wg sync.WaitGroup
|
||||
for i, reviewer := range []string{"admin-a", "admin-b"} {
|
||||
wg.Add(1)
|
||||
go func(i int, reviewer string) {
|
||||
defer wg.Done()
|
||||
_, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
|
||||
ApplicationID: app.ID, Version: app.Version, Reviewer: reviewer,
|
||||
}, true, apply)
|
||||
results[i] = err
|
||||
}(i, reviewer)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
winners, conflicts := 0, 0
|
||||
for _, err := range results {
|
||||
switch {
|
||||
case err == nil:
|
||||
winners++
|
||||
case errors.Is(err, domain.ErrVerificationVersionConflict):
|
||||
conflicts++
|
||||
default:
|
||||
t.Fatalf("unexpected concurrent decision error: %v", err)
|
||||
}
|
||||
}
|
||||
if winners != 1 || conflicts != 1 {
|
||||
t.Fatalf("concurrent decision = %d winners, %d conflicts, want 1 and 1", winners, conflicts)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("applyVerified ran %d times, want exactly 1", calls)
|
||||
}
|
||||
final, err := s.VerificationApplication(ctx, app.ID)
|
||||
if err != nil || final.Status != domain.VerificationStatusApproved || final.Version != app.Version+1 {
|
||||
t.Fatalf("final application = %s v%d err=%v", final.Status, final.Version, err)
|
||||
}
|
||||
if !verificationUserVerified(t, pool, target) {
|
||||
t.Fatal("approved application whose target is not verified")
|
||||
}
|
||||
pending, err := s.PendingVerificationNotifications(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if got := verificationRowsFor(pending, app.ID); len(got) != 1 {
|
||||
t.Fatalf("outbox = %+v, want exactly one notification", got)
|
||||
}
|
||||
var events int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM verification_application_events
|
||||
WHERE application_id = $1 AND kind = 'approved'`, app.ID).Scan(&events); err != nil {
|
||||
t.Fatalf("count approved events: %v", err)
|
||||
}
|
||||
if events != 1 {
|
||||
t.Fatalf("approved history rows = %d, want 1", events)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerificationRevokePostgres takes the badge back: the flag is cleared in the
|
||||
// same transaction, the application stays approved as history, and the revocation
|
||||
// notifies exactly once.
|
||||
func TestVerificationRevokePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
s := NewVerificationStore(pool)
|
||||
applicant := verificationTestUser(t, pool)
|
||||
target := verificationTestUser(t, pool)
|
||||
orphanTarget := verificationTestUser(t, pool)
|
||||
app := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetBot, target, "zeta")
|
||||
approved, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
|
||||
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
|
||||
}, true, verificationTxApply(t))
|
||||
if err != nil {
|
||||
t.Fatalf("approve: %v", err)
|
||||
}
|
||||
|
||||
clear := func(ctx context.Context, peer domain.Peer) error {
|
||||
tx, ok := VerificationTxFromContext(ctx)
|
||||
if !ok {
|
||||
return fmt.Errorf("revocation context carries no transaction")
|
||||
}
|
||||
if peer.Type != domain.PeerTypeUser {
|
||||
return fmt.Errorf("unexpected peer type %q", peer.Type)
|
||||
}
|
||||
_, err := NewUserStore(tx).SetVerified(ctx, peer.ID, false)
|
||||
return err
|
||||
}
|
||||
req := domain.VerificationRevocation{
|
||||
TargetType: domain.VerificationTargetBot, TargetID: target,
|
||||
Reviewer: "admin-b", Reason: "impersonation report upheld", CorrelationID: "cmd-r",
|
||||
}
|
||||
if _, _, err := s.RevokeVerification(ctx, domain.VerificationRevocation{
|
||||
TargetType: domain.VerificationTargetBot, TargetID: target, Reviewer: "admin-b",
|
||||
}, clear); !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(ctx context.Context, peer domain.Peer) error {
|
||||
if err := clear(ctx, peer); err != nil {
|
||||
return err
|
||||
}
|
||||
return failing
|
||||
}); !errors.Is(err, failing) {
|
||||
t.Fatalf("failing revoke err = %v, want %v", err, failing)
|
||||
}
|
||||
if !verificationUserVerified(t, pool, target) {
|
||||
t.Fatal("rolled-back revocation cleared the flag anyway")
|
||||
}
|
||||
|
||||
revoked, changed, err := s.RevokeVerification(ctx, req, clear)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("revoke: changed=%v err=%v", changed, err)
|
||||
}
|
||||
if revoked.ID != approved.ID || revoked.Status != domain.VerificationStatusApproved {
|
||||
t.Fatalf("revoked application = %d %s, want %d approved", revoked.ID, revoked.Status, approved.ID)
|
||||
}
|
||||
if verificationUserVerified(t, pool, target) {
|
||||
t.Fatal("revocation left the target verified")
|
||||
}
|
||||
|
||||
repeat, changed, err := s.RevokeVerification(ctx, req, func(context.Context, domain.Peer) error {
|
||||
t.Error("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)
|
||||
}
|
||||
|
||||
var revokedEvents, revokedOutbox int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM verification_application_events
|
||||
WHERE application_id = $1 AND kind = 'revoked'`, app.ID).Scan(&revokedEvents); err != nil {
|
||||
t.Fatalf("count revoked events: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM verification_notification_outbox
|
||||
WHERE application_id = $1 AND kind = 'revoked'`, app.ID).Scan(&revokedOutbox); err != nil {
|
||||
t.Fatalf("count revoked outbox rows: %v", err)
|
||||
}
|
||||
if revokedEvents != 1 || revokedOutbox != 1 {
|
||||
t.Fatalf("revocation recorded %d events and %d outbox rows, want 1 and 1", revokedEvents, revokedOutbox)
|
||||
}
|
||||
|
||||
// A flag with no application behind it is still cleared: leaving it standing
|
||||
// is worse than a missing audit row.
|
||||
if _, err := NewUserStore(pool).SetVerified(ctx, orphanTarget, true); err != nil {
|
||||
t.Fatalf("seed orphan flag: %v", err)
|
||||
}
|
||||
orphan, changed, err := s.RevokeVerification(ctx, domain.VerificationRevocation{
|
||||
TargetType: domain.VerificationTargetBot, TargetID: orphanTarget,
|
||||
Reviewer: "admin-b", Reason: "manual flag from an older deployment",
|
||||
}, clear)
|
||||
if err != nil || !changed || orphan.ID != 0 {
|
||||
t.Fatalf("orphan revoke: app=%d changed=%v err=%v", orphan.ID, changed, err)
|
||||
}
|
||||
if verificationUserVerified(t, pool, orphanTarget) {
|
||||
t.Fatal("orphan revocation left the flag standing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerificationHistoryAndOutboxPostgres pins the append-only timeline and
|
||||
// walks one notification from pending to delivered.
|
||||
func TestVerificationHistoryAndOutboxPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
s := NewVerificationStore(pool)
|
||||
applicant := verificationTestUser(t, pool)
|
||||
target := verificationTestUser(t, pool)
|
||||
app := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetBot, target, "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, verificationTxApply(t)); err != nil {
|
||||
t.Fatalf("approve: %v", err)
|
||||
}
|
||||
|
||||
pending, err := s.PendingVerificationNotifications(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
mine := verificationRowsFor(pending, app.ID)
|
||||
if len(mine) != 1 || mine[0].Attempts != 0 {
|
||||
t.Fatalf("pending = %+v, want one fresh row", mine)
|
||||
}
|
||||
id := mine[0].ID
|
||||
if err := s.MarkVerificationNotificationFailed(ctx, id, "bot blocked by user"); err != nil {
|
||||
t.Fatalf("fail: %v", err)
|
||||
}
|
||||
pending, err = s.PendingVerificationNotifications(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("pending after failure: %v", err)
|
||||
}
|
||||
mine = verificationRowsFor(pending, app.ID)
|
||||
if len(mine) != 1 || mine[0].Attempts != 1 {
|
||||
t.Fatalf("after failure = %+v, want still pending with one attempt", mine)
|
||||
}
|
||||
var lastError string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT last_error FROM verification_notification_outbox WHERE id = $1`, id).Scan(&lastError); err != nil {
|
||||
t.Fatalf("read last_error: %v", err)
|
||||
}
|
||||
if lastError != "bot blocked by user" {
|
||||
t.Fatalf("last_error = %q", lastError)
|
||||
}
|
||||
|
||||
if err := s.MarkVerificationNotificationDelivered(ctx, id); err != nil {
|
||||
t.Fatalf("deliver: %v", err)
|
||||
}
|
||||
pending, err = s.PendingVerificationNotifications(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("pending after delivery: %v", err)
|
||||
}
|
||||
if got := verificationRowsFor(pending, app.ID); len(got) != 0 {
|
||||
t.Fatalf("delivered row is still pending: %+v", got)
|
||||
}
|
||||
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, 0); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
|
||||
t.Fatalf("deliver id=0 err = %v, want ErrVerificationApplicationInvalid", 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: %+v", len(events), len(wantKinds), events)
|
||||
}
|
||||
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", i)
|
||||
}
|
||||
}
|
||||
if events[1].FromStatus != domain.VerificationStatusInReview ||
|
||||
events[1].ToStatus != domain.VerificationStatusApproved ||
|
||||
events[1].Actor != "admin-a" || events[1].CorrelationID != "cmd-9" {
|
||||
t.Fatalf("approved event = %+v", events[1])
|
||||
}
|
||||
if events[0].Reason != "approved" {
|
||||
t.Fatalf("notified event = %+v, want the approved notification", events[0])
|
||||
}
|
||||
|
||||
// The timeline is append-only: nothing in the store rewrites a row, and the
|
||||
// delivered notification did not touch the earlier ones.
|
||||
var mutated int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM verification_application_events
|
||||
WHERE application_id = $1 AND created_at < (
|
||||
SELECT created_at FROM verification_application_events
|
||||
WHERE application_id = $1 ORDER BY id LIMIT 1
|
||||
)`, app.ID).Scan(&mutated); err != nil {
|
||||
t.Fatalf("check event ordering: %v", err)
|
||||
}
|
||||
if mutated != 0 {
|
||||
t.Fatalf("%d history rows predate the first one", mutated)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerificationQueueQueriesPostgres covers the review-queue projection against
|
||||
// the real indexes: status and target filters, reviewer scoping, the search shapes
|
||||
// and keyset paging. Every assertion is scoped by the date lower bound so rows
|
||||
// from other runs cannot leak into it.
|
||||
func TestVerificationQueueQueriesPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
s := NewVerificationStore(pool)
|
||||
suffix := randomSuffix(t)
|
||||
start := time.Now().UTC().Truncate(time.Microsecond)
|
||||
|
||||
before, err := s.VerificationStatusCounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("counts before: %v", err)
|
||||
}
|
||||
|
||||
firstApplicant := verificationTestUser(t, pool)
|
||||
secondApplicant := verificationTestUser(t, pool)
|
||||
thirdApplicant := verificationTestUser(t, pool)
|
||||
firstTarget := verificationTestUser(t, pool)
|
||||
secondTarget := verificationTestUser(t, pool)
|
||||
thirdTarget := verificationTestUser(t, pool)
|
||||
|
||||
reviewer := "admin-" + suffix
|
||||
first := submittedVerificationApplication(t, s, firstApplicant, domain.VerificationTargetBot, firstTarget, "alpha"+suffix)
|
||||
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
|
||||
ApplicationID: first.ID, Version: first.Version, Reviewer: reviewer,
|
||||
}); err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
second := submittedVerificationApplication(t, s, secondApplicant, domain.VerificationTargetChannel, secondTarget, "Beta"+suffix)
|
||||
third, _, err := s.CreateVerificationDraft(ctx,
|
||||
verificationTestRequest(thirdApplicant, domain.VerificationTargetSupergroup, thirdTarget, "gamma"+suffix))
|
||||
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
|
||||
}
|
||||
list := func(filter domain.VerificationApplicationFilter) []int64 {
|
||||
t.Helper()
|
||||
if filter.CreatedAt.IsZero() {
|
||||
filter.CreatedAt = start
|
||||
}
|
||||
got, err := s.ListVerificationApplications(ctx, filter)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
return ids(got)
|
||||
}
|
||||
|
||||
if got := list(domain.VerificationApplicationFilter{}); !equal(got, third.ID, second.ID, first.ID) {
|
||||
t.Fatalf("queue order = %v, want newest first", got)
|
||||
}
|
||||
if got := list(domain.VerificationApplicationFilter{
|
||||
Statuses: []domain.VerificationStatus{domain.VerificationStatusSubmitted, domain.VerificationStatusInReview},
|
||||
}); !equal(got, second.ID, first.ID) {
|
||||
t.Fatalf("status filter = %v", got)
|
||||
}
|
||||
if got := list(domain.VerificationApplicationFilter{
|
||||
TargetType: domain.VerificationTargetChannel,
|
||||
}); !equal(got, second.ID) {
|
||||
t.Fatalf("target type filter = %v", got)
|
||||
}
|
||||
if got := list(domain.VerificationApplicationFilter{Reviewer: reviewer}); !equal(got, first.ID) {
|
||||
t.Fatalf("reviewer filter = %v", got)
|
||||
}
|
||||
if got := list(domain.VerificationApplicationFilter{Reviewer: "admin-nobody"}); len(got) != 0 {
|
||||
t.Fatalf("unknown reviewer = %v", got)
|
||||
}
|
||||
if got := list(domain.VerificationApplicationFilter{Query: fmt.Sprint(first.ID)}); !equal(got, first.ID) {
|
||||
t.Fatalf("application id search = %v", got)
|
||||
}
|
||||
if got := list(domain.VerificationApplicationFilter{Query: fmt.Sprint(secondTarget)}); !equal(got, second.ID) {
|
||||
t.Fatalf("peer id search = %v", got)
|
||||
}
|
||||
if got := list(domain.VerificationApplicationFilter{Query: "@beta" + suffix}); !equal(got, second.ID) {
|
||||
t.Fatalf("username search = %v", got)
|
||||
}
|
||||
if got := list(domain.VerificationApplicationFilter{Query: "ALPHA" + suffix}); !equal(got, first.ID) {
|
||||
t.Fatalf("case-insensitive username search = %v", got)
|
||||
}
|
||||
if got := list(domain.VerificationApplicationFilter{Query: "nobody" + suffix}); len(got) != 0 {
|
||||
t.Fatalf("miss search = %v", got)
|
||||
}
|
||||
if got := list(domain.VerificationApplicationFilter{CreatedAt: second.CreatedAt}); !equal(got, third.ID, second.ID) {
|
||||
t.Fatalf("since filter = %v", got)
|
||||
}
|
||||
// BeforeID without a cursor timestamp still bounds the page.
|
||||
if got := list(domain.VerificationApplicationFilter{BeforeID: second.ID}); !equal(got, first.ID) {
|
||||
t.Fatalf("id-only cursor = %v", got)
|
||||
}
|
||||
|
||||
page, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
|
||||
CreatedAt: start, 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{
|
||||
CreatedAt: start, 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{
|
||||
CreatedAt: start, 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)
|
||||
}
|
||||
|
||||
after, err := s.VerificationStatusCounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("counts after: %v", err)
|
||||
}
|
||||
for status, want := range map[domain.VerificationStatus]int64{
|
||||
domain.VerificationStatusDraft: 1,
|
||||
domain.VerificationStatusSubmitted: 1,
|
||||
domain.VerificationStatusInReview: 1,
|
||||
} {
|
||||
if got := after[status] - before[status]; got != want {
|
||||
t.Fatalf("count delta for %s = %d, want %d", status, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerificationMissingApplicationPostgres pins the not-found surface every
|
||||
// mutation shares.
|
||||
func TestVerificationMissingApplicationPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
s := NewVerificationStore(pool)
|
||||
var missing int64
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT COALESCE(max(id), 0) + 1000 FROM verification_applications`).Scan(&missing); err != nil {
|
||||
t.Fatalf("pick missing id: %v", err)
|
||||
}
|
||||
if _, err := s.VerificationApplication(ctx, missing); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
||||
t.Fatalf("read err = %v", err)
|
||||
}
|
||||
if _, err := s.SubmitVerificationApplication(ctx, missing, 1); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
||||
t.Fatalf("submit err = %v", err)
|
||||
}
|
||||
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
|
||||
ApplicationID: missing, Version: 1, Reviewer: "admin-a",
|
||||
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
||||
t.Fatalf("claim err = %v", err)
|
||||
}
|
||||
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
|
||||
ApplicationID: missing, 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)
|
||||
}
|
||||
if _, err := s.VerificationApplicationEvents(ctx, missing, 10); err != nil {
|
||||
t.Fatalf("events of a missing application: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// verificationRowsFor narrows the shared outbox to one application, so a test
|
||||
// never depends on what other rows the test database happens to hold.
|
||||
func verificationRowsFor(rows []store.VerificationNotification, applicationID int64) []store.VerificationNotification {
|
||||
out := make([]store.VerificationNotification, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.ApplicationID == applicationID {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
89
internal/store/verification.go
Normal file
89
internal/store/verification.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// VerificationStore owns official verification applications, their immutable
|
||||
// history and the applicant-notification outbox.
|
||||
//
|
||||
// Every mutation is expected to be atomic with the history row it produces, and
|
||||
// every status change is guarded by the caller-supplied version: two reviewers
|
||||
// deciding the same application concurrently must produce exactly one decision
|
||||
// and one notification.
|
||||
type VerificationStore interface {
|
||||
// CreateVerificationDraft opens a draft for the applicant/target pair. An
|
||||
// active application on the target reports domain.ErrVerificationApplicationExists;
|
||||
// an existing draft of the same applicant is returned with created=false so
|
||||
// the bot dialog can resume it.
|
||||
CreateVerificationDraft(ctx context.Context, req domain.SubmitVerificationApplicationRequest) (app domain.VerificationApplication, created bool, err error)
|
||||
// SaveVerificationDraft rewrites the applicant-supplied payload of a draft.
|
||||
// The application must be in draft and at the given version.
|
||||
SaveVerificationDraft(ctx context.Context, applicationID int64, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error)
|
||||
// SubmitVerificationApplication moves a draft to submitted and stamps
|
||||
// submitted_at.
|
||||
SubmitVerificationApplication(ctx context.Context, applicationID int64, version int64) (domain.VerificationApplication, error)
|
||||
// CancelVerificationApplication withdraws an active application on the
|
||||
// applicant's behalf.
|
||||
CancelVerificationApplication(ctx context.Context, applicationID int64, version int64, reason string) (domain.VerificationApplication, error)
|
||||
// ClaimVerificationApplication assigns a reviewer and moves the application to
|
||||
// in_review.
|
||||
ClaimVerificationApplication(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, error)
|
||||
// DecideVerificationApplication records an approval or a rejection.
|
||||
//
|
||||
// approve=true is the only path that flips the platform flag, and the store
|
||||
// does it in the same transaction as the status change through the supplied
|
||||
// applyVerified callback, so the invariant "approved implies target verified"
|
||||
// cannot be broken by a crash between two writes. The callback receives the
|
||||
// application as it will be stored and must set the flag on the target peer.
|
||||
//
|
||||
// The notification outbox row is written in the same transaction and is unique
|
||||
// per (application, kind), which makes a repeated approve a no-op that returns
|
||||
// changed=false instead of notifying twice.
|
||||
DecideVerificationApplication(ctx context.Context, decision domain.VerificationDecision, approve bool, applyVerified func(ctx context.Context, app domain.VerificationApplication) error) (app domain.VerificationApplication, changed bool, err error)
|
||||
// RevokeVerification clears the platform flag of a previously approved target
|
||||
// through the same callback discipline, appends a revoked event to the newest
|
||||
// approved application for that target, and enqueues the applicant
|
||||
// notification. The application itself stays approved: it is history.
|
||||
RevokeVerification(ctx context.Context, req domain.VerificationRevocation, clearVerified func(ctx context.Context, target domain.Peer) error) (app domain.VerificationApplication, changed bool, err error)
|
||||
// VerificationApplication reads one application by id.
|
||||
VerificationApplication(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
|
||||
// ActiveVerificationApplicationForTarget returns the live application
|
||||
// occupying a target, if any.
|
||||
ActiveVerificationApplicationForTarget(ctx context.Context, target domain.VerificationTargetType, targetID int64) (domain.VerificationApplication, error)
|
||||
// VerificationDraftForApplicant returns the applicant's open draft, if any.
|
||||
VerificationDraftForApplicant(ctx context.Context, applicantUserID int64) (domain.VerificationApplication, error)
|
||||
// ListVerificationApplications is the review-queue query with keyset paging.
|
||||
ListVerificationApplications(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error)
|
||||
// VerificationApplicationsForApplicant returns the applicant's own history,
|
||||
// newest first, for the bot's /status command.
|
||||
VerificationApplicationsForApplicant(ctx context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error)
|
||||
// VerificationStatusCounts is the queue summary.
|
||||
VerificationStatusCounts(ctx context.Context) (domain.VerificationStatusCounts, error)
|
||||
// VerificationApplicationEvents returns the immutable history, newest first.
|
||||
VerificationApplicationEvents(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error)
|
||||
// LastVerificationRejection returns the newest rejected application for the
|
||||
// applicant/target pair, which is what the re-application cooldown is measured
|
||||
// from.
|
||||
LastVerificationRejection(ctx context.Context, applicantUserID int64, target domain.VerificationTargetType, targetID int64) (domain.VerificationApplication, error)
|
||||
// PendingVerificationNotifications returns undelivered outbox rows, oldest
|
||||
// first, for the delivery worker.
|
||||
PendingVerificationNotifications(ctx context.Context, limit int) ([]VerificationNotification, error)
|
||||
// MarkVerificationNotificationDelivered closes an outbox row.
|
||||
MarkVerificationNotificationDelivered(ctx context.Context, id int64) error
|
||||
// MarkVerificationNotificationFailed records a delivery attempt so a poisoned
|
||||
// row cannot spin forever without a trace.
|
||||
MarkVerificationNotificationFailed(ctx context.Context, id int64, reason string) error
|
||||
}
|
||||
|
||||
// VerificationNotification is one queued applicant notification.
|
||||
type VerificationNotification struct {
|
||||
ID int64
|
||||
ApplicationID int64
|
||||
RecipientUserID int64
|
||||
Kind string
|
||||
Attempts int
|
||||
Application domain.VerificationApplication
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue