feat: add NFT usernames and bot verification (#22)

Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review.

The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation.

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
This commit is contained in:
Egor Egorov 2026-07-27 20:18:00 +03:00 committed by GitHub
parent b0fd3976f1
commit fff8de783a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
169 changed files with 55769 additions and 282 deletions

View file

@ -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)

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

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

View file

@ -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.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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, `

View file

@ -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

View file

@ -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)

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

View 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(&registry); 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)
}
}

View file

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

View file

@ -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

View file

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

View file

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

View file

@ -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
}
}

File diff suppressed because it is too large Load diff

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