feat: add NFT usernames and bot verification (#22)
Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review. The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation. Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9 Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
This commit is contained in:
parent
b0fd3976f1
commit
fff8de783a
169 changed files with 55769 additions and 282 deletions
462
internal/store/postgres/account_rating_integration_test.go
Normal file
462
internal/store/postgres/account_rating_integration_test.go
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// ratingTestUser inserts a user row with an explicit creation date so the account
|
||||
// age signal is deterministic.
|
||||
func ratingTestUser(t *testing.T, pool *pgxpool.Pool, seed int64, createdAt time.Time) int64 {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var id int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO users (access_hash, phone, first_name, created_at, updated_at)
|
||||
VALUES ($1, $2, 'rating test', $3, $3)
|
||||
RETURNING id`, seed, fmt.Sprintf("%d", seed), createdAt).Scan(&id); err != nil {
|
||||
t.Fatalf("insert rating test user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
// ratingTestCatalogRevision publishes a throwaway star gift so peer_star_gifts
|
||||
// rows can satisfy their catalog revision foreign key.
|
||||
func ratingTestCatalogRevision(t *testing.T, pool *pgxpool.Pool) (revisionID, giftID int64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
docID := time.Now().UnixNano() & 0x7fffffffffffffff
|
||||
entry, err := NewStarGiftStore(pool).CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Stars: 50, ConvertStars: 50, Enabled: true,
|
||||
Document: domain.Document{
|
||||
ID: docID, AccessHash: docID + 1, MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
|
||||
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
|
||||
},
|
||||
Blob: domain.FileBlob{
|
||||
LocationKey: "doc:" + fmt.Sprint(docID), Backend: domain.MediaBackendLocalFS,
|
||||
ObjectKey: "rating-star-gift", Size: 4, SHA256: make([]byte, 32),
|
||||
MimeType: "application/x-tgsticker",
|
||||
},
|
||||
Animation: domain.StarGiftAnimation{
|
||||
JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`),
|
||||
SHA256: make([]byte, 32), SourceFormat: domain.StarGiftAnimationTGS, Width: 512, Height: 512,
|
||||
},
|
||||
Actor: "test", CommandID: "rating-star-gift-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create catalog revision: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT id FROM star_gift_catalog_revisions WHERE gift_id = $1 ORDER BY revision DESC LIMIT 1`,
|
||||
entry.Gift.ID).Scan(&revisionID); err != nil {
|
||||
t.Fatalf("read catalog revision id: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx := context.Background()
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM star_gift_catalog WHERE gift_id = $1`, entry.Gift.ID)
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM star_gift_catalog_revisions WHERE gift_id = $1`, entry.Gift.ID)
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM file_blobs WHERE location_key = $1`, "doc:"+fmt.Sprint(docID))
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM documents WHERE id = $1`, docID)
|
||||
})
|
||||
return revisionID, entry.Gift.ID
|
||||
}
|
||||
|
||||
// TestAccountRatingSaveVersionConflict covers the optimistic write: a first save
|
||||
// creates the row, a stale version is refused without an error, and the next
|
||||
// version wins.
|
||||
func TestAccountRatingSaveVersionConflict(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
userID := ratingTestUser(t, pool, 3_100_000_000+seed, time.Now().UTC().AddDate(0, 0, -30))
|
||||
|
||||
if _, err := store.AccountRating(ctx, userID); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("missing rating err = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
signals := domain.AccountRatingSignals{UserID: userID, StarsReceived: 900, MessagesSent: 10, AccountAgeDays: 30}
|
||||
computed := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
|
||||
stored, changed, err := store.SaveAccountRating(ctx, computed)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("first save changed=%v err=%v", changed, err)
|
||||
}
|
||||
if stored.Version != 1 || stored.Stars != computed.Stars || stored.Level != computed.Level ||
|
||||
stored.HasNextLevel != computed.HasNextLevel || stored.NextLevelStars != computed.NextLevelStars {
|
||||
t.Fatalf("stored = %+v want %+v", stored, computed)
|
||||
}
|
||||
read, err := store.AccountRating(ctx, userID)
|
||||
if err != nil || read != stored {
|
||||
t.Fatalf("read = %+v stored = %+v err=%v", read, stored, err)
|
||||
}
|
||||
|
||||
// A second writer that still believes version 0 is stale: no error, no write.
|
||||
stale := computed
|
||||
stale.Stars = 999_999
|
||||
conflicted, changed, err := store.SaveAccountRating(ctx, stale)
|
||||
if err != nil || changed {
|
||||
t.Fatalf("stale save changed=%v err=%v", changed, err)
|
||||
}
|
||||
if conflicted.Stars != stored.Stars || conflicted.Version != 1 {
|
||||
t.Fatalf("conflicted = %+v, stored row must win", conflicted)
|
||||
}
|
||||
|
||||
// The recompute that carries the right version applies, including a pending
|
||||
// delta that must round-trip through the paired pending_stars/pending_date.
|
||||
next := domain.ResolveAccountRatingPending(stored,
|
||||
domain.ComputeAccountRating(domain.AccountRatingSignals{UserID: userID, StarsReceived: 40_000}, domain.DefaultAccountRatingWeights(), now),
|
||||
time.Hour, now)
|
||||
if next.PendingStars == 0 || next.PendingDate.IsZero() {
|
||||
t.Fatalf("expected a parked pending delta, got %+v", next)
|
||||
}
|
||||
applied, changed, err := store.SaveAccountRating(ctx, next)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("versioned save changed=%v err=%v", changed, err)
|
||||
}
|
||||
if applied.Version != 2 || applied.PendingStars != next.PendingStars ||
|
||||
!applied.PendingDate.Equal(next.PendingDate.UTC()) {
|
||||
t.Fatalf("applied = %+v want pending %d at %v", applied, next.PendingStars, next.PendingDate)
|
||||
}
|
||||
pending, ok := applied.PendingLevel()
|
||||
if !ok || pending.Stars <= applied.Stars {
|
||||
t.Fatalf("pending projection = %+v ok=%v", pending, ok)
|
||||
}
|
||||
|
||||
batch, err := store.AccountRatingBatch(ctx, []int64{userID, userID + 1})
|
||||
if err != nil || len(batch) != 1 || batch[userID].Version != 2 {
|
||||
t.Fatalf("batch = %+v err=%v", batch, err)
|
||||
}
|
||||
list, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{UserID: userID, Limit: 10})
|
||||
if err != nil || len(list) != 1 || list[0].UserID != userID {
|
||||
t.Fatalf("list = %+v err=%v", list, err)
|
||||
}
|
||||
stale2, err := store.StaleAccountRatings(ctx, now.Add(time.Minute).Unix(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("stale: %v", err)
|
||||
}
|
||||
if !containsInt64(stale2, userID) {
|
||||
t.Fatalf("stale ratings %v must contain %d", stale2, userID)
|
||||
}
|
||||
fresh, err := store.StaleAccountRatings(ctx, now.Add(-time.Hour).Unix(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("stale fresh: %v", err)
|
||||
}
|
||||
if containsInt64(fresh, userID) {
|
||||
t.Fatalf("rating computed at %v must not be stale before it", applied.ComputedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountRatingAdjustmentIdempotency covers the manual ledger: a replayed
|
||||
// command key records nothing new and the manual total feeds the recompute.
|
||||
func TestAccountRatingAdjustmentIdempotency(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
userID := ratingTestUser(t, pool, 3_200_000_000+seed, time.Now().UTC().AddDate(0, 0, -10))
|
||||
key := fmt.Sprintf("adjust-%d", seed)
|
||||
|
||||
req := domain.AdjustAccountRatingRequest{
|
||||
UserID: userID, Amount: 750, Reason: "community award", Actor: "ops", CommandKey: key,
|
||||
}
|
||||
event, applied, err := store.AdjustAccountRating(ctx, req)
|
||||
if err != nil || !applied {
|
||||
t.Fatalf("adjust applied=%v err=%v", applied, err)
|
||||
}
|
||||
if event.ID == 0 || event.Kind != domain.AccountRatingEventManual || event.Amount != 750 ||
|
||||
event.CommandKey != key {
|
||||
t.Fatalf("event = %+v", event)
|
||||
}
|
||||
replay, applied, err := store.AdjustAccountRating(ctx, req)
|
||||
if err != nil || applied || replay.ID != event.ID {
|
||||
t.Fatalf("replay applied=%v event=%+v err=%v", applied, replay, err)
|
||||
}
|
||||
// A second, distinct adjustment accumulates rather than replacing.
|
||||
if _, applied, err := store.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
|
||||
UserID: userID, Amount: -250, Reason: "partial revoke", Actor: "ops",
|
||||
CommandKey: key + "-b",
|
||||
}); err != nil || !applied {
|
||||
t.Fatalf("second adjust applied=%v err=%v", applied, err)
|
||||
}
|
||||
events, err := store.AccountRatingEvents(ctx, userID, 10)
|
||||
if err != nil || len(events) != 2 || events[0].Amount != -250 || events[1].Amount != 750 {
|
||||
t.Fatalf("events = %+v err=%v", events, err)
|
||||
}
|
||||
if _, _, err := store.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
|
||||
UserID: userID, Amount: 0, CommandKey: key + "-c",
|
||||
}); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("zero adjustment err = %v, want ErrAccountRatingAdjustmentInvalid", err)
|
||||
}
|
||||
signals, err := store.AccountRatingSignals(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("signals: %v", err)
|
||||
}
|
||||
if signals.Manual != 500 {
|
||||
t.Fatalf("manual signal = %d, want 500", signals.Manual)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountRatingSignalsSources pins where each contribution comes from: the
|
||||
// Stars ledger sign split, saved gifts, upheld moderation cases, the peer flags,
|
||||
// account age and the private-message count.
|
||||
func TestAccountRatingSignalsSources(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
createdAt := time.Now().UTC().AddDate(0, 0, -45)
|
||||
userID := ratingTestUser(t, pool, 3_300_000_000+seed, createdAt)
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO stars_transactions (user_id, amount, reason, date)
|
||||
VALUES ($1, 1500, 'gift', 0), ($1, 500, 'reaction', 0), ($1, -400, 'purchase', 0)`, userID); err != nil {
|
||||
t.Fatalf("seed stars: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM stars_transactions WHERE user_id = $1`, userID)
|
||||
})
|
||||
revisionID, giftID := ratingTestCatalogRevision(t, pool)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO peer_star_gifts (owner_peer_type, owner_peer_id, msg_id, gift_id, gift_date, catalog_revision_id, lifecycle_status, converted)
|
||||
VALUES ('user', $1, 1, $2, 0, $3, 'active', false),
|
||||
('user', $1, 2, $2, 0, $3, 'active', false),
|
||||
('user', $1, 3, $2, 0, $3, 'converted', true)`, userID, giftID, revisionID); err != nil {
|
||||
t.Fatalf("seed gifts: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(),
|
||||
`DELETE FROM peer_star_gifts WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, userID)
|
||||
})
|
||||
// Only statuses that follow a violation decision count; 'dismissed' (which
|
||||
// covers no_violation and a granted appeal) and undecided states do not.
|
||||
now := time.Now().UTC()
|
||||
for _, status := range []string{"resolved", "action_failed", "dismissed", "open"} {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO moderation_cases (
|
||||
target_peer_type, target_peer_id, status, severity, report_count,
|
||||
distinct_reporter_count, first_report_at, last_report_at, created_at, updated_at
|
||||
) VALUES ('user', $1, $2, 1, 1, 1, $3, $3, $3, $3)`, userID, status, now); err != nil {
|
||||
t.Fatalf("seed moderation case %s: %v", status, err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(),
|
||||
`DELETE FROM moderation_cases WHERE target_peer_type = 'user' AND target_peer_id = $1`, userID)
|
||||
})
|
||||
if _, err := pool.Exec(ctx, `UPDATE users SET scam = true WHERE id = $1`, userID); err != nil {
|
||||
t.Fatalf("set scam: %v", err)
|
||||
}
|
||||
// Two private messages, each materialised as the sender's and the recipient's
|
||||
// box: the distinct count must report two, not four.
|
||||
peerID := ratingTestUser(t, pool, 3_350_000_000+seed, createdAt)
|
||||
for i := 1; i <= 2; i++ {
|
||||
var messageID int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO private_messages (sender_user_id, recipient_user_id, message_date, body)
|
||||
VALUES ($1, $2, 0, 'hi')
|
||||
RETURNING id`, userID, peerID).Scan(&messageID); err != nil {
|
||||
t.Fatalf("seed private message: %v", err)
|
||||
}
|
||||
for _, box := range []struct {
|
||||
owner int64
|
||||
peer int64
|
||||
outgoing bool
|
||||
}{{userID, peerID, true}, {peerID, userID, false}} {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO message_boxes (
|
||||
owner_user_id, box_id, private_message_id, message_sender_id, peer_type, peer_id,
|
||||
from_user_id, message_date, outgoing, body
|
||||
) VALUES ($1, $2, $3, $4, 'user', $5, $4, 0, $6, 'hi')`,
|
||||
box.owner, i, messageID, userID, box.peer, box.outgoing); err != nil {
|
||||
t.Fatalf("seed message box: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM message_boxes WHERE message_sender_id = $1`, userID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM private_messages WHERE sender_user_id = $1`, userID)
|
||||
})
|
||||
|
||||
signals, err := store.AccountRatingSignals(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("signals: %v", err)
|
||||
}
|
||||
if signals.UserID != userID || signals.StarsReceived != 2000 || signals.StarsSpent != 400 ||
|
||||
signals.GiftsReceived != 2 || signals.ModerationCases != 2 || !signals.Scam || signals.Fake ||
|
||||
signals.MessagesSent != 2 || signals.AccountAgeDays != 45 || signals.Manual != 0 {
|
||||
t.Fatalf("signals = %+v", signals)
|
||||
}
|
||||
rating := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
|
||||
if rating.PenaltyComponent == 0 || rating.StarsComponent != 2000+100 {
|
||||
t.Fatalf("computed rating = %+v", rating)
|
||||
}
|
||||
if _, err := store.AccountRatingSignals(ctx, userID+7_000_000); !errors.Is(err, domain.ErrUserNotFound) {
|
||||
t.Fatalf("signals for unknown user err = %v, want ErrUserNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountRatingLeaderboardPaging covers the keyset walk over
|
||||
// (level DESC, stars DESC, user_id).
|
||||
func TestAccountRatingLeaderboardPaging(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
now := time.Now().UTC()
|
||||
scores := []int64{100, 400, 900, 1600}
|
||||
ids := make([]int64, 0, len(scores))
|
||||
for i, score := range scores {
|
||||
userID := ratingTestUser(t, pool, 3_400_000_000+seed+int64(i), now.AddDate(0, 0, -1))
|
||||
ids = append(ids, userID)
|
||||
rating := domain.ComputeAccountRating(domain.AccountRatingSignals{UserID: userID, Manual: score},
|
||||
domain.DefaultAccountRatingWeights(), now)
|
||||
if _, changed, err := store.SaveAccountRating(ctx, rating); err != nil || !changed {
|
||||
t.Fatalf("save rating %d: changed=%v err=%v", userID, changed, err)
|
||||
}
|
||||
}
|
||||
page, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: 2, Limit: 2})
|
||||
if err != nil || len(page) != 2 {
|
||||
t.Fatalf("first page = %+v err=%v", page, err)
|
||||
}
|
||||
if page[0].Level < page[1].Level {
|
||||
t.Fatalf("leaderboard must be level-descending: %+v", page)
|
||||
}
|
||||
next, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{
|
||||
MinLevel: 2, BeforeID: page[len(page)-1].UserID, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second page: %v", err)
|
||||
}
|
||||
for _, item := range next {
|
||||
if item.UserID == page[0].UserID || item.UserID == page[1].UserID {
|
||||
t.Fatalf("keyset page repeated user %d", item.UserID)
|
||||
}
|
||||
if item.Level > page[len(page)-1].Level {
|
||||
t.Fatalf("keyset page went backwards: %+v after %+v", item, page)
|
||||
}
|
||||
}
|
||||
if _, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: -1}); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("negative level filter must be rejected")
|
||||
}
|
||||
_ = ids
|
||||
}
|
||||
|
||||
// TestAccountRatingUnratedAccountsSeedsTheReadModel proves the SQL behind the
|
||||
// bootstrap pass: only accounts with no projection are returned, bots and deleted
|
||||
// tombstones are excluded, the walk is oldest-account-first, and a user drops out of
|
||||
// the candidate set the moment a projection exists.
|
||||
//
|
||||
// Without this query the read model can never populate itself -- StaleAccountRatings
|
||||
// walks account_rating and so cannot return a user who is not in it.
|
||||
func TestAccountRatingUnratedAccountsSeedsTheReadModel(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
base := time.Now().UTC().Add(-72 * time.Hour).Truncate(time.Second)
|
||||
|
||||
oldest := ratingTestUser(t, pool, 4_100_000_000+seed, base)
|
||||
middle := ratingTestUser(t, pool, 4_200_000_000+seed, base.Add(time.Hour))
|
||||
newest := ratingTestUser(t, pool, 4_300_000_000+seed, base.Add(2*time.Hour))
|
||||
bot := ratingTestUser(t, pool, 4_400_000_000+seed, base.Add(3*time.Hour))
|
||||
deleted := ratingTestUser(t, pool, 4_500_000_000+seed, base.Add(4*time.Hour))
|
||||
|
||||
if _, err := pool.Exec(ctx, `UPDATE users SET is_bot = true WHERE id = $1`, bot); err != nil {
|
||||
t.Fatalf("mark bot: %v", err)
|
||||
}
|
||||
// A deleted account is a tombstone: every profile field is already cleared, so
|
||||
// there is no rating to show and no reason to compute one.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE users SET deleted_at = now(), deletion_source = 'manual', deletion_reason = 'test',
|
||||
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
|
||||
verified = false, support = false, premium_expires_at = NULL,
|
||||
emoji_status_document_id = 0, emoji_status_until = 0,
|
||||
emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb,
|
||||
color_set = false, color = 0, color_background_emoji_id = 0,
|
||||
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
|
||||
birthday_day = 0, birthday_month = 0, birthday_year = 0,
|
||||
personal_channel_id = 0, last_seen_at = 0, account_delete_at = NULL
|
||||
WHERE id = $1`, deleted); err != nil {
|
||||
t.Fatalf("mark deleted: %v", err)
|
||||
}
|
||||
|
||||
// The table is shared with every other account in the test database, so assert
|
||||
// on relative order and membership rather than on an exact page.
|
||||
positions := func(t *testing.T) (map[int64]int, []int64) {
|
||||
t.Helper()
|
||||
ids, err := store.UnratedAccounts(ctx, maxAccountRatingListLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("UnratedAccounts: %v", err)
|
||||
}
|
||||
index := make(map[int64]int, len(ids))
|
||||
for i, id := range ids {
|
||||
index[id] = i
|
||||
}
|
||||
return index, ids
|
||||
}
|
||||
|
||||
index, ids := positions(t)
|
||||
for _, id := range []int64{oldest, middle, newest} {
|
||||
if _, ok := index[id]; !ok {
|
||||
t.Fatalf("account %d with no projection is absent from %d candidates", id, len(ids))
|
||||
}
|
||||
}
|
||||
if _, ok := index[bot]; ok {
|
||||
t.Fatalf("bot %d was offered as a rating candidate", bot)
|
||||
}
|
||||
if _, ok := index[deleted]; ok {
|
||||
t.Fatalf("deleted account %d was offered as a rating candidate", deleted)
|
||||
}
|
||||
// The service accounts are infrastructure. The platform account in particular is
|
||||
// NOT flagged is_bot, so excluding bots alone would still have seeded it -- which
|
||||
// is exactly how it got a rating.
|
||||
for _, serviceID := range domain.SystemUserIDs() {
|
||||
if _, ok := index[serviceID]; ok {
|
||||
t.Fatalf("service account %d was offered as a rating candidate", serviceID)
|
||||
}
|
||||
}
|
||||
if !(index[oldest] < index[middle] && index[middle] < index[newest]) {
|
||||
t.Fatalf("candidate order = oldest %d, middle %d, newest %d; want oldest first",
|
||||
index[oldest], index[middle], index[newest])
|
||||
}
|
||||
|
||||
// Seeding one account removes it from the candidate set, so the pass converges
|
||||
// instead of offering the same user every cycle.
|
||||
if _, changed, err := store.SaveAccountRating(ctx, domain.AccountRating{
|
||||
UserID: middle, Level: 1, Stars: 150,
|
||||
CurrentLevelStars: domain.AccountRatingLevelThreshold(1),
|
||||
ComputedAt: time.Now().UTC(), Version: 1,
|
||||
}); err != nil || !changed {
|
||||
t.Fatalf("seed projection = %v changed=%v", err, changed)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM account_rating WHERE user_id = $1`, middle)
|
||||
})
|
||||
index, _ = positions(t)
|
||||
if _, ok := index[middle]; ok {
|
||||
t.Fatalf("account %d is still a candidate after being seeded", middle)
|
||||
}
|
||||
if _, ok := index[oldest]; !ok {
|
||||
t.Fatalf("seeding %d removed unrelated candidate %d", middle, oldest)
|
||||
}
|
||||
|
||||
// The limit is honoured, so one cycle can never walk the whole users table.
|
||||
capped, err := store.UnratedAccounts(ctx, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("UnratedAccounts with a limit: %v", err)
|
||||
}
|
||||
if len(capped) != 2 {
|
||||
t.Fatalf("limited candidates = %d, want 2", len(capped))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue