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>
425 lines
15 KiB
Go
425 lines
15 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
)
|
|
|
|
var _ store.AccountRatingStore = (*AccountRatingStore)(nil)
|
|
|
|
// accountRatingFixture builds a projection the table's CHECK constraints accept.
|
|
func accountRatingFixture(userID, stars, version int64, computedAt time.Time) domain.AccountRating {
|
|
level, current, next, hasNext := domain.AccountRatingLevelForStars(stars)
|
|
return domain.AccountRating{
|
|
UserID: userID,
|
|
Level: level,
|
|
Stars: stars,
|
|
CurrentLevelStars: current,
|
|
NextLevelStars: next,
|
|
HasNextLevel: hasNext,
|
|
StarsComponent: stars,
|
|
ComputedAt: computedAt,
|
|
UpdatedAt: computedAt,
|
|
Version: version,
|
|
}
|
|
}
|
|
|
|
func TestSaveAccountRating(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Unix(1700000000, 0).UTC()
|
|
|
|
tests := []struct {
|
|
name string
|
|
seed []domain.AccountRating
|
|
input domain.AccountRating
|
|
wantErr error
|
|
wantChanged bool
|
|
check func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating)
|
|
}{
|
|
{
|
|
name: "insert",
|
|
input: accountRatingFixture(11, 450, 1, now),
|
|
wantChanged: true,
|
|
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
|
|
if stored.Level != 2 || stored.Stars != 450 || stored.Version != 1 ||
|
|
stored.CurrentLevelStars != 400 || stored.NextLevelStars != 900 || !stored.HasNextLevel {
|
|
t.Fatalf("stored=%+v", stored)
|
|
}
|
|
read, err := s.AccountRating(ctx, 11)
|
|
if err != nil || read != stored {
|
|
t.Fatalf("read=%+v err=%v", read, err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "successor version applied",
|
|
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
|
|
input: accountRatingFixture(11, 1000, 2, now.Add(time.Minute)),
|
|
wantChanged: true,
|
|
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
|
|
if stored.Version != 2 || stored.Stars != 1000 || stored.Level != 3 {
|
|
t.Fatalf("stored=%+v", stored)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "replayed version is stale",
|
|
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
|
|
input: accountRatingFixture(11, 9999, 1, now.Add(time.Minute)),
|
|
wantChanged: false,
|
|
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
|
|
// The loser of the race gets the current row back, untouched.
|
|
if stored.Stars != 450 || stored.Version != 1 {
|
|
t.Fatalf("stored=%+v", stored)
|
|
}
|
|
read, err := s.AccountRating(ctx, 11)
|
|
if err != nil || read.Stars != 450 || read.Version != 1 {
|
|
t.Fatalf("read=%+v err=%v", read, err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "version from the future is rejected",
|
|
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
|
|
input: accountRatingFixture(11, 9999, 7, now.Add(time.Minute)),
|
|
wantChanged: false,
|
|
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
|
|
if stored.Stars != 450 || stored.Version != 1 {
|
|
t.Fatalf("stored=%+v", stored)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "insert must carry version one",
|
|
input: accountRatingFixture(11, 450, 3, now),
|
|
wantChanged: false,
|
|
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
|
|
if stored != (domain.AccountRating{}) {
|
|
t.Fatalf("stored=%+v", stored)
|
|
}
|
|
if _, err := s.AccountRating(ctx, 11); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
|
t.Fatalf("row was written: %v", err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "no user",
|
|
input: accountRatingFixture(0, 450, 1, now),
|
|
wantErr: domain.ErrAccountRatingNotFound,
|
|
},
|
|
{
|
|
name: "pending delta without a date is dropped",
|
|
input: func() domain.AccountRating {
|
|
rating := accountRatingFixture(11, 450, 1, now)
|
|
rating.PendingStars = 120
|
|
return rating
|
|
}(),
|
|
wantChanged: true,
|
|
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
|
|
if stored.PendingStars != 0 || !stored.PendingDate.IsZero() {
|
|
t.Fatalf("stored=%+v", stored)
|
|
}
|
|
if _, ok := stored.PendingLevel(); ok {
|
|
t.Fatalf("pending projection survived: %+v", stored)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "pending pair is kept",
|
|
input: func() domain.AccountRating {
|
|
rating := accountRatingFixture(11, 450, 1, now)
|
|
rating.PendingStars = 500
|
|
rating.PendingDate = now.Add(time.Hour)
|
|
return rating
|
|
}(),
|
|
wantChanged: true,
|
|
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
|
|
pending, ok := stored.PendingLevel()
|
|
if !ok || pending.Stars != 950 || pending.Level != 3 {
|
|
t.Fatalf("pending=%+v ok=%v", pending, ok)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "impossible components are folded",
|
|
input: func() domain.AccountRating {
|
|
rating := accountRatingFixture(11, 450, 1, now)
|
|
rating.Level = -3
|
|
rating.StarsComponent = -10
|
|
rating.ActivityComponent = -1
|
|
rating.PenaltyComponent = -7
|
|
rating.CurrentLevelStars = -5
|
|
rating.NextLevelStars = -9
|
|
rating.HasNextLevel = true
|
|
return rating
|
|
}(),
|
|
wantChanged: true,
|
|
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
|
|
if stored.Level != 0 || stored.StarsComponent != 0 || stored.ActivityComponent != 0 ||
|
|
stored.PenaltyComponent != 0 || stored.CurrentLevelStars != 0 {
|
|
t.Fatalf("stored=%+v", stored)
|
|
}
|
|
if stored.HasNextLevel || stored.NextLevelStars != 0 {
|
|
t.Fatalf("next level survived: %+v", stored)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s := NewAccountRatingStore()
|
|
for _, seed := range tc.seed {
|
|
if _, changed, err := s.SaveAccountRating(ctx, seed); err != nil || !changed {
|
|
t.Fatalf("seed changed=%v err=%v", changed, err)
|
|
}
|
|
}
|
|
stored, changed, err := s.SaveAccountRating(ctx, tc.input)
|
|
if !errors.Is(err, tc.wantErr) {
|
|
t.Fatalf("err=%v want %v", err, tc.wantErr)
|
|
}
|
|
if changed != tc.wantChanged {
|
|
t.Fatalf("changed=%v want %v", changed, tc.wantChanged)
|
|
}
|
|
if tc.wantErr != nil {
|
|
return
|
|
}
|
|
if tc.check != nil {
|
|
tc.check(t, s, stored)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAccountRatingReadsAndLeaderboard(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Unix(1700000000, 0).UTC()
|
|
s := NewAccountRatingStore()
|
|
for _, rating := range []domain.AccountRating{
|
|
accountRatingFixture(11, 2500, 1, now),
|
|
accountRatingFixture(12, 450, 1, now),
|
|
accountRatingFixture(13, 2500, 1, now),
|
|
accountRatingFixture(14, 0, 1, now),
|
|
} {
|
|
if _, changed, err := s.SaveAccountRating(ctx, rating); err != nil || !changed {
|
|
t.Fatalf("seed %d changed=%v err=%v", rating.UserID, changed, err)
|
|
}
|
|
}
|
|
|
|
batch, err := s.AccountRatingBatch(ctx, []int64{11, 13, 99, 0, 11})
|
|
if err != nil || len(batch) != 2 {
|
|
t.Fatalf("batch=%+v err=%v", batch, err)
|
|
}
|
|
if batch[11].Stars != 2500 || batch[13].Stars != 2500 {
|
|
t.Fatalf("batch=%+v", batch)
|
|
}
|
|
if empty, err := s.AccountRatingBatch(ctx, nil); err != nil || len(empty) != 0 {
|
|
t.Fatalf("empty batch=%+v err=%v", empty, err)
|
|
}
|
|
|
|
// level desc, stars desc, user id asc.
|
|
board, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{})
|
|
if err != nil || len(board) != 4 {
|
|
t.Fatalf("board=%+v err=%v", board, err)
|
|
}
|
|
want := []int64{11, 13, 12, 14}
|
|
for i, userID := range want {
|
|
if board[i].UserID != userID {
|
|
t.Fatalf("board order=%+v want %v", board, want)
|
|
}
|
|
}
|
|
page, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{Limit: 2})
|
|
if err != nil || len(page) != 2 || page[1].UserID != 13 {
|
|
t.Fatalf("page=%+v err=%v", page, err)
|
|
}
|
|
next, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{
|
|
BeforeID: page[len(page)-1].UserID, Limit: 2,
|
|
})
|
|
if err != nil || len(next) != 2 || next[0].UserID != 12 || next[1].UserID != 14 {
|
|
t.Fatalf("next=%+v err=%v", next, err)
|
|
}
|
|
filtered, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: 5})
|
|
if err != nil || len(filtered) != 2 {
|
|
t.Fatalf("filtered=%+v err=%v", filtered, err)
|
|
}
|
|
single, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{UserID: 12})
|
|
if err != nil || len(single) != 1 || single[0].UserID != 12 {
|
|
t.Fatalf("single=%+v err=%v", single, err)
|
|
}
|
|
if _, err := s.AccountRating(ctx, 99); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
|
t.Fatalf("unknown user err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestAdjustAccountRating(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := NewAccountRatingStore()
|
|
req := domain.AdjustAccountRatingRequest{
|
|
UserID: 11, Amount: 750, Reason: "contest prize", Actor: "admin", CommandKey: "cmd-adjust",
|
|
}
|
|
|
|
event, applied, err := s.AdjustAccountRating(ctx, req)
|
|
if err != nil || !applied {
|
|
t.Fatalf("adjust applied=%v err=%v", applied, err)
|
|
}
|
|
if event.ID == 0 || event.Kind != domain.AccountRatingEventManual || event.Amount != 750 ||
|
|
event.Reason != "contest prize" || event.Actor != "admin" || event.CreatedAt.IsZero() {
|
|
t.Fatalf("event=%+v", event)
|
|
}
|
|
|
|
// Replaying the command key returns the recorded row and appends nothing.
|
|
replay, applied, err := s.AdjustAccountRating(ctx, req)
|
|
if err != nil || applied {
|
|
t.Fatalf("replay applied=%v err=%v", applied, err)
|
|
}
|
|
if replay != event {
|
|
t.Fatalf("replay=%+v want %+v", replay, event)
|
|
}
|
|
ledger, err := s.AccountRatingEvents(ctx, 11, 10)
|
|
if err != nil || len(ledger) != 1 {
|
|
t.Fatalf("ledger=%+v err=%v", ledger, err)
|
|
}
|
|
|
|
second := req
|
|
second.Amount = -200
|
|
second.CommandKey = "cmd-adjust-2"
|
|
if _, applied, err := s.AdjustAccountRating(ctx, second); err != nil || !applied {
|
|
t.Fatalf("second adjust applied=%v err=%v", applied, err)
|
|
}
|
|
// Newest first, and other users are not mixed in.
|
|
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
|
|
UserID: 12, Amount: 5, CommandKey: "cmd-other",
|
|
}); err != nil || !applied {
|
|
t.Fatalf("other user applied=%v err=%v", applied, err)
|
|
}
|
|
ledger, err = s.AccountRatingEvents(ctx, 11, 10)
|
|
if err != nil || len(ledger) != 2 || ledger[0].Amount != -200 || ledger[1].Amount != 750 {
|
|
t.Fatalf("ledger=%+v err=%v", ledger, err)
|
|
}
|
|
if capped, err := s.AccountRatingEvents(ctx, 11, 1); err != nil || len(capped) != 1 ||
|
|
capped[0].Amount != -200 {
|
|
t.Fatalf("capped=%+v err=%v", capped, err)
|
|
}
|
|
|
|
// An unkeyed adjustment is always appended.
|
|
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
|
|
UserID: 11, Amount: 10,
|
|
}); err != nil || !applied {
|
|
t.Fatalf("unkeyed applied=%v err=%v", applied, err)
|
|
}
|
|
|
|
for _, invalid := range []domain.AdjustAccountRatingRequest{
|
|
{UserID: 11, Amount: 0, CommandKey: "cmd-zero"},
|
|
{UserID: 0, Amount: 5, CommandKey: "cmd-nouser"},
|
|
} {
|
|
if _, applied, err := s.AdjustAccountRating(ctx, invalid); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) || applied {
|
|
t.Fatalf("invalid adjust applied=%v err=%v", applied, err)
|
|
}
|
|
}
|
|
|
|
// The manual total is carried out of the ledger into the signal snapshot.
|
|
signals, err := s.AccountRatingSignals(ctx, 11)
|
|
if err != nil || signals.UserID != 11 || signals.Manual != 560 {
|
|
t.Fatalf("signals=%+v err=%v", signals, err)
|
|
}
|
|
}
|
|
|
|
func TestAccountRatingSignals(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := NewAccountRatingStore()
|
|
|
|
// A user with no contributions reports zeros rather than an error.
|
|
signals, err := s.AccountRatingSignals(ctx, 11)
|
|
if err != nil || signals != (domain.AccountRatingSignals{UserID: 11}) {
|
|
t.Fatalf("signals=%+v err=%v", signals, err)
|
|
}
|
|
if _, err := s.AccountRatingSignals(ctx, 0); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
|
t.Fatalf("missing user err=%v", err)
|
|
}
|
|
|
|
s.setAccountRatingSignals(domain.AccountRatingSignals{
|
|
UserID: 11, StarsReceived: 4000, StarsSpent: 2000, MessagesSent: 300,
|
|
AccountAgeDays: 100, GiftsReceived: 4, ModerationCases: 1,
|
|
})
|
|
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
|
|
UserID: 11, Amount: 250, CommandKey: "cmd-bonus",
|
|
}); err != nil || !applied {
|
|
t.Fatalf("adjust applied=%v err=%v", applied, err)
|
|
}
|
|
signals, err = s.AccountRatingSignals(ctx, 11)
|
|
if err != nil || signals.StarsReceived != 4000 || signals.MessagesSent != 300 ||
|
|
signals.ModerationCases != 1 || signals.Manual != 250 {
|
|
t.Fatalf("signals=%+v err=%v", signals, err)
|
|
}
|
|
|
|
// The snapshot feeds the domain formula, and the result round-trips.
|
|
now := time.Unix(1700000000, 0).UTC()
|
|
computed := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
|
|
stored, changed, err := s.SaveAccountRating(ctx, computed)
|
|
if err != nil || !changed {
|
|
t.Fatalf("save changed=%v err=%v", changed, err)
|
|
}
|
|
if stored.Stars != computed.Stars || stored.Level != computed.Level ||
|
|
stored.ManualComponent != 250 {
|
|
t.Fatalf("stored=%+v computed=%+v", stored, computed)
|
|
}
|
|
|
|
// A recompute uses the pending resolution the domain owns.
|
|
s.setAccountRatingSignals(domain.AccountRatingSignals{UserID: 11, StarsReceived: 40000})
|
|
signals, err = s.AccountRatingSignals(ctx, 11)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
recomputed := domain.ResolveAccountRatingPending(stored,
|
|
domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now.Add(time.Hour)),
|
|
24*time.Hour, now.Add(time.Hour))
|
|
saved, changed, err := s.SaveAccountRating(ctx, recomputed)
|
|
if err != nil || !changed || saved.Version != stored.Version+1 {
|
|
t.Fatalf("saved=%+v changed=%v err=%v", saved, changed, err)
|
|
}
|
|
if saved.PendingStars <= 0 || saved.PendingDate.IsZero() {
|
|
t.Fatalf("pending was not parked: %+v", saved)
|
|
}
|
|
}
|
|
|
|
func TestStaleAccountRatings(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Unix(1700000000, 0).UTC()
|
|
s := NewAccountRatingStore()
|
|
for _, rating := range []domain.AccountRating{
|
|
accountRatingFixture(11, 100, 1, now.Add(-3*time.Hour)),
|
|
accountRatingFixture(12, 100, 1, now.Add(-2*time.Hour)),
|
|
accountRatingFixture(13, 100, 1, now.Add(-time.Hour)),
|
|
accountRatingFixture(14, 100, 1, now),
|
|
} {
|
|
if _, changed, err := s.SaveAccountRating(ctx, rating); err != nil || !changed {
|
|
t.Fatalf("seed %d changed=%v err=%v", rating.UserID, changed, err)
|
|
}
|
|
}
|
|
|
|
stale, err := s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 10)
|
|
if err != nil || len(stale) != 2 || stale[0] != 11 || stale[1] != 12 {
|
|
t.Fatalf("stale=%v err=%v", stale, err)
|
|
}
|
|
if limited, err := s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 1); err != nil ||
|
|
len(limited) != 1 || limited[0] != 11 {
|
|
t.Fatalf("limited=%v err=%v", limited, err)
|
|
}
|
|
if none, err := s.StaleAccountRatings(ctx, now.Add(-4*time.Hour).Unix(), 10); err != nil || len(none) != 0 {
|
|
t.Fatalf("none=%v err=%v", none, err)
|
|
}
|
|
// A recompute refreshes computed_at and takes the row out of the horizon.
|
|
refreshed := accountRatingFixture(11, 100, 2, now)
|
|
if _, changed, err := s.SaveAccountRating(ctx, refreshed); err != nil || !changed {
|
|
t.Fatalf("refresh changed=%v err=%v", changed, err)
|
|
}
|
|
stale, err = s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 10)
|
|
if err != nil || len(stale) != 1 || stale[0] != 12 {
|
|
t.Fatalf("stale=%v err=%v", stale, err)
|
|
}
|
|
}
|