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>
462 lines
19 KiB
Go
462 lines
19 KiB
Go
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))
|
|
}
|
|
}
|