owpengram-server/internal/app/rating/service_test.go
Egor Egorov fff8de783a
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>
2026-07-28 01:18:00 +08:00

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