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>
719 lines
25 KiB
Go
719 lines
25 KiB
Go
package rating
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
var testNow = time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
|
|
|
|
// fakeRatingStore is an in-memory AccountRatingStore with the same optimistic
|
|
// concurrency contract as PostgreSQL: a save whose version does not follow the
|
|
// stored one reports changed=false and returns the row that won.
|
|
type fakeRatingStore struct {
|
|
signals map[int64]domain.AccountRatingSignals
|
|
ratings map[int64]domain.AccountRating
|
|
manual map[int64]int64
|
|
events map[int64][]domain.AccountRatingEvent
|
|
keys map[string]domain.AccountRatingEvent
|
|
|
|
stale []int64
|
|
staleOlderThan int64
|
|
staleLimit int
|
|
|
|
unrated []int64
|
|
unratedLimit int
|
|
unratedCalls int
|
|
unratedErr error
|
|
|
|
saves []domain.AccountRating
|
|
forceConflicts int
|
|
signalsErr error
|
|
}
|
|
|
|
func newFakeRatingStore() *fakeRatingStore {
|
|
return &fakeRatingStore{
|
|
signals: map[int64]domain.AccountRatingSignals{},
|
|
ratings: map[int64]domain.AccountRating{},
|
|
manual: map[int64]int64{},
|
|
events: map[int64][]domain.AccountRatingEvent{},
|
|
keys: map[string]domain.AccountRatingEvent{},
|
|
}
|
|
}
|
|
|
|
func (f *fakeRatingStore) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
|
rating, ok := f.ratings[userID]
|
|
if !ok {
|
|
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
|
}
|
|
return rating, nil
|
|
}
|
|
|
|
func (f *fakeRatingStore) AccountRatingBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
|
|
out := make(map[int64]domain.AccountRating, len(userIDs))
|
|
for _, userID := range userIDs {
|
|
if rating, ok := f.ratings[userID]; ok {
|
|
out[userID] = rating
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeRatingStore) SaveAccountRating(_ context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
|
|
f.saves = append(f.saves, rating)
|
|
current := f.ratings[rating.UserID]
|
|
if f.forceConflicts > 0 {
|
|
f.forceConflicts--
|
|
return current, false, nil
|
|
}
|
|
if rating.Version != current.Version+1 {
|
|
return current, false, nil
|
|
}
|
|
f.ratings[rating.UserID] = rating
|
|
return rating, true, nil
|
|
}
|
|
|
|
func (f *fakeRatingStore) AccountRatingSignals(_ context.Context, userID int64) (domain.AccountRatingSignals, error) {
|
|
if f.signalsErr != nil {
|
|
return domain.AccountRatingSignals{}, f.signalsErr
|
|
}
|
|
signals := f.signals[userID]
|
|
signals.UserID = userID
|
|
signals.Manual = f.manual[userID]
|
|
return signals, nil
|
|
}
|
|
|
|
func (f *fakeRatingStore) AdjustAccountRating(_ context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
|
|
if req.CommandKey != "" {
|
|
if event, ok := f.keys[req.CommandKey]; ok {
|
|
return event, false, nil
|
|
}
|
|
}
|
|
event := domain.AccountRatingEvent{
|
|
ID: int64(len(f.events[req.UserID]) + 1), UserID: req.UserID, Kind: domain.AccountRatingEventManual,
|
|
Amount: req.Amount, Reason: req.Reason, Actor: req.Actor, CommandKey: req.CommandKey, CreatedAt: testNow,
|
|
}
|
|
f.events[req.UserID] = append(f.events[req.UserID], event)
|
|
f.manual[req.UserID] += req.Amount
|
|
if req.CommandKey != "" {
|
|
f.keys[req.CommandKey] = event
|
|
}
|
|
return event, true, nil
|
|
}
|
|
|
|
func (f *fakeRatingStore) ListAccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
|
out := make([]domain.AccountRating, 0, len(f.ratings))
|
|
for _, rating := range f.ratings {
|
|
if rating.Level >= filter.MinLevel {
|
|
out = append(out, rating)
|
|
}
|
|
if len(out) >= filter.Limit {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeRatingStore) AccountRatingEvents(_ context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
|
|
events := f.events[userID]
|
|
if len(events) > limit {
|
|
events = events[:limit]
|
|
}
|
|
return append([]domain.AccountRatingEvent(nil), events...), nil
|
|
}
|
|
|
|
func (f *fakeRatingStore) StaleAccountRatings(_ context.Context, olderThanUnix int64, limit int) ([]int64, error) {
|
|
f.staleOlderThan = olderThanUnix
|
|
f.staleLimit = limit
|
|
if len(f.stale) > limit {
|
|
return append([]int64(nil), f.stale[:limit]...), nil
|
|
}
|
|
return append([]int64(nil), f.stale...), nil
|
|
}
|
|
|
|
func (f *fakeRatingStore) UnratedAccounts(_ context.Context, limit int) ([]int64, error) {
|
|
f.unratedCalls++
|
|
f.unratedLimit = limit
|
|
if f.unratedErr != nil {
|
|
return nil, f.unratedErr
|
|
}
|
|
out := make([]int64, 0, len(f.unrated))
|
|
for _, userID := range f.unrated {
|
|
if _, rated := f.ratings[userID]; rated {
|
|
continue
|
|
}
|
|
out = append(out, userID)
|
|
if len(out) == limit {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func newTestService(st *fakeRatingStore, opts ...Option) *Service {
|
|
base := []Option{WithStore(st), WithClock(func() time.Time { return testNow })}
|
|
return NewService(append(base, opts...)...)
|
|
}
|
|
|
|
func TestRecomputeAppliesConfiguredWeights(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.signals[7] = domain.AccountRatingSignals{
|
|
StarsReceived: 1000, StarsSpent: 400, MessagesSent: 30, AccountAgeDays: 10,
|
|
GiftsReceived: 2, ModerationCases: 1,
|
|
}
|
|
service := newTestService(st)
|
|
|
|
rating, err := service.Recompute(context.Background(), 7)
|
|
if err != nil {
|
|
t.Fatalf("Recompute: %v", err)
|
|
}
|
|
weights := domain.DefaultAccountRatingWeights()
|
|
want := domain.ComputeAccountRating(domain.AccountRatingSignals{
|
|
UserID: 7, StarsReceived: 1000, StarsSpent: 400, MessagesSent: 30, AccountAgeDays: 10,
|
|
GiftsReceived: 2, ModerationCases: 1,
|
|
}, weights, testNow)
|
|
if rating.Stars != want.Stars || rating.Level != want.Level ||
|
|
rating.StarsComponent != want.StarsComponent || rating.ActivityComponent != want.ActivityComponent ||
|
|
rating.PenaltyComponent != want.PenaltyComponent {
|
|
t.Fatalf("rating = %#v, want the domain formula result %#v", rating, want)
|
|
}
|
|
if rating.Version != 1 {
|
|
t.Fatalf("first stored version = %d, want 1", rating.Version)
|
|
}
|
|
if !rating.ComputedAt.Equal(testNow) {
|
|
t.Fatalf("ComputedAt = %v, want the injected clock %v", rating.ComputedAt, testNow)
|
|
}
|
|
}
|
|
|
|
func TestRecomputePendingPolicy(t *testing.T) {
|
|
t.Run("increase is parked", func(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Level: 1, Version: 4}
|
|
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
|
|
service := newTestService(st, WithPendingDelay(24*time.Hour))
|
|
|
|
rating, err := service.Recompute(context.Background(), 7)
|
|
if err != nil {
|
|
t.Fatalf("Recompute: %v", err)
|
|
}
|
|
if rating.Stars != 100 {
|
|
t.Fatalf("visible stars = %d, want the previous 100 while the increase is pending", rating.Stars)
|
|
}
|
|
if rating.PendingStars != 400 {
|
|
t.Fatalf("pending stars = %d, want 400", rating.PendingStars)
|
|
}
|
|
if want := testNow.Add(24 * time.Hour); !rating.PendingDate.Equal(want) {
|
|
t.Fatalf("pending date = %v, want %v", rating.PendingDate, want)
|
|
}
|
|
if rating.Version != 5 {
|
|
t.Fatalf("version = %d, want 5", rating.Version)
|
|
}
|
|
})
|
|
|
|
t.Run("decrease applies immediately", func(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 500, Level: 2, Version: 1}
|
|
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500, Scam: true}
|
|
service := newTestService(st, WithPendingDelay(24*time.Hour))
|
|
|
|
rating, err := service.Recompute(context.Background(), 7)
|
|
if err != nil {
|
|
t.Fatalf("Recompute: %v", err)
|
|
}
|
|
if rating.Stars != 0 || rating.PendingStars != 0 {
|
|
t.Fatalf("rating = %d stars / %d pending, want a penalty applied at once", rating.Stars, rating.PendingStars)
|
|
}
|
|
if rating.PenaltyComponent != domain.DefaultAccountRatingWeights().ScamPenalty {
|
|
t.Fatalf("penalty = %d, want the scam penalty", rating.PenaltyComponent)
|
|
}
|
|
})
|
|
|
|
t.Run("expired parking is folded into the visible rating", func(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.ratings[7] = domain.AccountRating{
|
|
UserID: 7, Stars: 100, Level: 1, Version: 2,
|
|
PendingStars: 400, PendingDate: testNow.Add(-time.Hour),
|
|
}
|
|
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
|
|
service := newTestService(st, WithPendingDelay(24*time.Hour))
|
|
|
|
rating, err := service.Recompute(context.Background(), 7)
|
|
if err != nil {
|
|
t.Fatalf("Recompute: %v", err)
|
|
}
|
|
if rating.Stars != 500 || rating.PendingStars != 0 || !rating.PendingDate.IsZero() {
|
|
t.Fatalf("rating = %#v, want the parked delta applied and cleared", rating)
|
|
}
|
|
})
|
|
|
|
t.Run("zero delay never parks", func(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Version: 1}
|
|
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 500}
|
|
service := newTestService(st, WithPendingDelay(0))
|
|
|
|
rating, err := service.Recompute(context.Background(), 7)
|
|
if err != nil {
|
|
t.Fatalf("Recompute: %v", err)
|
|
}
|
|
if rating.Stars != 500 || rating.PendingStars != 0 {
|
|
t.Fatalf("rating = %d stars / %d pending, want an immediate apply", rating.Stars, rating.PendingStars)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRecomputeRetriesOnceOnVersionConflict(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 100, Version: 3}
|
|
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 200}
|
|
st.forceConflicts = 1
|
|
service := newTestService(st, WithPendingDelay(0))
|
|
|
|
rating, err := service.Recompute(context.Background(), 7)
|
|
if err != nil {
|
|
t.Fatalf("Recompute: %v", err)
|
|
}
|
|
if len(st.saves) != 2 {
|
|
t.Fatalf("saves = %d, want exactly one retry", len(st.saves))
|
|
}
|
|
if rating.Version != 4 || rating.Stars != 200 {
|
|
t.Fatalf("rating = %#v, want version 4 with 200 stars", rating)
|
|
}
|
|
}
|
|
|
|
func TestRecomputeFailsAfterPersistentConflict(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 200}
|
|
st.forceConflicts = 2
|
|
service := newTestService(st)
|
|
|
|
if _, err := service.Recompute(context.Background(), 7); err == nil {
|
|
t.Fatal("Recompute reported success while every save lost the version race")
|
|
}
|
|
if len(st.saves) != 2 {
|
|
t.Fatalf("saves = %d, want the bounded single retry", len(st.saves))
|
|
}
|
|
}
|
|
|
|
func TestAdjustRecordsLedgerAndRecomputes(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 100}
|
|
service := newTestService(st, WithPendingDelay(0))
|
|
|
|
rating, applied, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{
|
|
UserID: 7, Amount: 300, Reason: "support compensation", Actor: "admin", CommandKey: "cmd-1",
|
|
})
|
|
if err != nil || !applied {
|
|
t.Fatalf("Adjust = %v, %v", applied, err)
|
|
}
|
|
if rating.ManualComponent != 300 || rating.Stars != 400 {
|
|
t.Fatalf("rating = %#v, want the manual component folded in", rating)
|
|
}
|
|
if len(st.events[7]) != 1 {
|
|
t.Fatalf("ledger rows = %d, want 1", len(st.events[7]))
|
|
}
|
|
}
|
|
|
|
func TestAdjustReplayByCommandKeyIsIdempotent(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 100}
|
|
service := newTestService(st, WithPendingDelay(0))
|
|
req := domain.AdjustAccountRatingRequest{UserID: 7, Amount: 300, Actor: "admin", CommandKey: "cmd-1"}
|
|
|
|
first, applied, err := service.Adjust(context.Background(), req)
|
|
if err != nil || !applied {
|
|
t.Fatalf("first Adjust = %v, %v", applied, err)
|
|
}
|
|
second, applied, err := service.Adjust(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("replayed Adjust: %v", err)
|
|
}
|
|
if applied {
|
|
t.Fatal("replayed Adjust reported applied=true")
|
|
}
|
|
if len(st.events[7]) != 1 || st.manual[7] != 300 {
|
|
t.Fatalf("ledger = %d rows / manual %d, want the replay recorded nothing", len(st.events[7]), st.manual[7])
|
|
}
|
|
if second.Stars != first.Stars || second.ManualComponent != first.ManualComponent {
|
|
t.Fatalf("replayed rating = %#v, want the same score as %#v", second, first)
|
|
}
|
|
}
|
|
|
|
func TestAdjustValidatesRequest(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
service := newTestService(st)
|
|
tests := []domain.AdjustAccountRatingRequest{
|
|
{UserID: 0, Amount: 10},
|
|
{UserID: 7, Amount: 0},
|
|
{UserID: 7, Amount: 10, Reason: string(make([]byte, domain.MaxAccountRatingReasonLength+1))},
|
|
}
|
|
for _, req := range tests {
|
|
if _, _, err := service.Adjust(context.Background(), req); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
|
t.Fatalf("Adjust(%#v) error = %v, want ErrAccountRatingAdjustmentInvalid", req, err)
|
|
}
|
|
}
|
|
if len(st.events) != 0 || len(st.saves) != 0 {
|
|
t.Fatal("store was touched by an invalid adjustment")
|
|
}
|
|
}
|
|
|
|
func TestRunRecomputeCycleProcessesTheBatch(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.stale = []int64{1, 2, 3}
|
|
for _, userID := range st.stale {
|
|
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 100 * userID}
|
|
}
|
|
service := newTestService(st, WithStaleAfter(6*time.Hour))
|
|
|
|
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
|
if err != nil {
|
|
t.Fatalf("RunRecomputeCycle: %v", err)
|
|
}
|
|
if processed != 3 {
|
|
t.Fatalf("processed = %d, want 3", processed)
|
|
}
|
|
if st.staleLimit != 10 {
|
|
t.Fatalf("stale limit = %d, want the requested 10", st.staleLimit)
|
|
}
|
|
if want := testNow.Add(-6 * time.Hour).Unix(); st.staleOlderThan != want {
|
|
t.Fatalf("stale horizon = %d, want %d", st.staleOlderThan, want)
|
|
}
|
|
for _, userID := range st.stale {
|
|
if _, ok := st.ratings[userID]; !ok {
|
|
t.Fatalf("user %d was not recomputed", userID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunRecomputeCycleSkipsFailingUsers(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.stale = []int64{1, 0, 2}
|
|
st.forceConflicts = 2 // both saves of the first user lose the race
|
|
service := newTestService(st)
|
|
|
|
processed, err := service.RunRecomputeCycle(context.Background(), 0)
|
|
if err != nil {
|
|
t.Fatalf("RunRecomputeCycle: %v", err)
|
|
}
|
|
if processed != 1 {
|
|
t.Fatalf("processed = %d, want the surviving user only", processed)
|
|
}
|
|
if st.staleLimit != defaultRecomputeBatch {
|
|
t.Fatalf("stale limit = %d, want the default batch", st.staleLimit)
|
|
}
|
|
}
|
|
|
|
func TestReadPathsDegradeWhenDisabled(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.ratings[7] = domain.AccountRating{UserID: 7, Stars: 500, Level: 2, Version: 1}
|
|
service := newTestService(st, WithEnabled(false))
|
|
|
|
if service.Enabled() || service.Ready() {
|
|
t.Fatal("disabled service reported enabled/ready")
|
|
}
|
|
// The userFull projection omits both TL flags on this error, which is exactly
|
|
// the pre-rating wire shape.
|
|
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
|
t.Fatalf("Rating error = %v, want ErrAccountRatingNotFound", err)
|
|
}
|
|
batch, err := service.RatingBatch(context.Background(), []int64{7})
|
|
if err != nil || len(batch) != 0 {
|
|
t.Fatalf("RatingBatch = %#v, %v; want empty", batch, err)
|
|
}
|
|
if _, err := service.Recompute(context.Background(), 7); !errors.Is(err, ErrDisabled) {
|
|
t.Fatalf("Recompute error = %v, want ErrDisabled", err)
|
|
}
|
|
if _, _, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{UserID: 7, Amount: 5}); !errors.Is(err, ErrDisabled) {
|
|
t.Fatalf("Adjust error = %v, want ErrDisabled", err)
|
|
}
|
|
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
|
if err != nil || processed != 0 {
|
|
t.Fatalf("RunRecomputeCycle = %d, %v; want a no-op", processed, err)
|
|
}
|
|
}
|
|
|
|
func TestUnconfiguredStoreReportsConfiguration(t *testing.T) {
|
|
service := NewService()
|
|
if service.Ready() {
|
|
t.Fatal("Ready = true without a store")
|
|
}
|
|
if _, err := service.Rating(context.Background(), 7); err == nil {
|
|
t.Fatal("Rating accepted a missing store")
|
|
}
|
|
if _, err := service.Recompute(context.Background(), 7); err == nil {
|
|
t.Fatal("Recompute accepted a missing store")
|
|
}
|
|
if _, _, err := service.Adjust(context.Background(), domain.AdjustAccountRatingRequest{UserID: 7, Amount: 5}); err == nil {
|
|
t.Fatal("Adjust accepted a missing store")
|
|
}
|
|
if _, err := service.RunRecomputeCycle(context.Background(), 10); err == nil {
|
|
t.Fatal("RunRecomputeCycle accepted a missing store")
|
|
}
|
|
}
|
|
|
|
func TestNilServiceIsSafe(t *testing.T) {
|
|
var service *Service
|
|
if service.Enabled() || service.Ready() {
|
|
t.Fatal("nil service reported enabled/ready")
|
|
}
|
|
if got := service.Weights(); got != domain.DefaultAccountRatingWeights() {
|
|
t.Fatalf("nil service weights = %#v, want the defaults", got)
|
|
}
|
|
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
|
t.Fatalf("nil service Rating error = %v, want ErrAccountRatingNotFound", err)
|
|
}
|
|
if batch, err := service.RatingBatch(context.Background(), []int64{7}); err != nil || len(batch) != 0 {
|
|
t.Fatalf("nil service RatingBatch = %#v, %v; want empty", batch, err)
|
|
}
|
|
if _, err := service.Recompute(context.Background(), 7); !errors.Is(err, ErrDisabled) {
|
|
t.Fatalf("nil service Recompute error = %v, want ErrDisabled", err)
|
|
}
|
|
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
|
|
t.Fatalf("nil service RunRecomputeCycle = %d, %v", processed, err)
|
|
}
|
|
}
|
|
|
|
func TestInvalidWeightsFallBackToDefaults(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
service := newTestService(st, WithWeights(domain.AccountRatingWeights{StarsReceivedPermille: -1}))
|
|
if got := service.Weights(); got != domain.DefaultAccountRatingWeights() {
|
|
t.Fatalf("weights = %#v, want the defaults after rejecting a negative set", got)
|
|
}
|
|
}
|
|
|
|
func TestListAndEventsBoundThePage(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.ratings[7] = domain.AccountRating{UserID: 7, Level: 3, Version: 1}
|
|
for i := range maxEventLimit + 10 {
|
|
st.events[7] = append(st.events[7], domain.AccountRatingEvent{ID: int64(i + 1), UserID: 7, Amount: 1})
|
|
}
|
|
service := newTestService(st)
|
|
|
|
list, err := service.List(context.Background(), domain.AccountRatingFilter{MinLevel: -5, Limit: 0})
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(list) != 1 {
|
|
t.Fatalf("List = %d rows, want 1", len(list))
|
|
}
|
|
events, err := service.Events(context.Background(), 7, 100000)
|
|
if err != nil {
|
|
t.Fatalf("Events: %v", err)
|
|
}
|
|
if len(events) != maxEventLimit {
|
|
t.Fatalf("Events = %d rows, want the %d cap", len(events), maxEventLimit)
|
|
}
|
|
if _, err := service.Events(context.Background(), 0, 10); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
|
t.Fatalf("Events accepted a zero user id")
|
|
}
|
|
}
|
|
|
|
func TestRecomputeWorkerRunsAndStops(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.stale = []int64{1}
|
|
st.signals[1] = domain.AccountRatingSignals{StarsReceived: 100}
|
|
service := newTestService(st)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
NewRecomputeWorker(service, nil, time.Hour, 10).Run(ctx)
|
|
}()
|
|
// The first cycle runs before the ticker, so cancelling immediately still
|
|
// leaves exactly one recompute behind.
|
|
<-time.After(20 * time.Millisecond)
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("worker did not stop on context cancellation")
|
|
}
|
|
if _, ok := st.ratings[1]; !ok {
|
|
t.Fatal("worker did not recompute the stale user")
|
|
}
|
|
}
|
|
|
|
func TestRecomputeWorkerExitsWhenNotReady(t *testing.T) {
|
|
worker := NewRecomputeWorker(newTestService(newFakeRatingStore(), WithEnabled(false)), nil, time.Millisecond, 0)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
worker.Run(context.Background())
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("disabled worker kept running")
|
|
}
|
|
if worker.batch != defaultRecomputeBatch {
|
|
t.Fatalf("batch = %d, want the default fallback", worker.batch)
|
|
}
|
|
}
|
|
|
|
// TestRunRecomputeCycleSeedsAccountsWithNoProjection is the report "the ratings tab
|
|
// is empty and no client shows a rating". StaleAccountRatings reads account_rating,
|
|
// so it can only ever refresh rows that already exist; without a seeding pass the
|
|
// very first row for a user has to come from an operator recomputing that user by
|
|
// hand, and the read model stays permanently empty.
|
|
func TestRunRecomputeCycleSeedsAccountsWithNoProjection(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.unrated = []int64{11, 12, 13}
|
|
for _, userID := range st.unrated {
|
|
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 100 * userID}
|
|
}
|
|
service := newTestService(st)
|
|
|
|
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
|
if err != nil {
|
|
t.Fatalf("RunRecomputeCycle: %v", err)
|
|
}
|
|
if processed != 3 {
|
|
t.Fatalf("processed = %d, want the three seeded accounts", processed)
|
|
}
|
|
for _, userID := range st.unrated {
|
|
if _, ok := st.ratings[userID]; !ok {
|
|
t.Fatalf("account %d was not seeded", userID)
|
|
}
|
|
}
|
|
// A second cycle has nothing left to seed, so seeding converges instead of
|
|
// rewriting the same rows every interval.
|
|
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
|
|
t.Fatalf("second cycle = %d,%v, want 0,nil", processed, err)
|
|
}
|
|
}
|
|
|
|
// The batch bound belongs to the cycle, not to each pass: a backlog of stale rows
|
|
// must not let one cycle do an unbounded amount of work.
|
|
func TestRunRecomputeCycleSharesTheBatchBudget(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.stale = []int64{1, 2}
|
|
st.unrated = []int64{11, 12, 13, 14}
|
|
service := newTestService(st)
|
|
|
|
processed, err := service.RunRecomputeCycle(context.Background(), 3)
|
|
if err != nil {
|
|
t.Fatalf("RunRecomputeCycle: %v", err)
|
|
}
|
|
if processed != 3 {
|
|
t.Fatalf("processed = %d, want the batch bound of 3", processed)
|
|
}
|
|
if st.unratedLimit != 1 {
|
|
t.Fatalf("seeding limit = %d, want the 1 left after two stale rows", st.unratedLimit)
|
|
}
|
|
|
|
// A cycle whose stale pass already fills the batch does not query for seeds at
|
|
// all: refreshing rows somebody is looking at comes first.
|
|
full := newFakeRatingStore()
|
|
full.stale = []int64{1, 2, 3}
|
|
full.unrated = []int64{11}
|
|
if _, err := newTestService(full).RunRecomputeCycle(context.Background(), 3); err != nil {
|
|
t.Fatalf("RunRecomputeCycle: %v", err)
|
|
}
|
|
if full.unratedCalls != 0 {
|
|
t.Fatalf("seeding was queried %d times, want none when the batch is already full", full.unratedCalls)
|
|
}
|
|
}
|
|
|
|
// Seeding extends the cycle; it is not its purpose. A store that cannot enumerate
|
|
// accounts must not turn a successful stale pass into a failed cycle.
|
|
func TestRunRecomputeCycleSurvivesSeedingFailure(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.stale = []int64{1}
|
|
st.unratedErr = errors.New("no users table")
|
|
service := newTestService(st)
|
|
|
|
processed, err := service.RunRecomputeCycle(context.Background(), 10)
|
|
if err != nil {
|
|
t.Fatalf("RunRecomputeCycle = %v, want the stale pass to stand", err)
|
|
}
|
|
if processed != 1 {
|
|
t.Fatalf("processed = %d, want the one stale row", processed)
|
|
}
|
|
}
|
|
|
|
// TestEnsureRatingMaterializesOnce covers an administrative immediate-read path:
|
|
// when the worker has not reached an account yet, the first read materializes the
|
|
// local projection and the second read must not write again.
|
|
func TestEnsureRatingMaterializesOnce(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 900}
|
|
service := newTestService(st)
|
|
|
|
if _, err := service.Rating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
|
t.Fatalf("Rating before materialising = %v, want ErrAccountRatingNotFound", err)
|
|
}
|
|
rating, err := service.EnsureRating(context.Background(), 7)
|
|
if err != nil {
|
|
t.Fatalf("EnsureRating: %v", err)
|
|
}
|
|
if rating.UserID != 7 || rating.Stars == 0 {
|
|
t.Fatalf("materialised rating = %+v, want a computed rating for user 7", rating)
|
|
}
|
|
writes := len(st.saves)
|
|
again, err := service.EnsureRating(context.Background(), 7)
|
|
if err != nil {
|
|
t.Fatalf("second EnsureRating: %v", err)
|
|
}
|
|
if again.Version != rating.Version {
|
|
t.Fatalf("second EnsureRating rewrote the row: version %d then %d", rating.Version, again.Version)
|
|
}
|
|
if len(st.saves) != writes {
|
|
t.Fatalf("second EnsureRating issued %d extra saves, want none", len(st.saves)-writes)
|
|
}
|
|
}
|
|
|
|
// A disabled feature materialises nothing. Telegram wire fields remain unset
|
|
// independently of this local feature flag.
|
|
func TestEnsureRatingDisabledStaysEmpty(t *testing.T) {
|
|
st := newFakeRatingStore()
|
|
st.signals[7] = domain.AccountRatingSignals{StarsReceived: 900}
|
|
service := newTestService(st, WithEnabled(false))
|
|
|
|
if _, err := service.EnsureRating(context.Background(), 7); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
|
t.Fatalf("EnsureRating while disabled = %v, want ErrAccountRatingNotFound", err)
|
|
}
|
|
if len(st.saves) != 0 {
|
|
t.Fatalf("EnsureRating while disabled wrote %d rows, want none", len(st.saves))
|
|
}
|
|
}
|
|
|
|
// TestRecomputeRefusesServiceAccounts pins that the platform account and the
|
|
// built-in bots carry no rating. The platform account is not flagged is_bot, so the
|
|
// bot exclusion in the seeding query does not cover it -- which is how it acquired a
|
|
// rating in the first place -- and an operator must not be able to create one by
|
|
// hand either.
|
|
func TestRecomputeRefusesServiceAccounts(t *testing.T) {
|
|
for _, userID := range domain.SystemUserIDs() {
|
|
st := newFakeRatingStore()
|
|
st.signals[userID] = domain.AccountRatingSignals{StarsReceived: 5000}
|
|
st.unrated = []int64{userID}
|
|
service := newTestService(st)
|
|
|
|
if _, err := service.Recompute(context.Background(), userID); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
|
t.Fatalf("Recompute(%d) = %v, want ErrAccountRatingAdjustmentInvalid", userID, err)
|
|
}
|
|
if _, err := service.EnsureRating(context.Background(), userID); err == nil {
|
|
t.Fatalf("EnsureRating(%d) succeeded, want a refusal", userID)
|
|
}
|
|
if len(st.ratings) != 0 {
|
|
t.Fatalf("service account %d ended up with a projection: %#v", userID, st.ratings)
|
|
}
|
|
// A seeding pass that is somehow handed one skips it rather than failing the
|
|
// whole cycle.
|
|
if processed, err := service.RunRecomputeCycle(context.Background(), 10); err != nil || processed != 0 {
|
|
t.Fatalf("cycle over service account %d = %d,%v, want 0,nil", userID, processed, err)
|
|
}
|
|
}
|
|
|
|
// An ordinary account is unaffected.
|
|
st := newFakeRatingStore()
|
|
st.signals[42] = domain.AccountRatingSignals{StarsReceived: 5000}
|
|
if _, err := newTestService(st).Recompute(context.Background(), 42); err != nil {
|
|
t.Fatalf("Recompute of an ordinary account: %v", err)
|
|
}
|
|
}
|