Follow up PR #22 by projecting its composite rating through userFull while keeping profile reads cache-backed and strictly read-only. Source-Commit: 00eea44c Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
272 lines
8.5 KiB
Go
272 lines
8.5 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/iamxvbaba/td/clock"
|
|
"github.com/iamxvbaba/td/tg"
|
|
"go.uber.org/zap/zaptest"
|
|
|
|
appusers "telesrv/internal/app/users"
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store/memory"
|
|
)
|
|
|
|
type fakeAccountRatingProjection struct {
|
|
byUser map[int64]domain.AccountRating
|
|
err error
|
|
ratingCalls int
|
|
ensureCalls int
|
|
}
|
|
|
|
func (f *fakeAccountRatingProjection) Rating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
|
f.ratingCalls++
|
|
if f.err != nil {
|
|
return domain.AccountRating{}, f.err
|
|
}
|
|
rating, ok := f.byUser[userID]
|
|
if !ok {
|
|
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
|
}
|
|
return rating, nil
|
|
}
|
|
|
|
// EnsureRating deliberately exists on the fake even though it is not part of
|
|
// AccountRatingService. The assertion below pins that profile reads stay
|
|
// read-only if a future implementation happens to expose a materializer.
|
|
func (f *fakeAccountRatingProjection) EnsureRating(_ context.Context, userID int64) (domain.AccountRating, error) {
|
|
f.ensureCalls++
|
|
return f.byUser[userID], nil
|
|
}
|
|
|
|
var _ AccountRatingService = (*fakeAccountRatingProjection)(nil)
|
|
|
|
func newAccountRatingProjectionFixture(t *testing.T, ratings AccountRatingService) (*Router, domain.User, domain.User) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
userStore := memory.NewUserStore()
|
|
owner, err := userStore.Create(ctx, domain.User{
|
|
AccessHash: 11,
|
|
Phone: "15550004001",
|
|
FirstName: "Owner",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create owner: %v", err)
|
|
}
|
|
other, err := userStore.Create(ctx, domain.User{
|
|
AccessHash: 22,
|
|
Phone: "15550004002",
|
|
FirstName: "Other",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create other: %v", err)
|
|
}
|
|
router := New(Config{}, Deps{
|
|
Users: appusers.NewService(userStore),
|
|
AccountRatings: ratings,
|
|
}, zaptest.NewLogger(t), clock.System)
|
|
return router, owner, other
|
|
}
|
|
|
|
func TestUserFullProjectsCompositeRatingReadOnlyAndCachesIt(t *testing.T) {
|
|
pendingDate := time.Unix(1800000000, 0).UTC()
|
|
ratings := &fakeAccountRatingProjection{byUser: make(map[int64]domain.AccountRating)}
|
|
router, owner, _ := newAccountRatingProjectionFixture(t, ratings)
|
|
ratings.byUser[owner.ID] = domain.AccountRating{
|
|
UserID: owner.ID,
|
|
Level: 3,
|
|
Stars: 1200,
|
|
CurrentLevelStars: domain.AccountRatingLevelThreshold(3),
|
|
NextLevelStars: domain.AccountRatingLevelThreshold(4),
|
|
HasNextLevel: true,
|
|
PendingStars: 500,
|
|
PendingDate: pendingDate,
|
|
}
|
|
ctx := WithUserID(context.Background(), owner.ID)
|
|
|
|
full, err := router.onUsersGetFullUser(ctx, &tg.InputUserSelf{})
|
|
if err != nil {
|
|
t.Fatalf("get self full user: %v", err)
|
|
}
|
|
rating, ok := full.FullUser.GetStarsRating()
|
|
if !ok || rating.Level != 3 || rating.Stars != 1200 ||
|
|
rating.CurrentLevelStars != domain.AccountRatingLevelThreshold(3) {
|
|
t.Fatalf("self rating = %+v (present=%v)", rating, ok)
|
|
}
|
|
if next, ok := rating.GetNextLevelStars(); !ok || next != domain.AccountRatingLevelThreshold(4) {
|
|
t.Fatalf("self next_level_stars = %d (present=%v)", next, ok)
|
|
}
|
|
pending, ok := full.FullUser.GetStarsMyPendingRating()
|
|
if !ok || pending.Stars != 1700 {
|
|
t.Fatalf("self pending rating = %+v (present=%v)", pending, ok)
|
|
}
|
|
if date, ok := full.FullUser.GetStarsMyPendingRatingDate(); !ok || date != int(pendingDate.Unix()) {
|
|
t.Fatalf("self pending date = %d (present=%v)", date, ok)
|
|
}
|
|
if ratings.ratingCalls != 1 || ratings.ensureCalls != 0 {
|
|
t.Fatalf("rating calls=%d ensure calls=%d, want 1/0", ratings.ratingCalls, ratings.ensureCalls)
|
|
}
|
|
|
|
// The second response is served from the existing UserFull projection cache:
|
|
// the rating read cannot become a per-request database query.
|
|
if _, err := router.onUsersGetFullUser(ctx, &tg.InputUserSelf{}); err != nil {
|
|
t.Fatalf("get cached self full user: %v", err)
|
|
}
|
|
if ratings.ratingCalls != 1 || ratings.ensureCalls != 0 {
|
|
t.Fatalf("cached rating calls=%d ensure calls=%d, want 1/0", ratings.ratingCalls, ratings.ensureCalls)
|
|
}
|
|
}
|
|
|
|
func TestUserFullRatingPendingIsSelfOnly(t *testing.T) {
|
|
pendingDate := time.Unix(1800000000, 0).UTC()
|
|
ratings := &fakeAccountRatingProjection{byUser: make(map[int64]domain.AccountRating)}
|
|
router, owner, other := newAccountRatingProjectionFixture(t, ratings)
|
|
ratings.byUser[other.ID] = domain.AccountRating{
|
|
UserID: other.ID,
|
|
Level: 1,
|
|
Stars: 150,
|
|
CurrentLevelStars: domain.AccountRatingLevelThreshold(1),
|
|
NextLevelStars: domain.AccountRatingLevelThreshold(2),
|
|
HasNextLevel: true,
|
|
PendingStars: 500,
|
|
PendingDate: pendingDate,
|
|
}
|
|
|
|
full, err := router.onUsersGetFullUser(
|
|
WithUserID(context.Background(), owner.ID),
|
|
&tg.InputUser{UserID: other.ID, AccessHash: other.AccessHash},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("get other full user: %v", err)
|
|
}
|
|
if rating, ok := full.FullUser.GetStarsRating(); !ok || rating.Level != 1 || rating.Stars != 150 {
|
|
t.Fatalf("other rating = %+v (present=%v)", rating, ok)
|
|
}
|
|
if _, ok := full.FullUser.GetStarsMyPendingRating(); ok {
|
|
t.Fatal("other pending rating is visible")
|
|
}
|
|
if _, ok := full.FullUser.GetStarsMyPendingRatingDate(); ok {
|
|
t.Fatal("other pending rating date is visible")
|
|
}
|
|
}
|
|
|
|
func TestUserFullRatingDegradesWithoutStoredProjection(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ratings AccountRatingService
|
|
}{
|
|
{name: "service absent"},
|
|
{name: "row missing", ratings: &fakeAccountRatingProjection{byUser: map[int64]domain.AccountRating{}}},
|
|
{name: "read failure", ratings: &fakeAccountRatingProjection{
|
|
byUser: map[int64]domain.AccountRating{},
|
|
err: errors.New("rating unavailable"),
|
|
}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
router, owner, _ := newAccountRatingProjectionFixture(t, tt.ratings)
|
|
full, err := router.onUsersGetFullUser(
|
|
WithUserID(context.Background(), owner.ID),
|
|
&tg.InputUserSelf{},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("get full user: %v", err)
|
|
}
|
|
if _, ok := full.FullUser.GetStarsRating(); ok {
|
|
t.Fatal("rating set without a stored projection")
|
|
}
|
|
if _, ok := full.FullUser.GetStarsMyPendingRating(); ok {
|
|
t.Fatal("pending rating set without a stored projection")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUserFullRatingOmitsBotsAndTopLevelThreshold(t *testing.T) {
|
|
ctx := context.Background()
|
|
userStore := memory.NewUserStore()
|
|
viewer, err := userStore.Create(ctx, domain.User{
|
|
AccessHash: 31,
|
|
Phone: "15550004101",
|
|
FirstName: "Viewer",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create viewer: %v", err)
|
|
}
|
|
bot, err := userStore.Create(ctx, domain.User{
|
|
AccessHash: 32,
|
|
Phone: "15550004102",
|
|
FirstName: "Helper",
|
|
Bot: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create bot: %v", err)
|
|
}
|
|
ratings := &fakeAccountRatingProjection{byUser: map[int64]domain.AccountRating{
|
|
viewer.ID: {
|
|
UserID: viewer.ID,
|
|
Level: domain.MaxAccountRatingLevel,
|
|
Stars: domain.AccountRatingLevelThreshold(domain.MaxAccountRatingLevel),
|
|
CurrentLevelStars: domain.AccountRatingLevelThreshold(domain.MaxAccountRatingLevel),
|
|
},
|
|
bot.ID: {
|
|
UserID: bot.ID,
|
|
Level: 4,
|
|
Stars: 2000,
|
|
},
|
|
domain.OfficialSystemUserID: {
|
|
UserID: domain.OfficialSystemUserID,
|
|
Level: 5,
|
|
Stars: 3000,
|
|
},
|
|
}}
|
|
router := New(Config{}, Deps{
|
|
Users: appusers.NewService(userStore),
|
|
AccountRatings: ratings,
|
|
}, zaptest.NewLogger(t), clock.System)
|
|
viewerCtx := WithUserID(ctx, viewer.ID)
|
|
|
|
own, err := router.onUsersGetFullUser(viewerCtx, &tg.InputUserSelf{})
|
|
if err != nil {
|
|
t.Fatalf("get own full user: %v", err)
|
|
}
|
|
rating, ok := own.FullUser.GetStarsRating()
|
|
if !ok || rating.Level != domain.MaxAccountRatingLevel {
|
|
t.Fatalf("top-level rating = %+v (present=%v)", rating, ok)
|
|
}
|
|
if _, ok := rating.GetNextLevelStars(); ok {
|
|
t.Fatal("next_level_stars set at the maximum level")
|
|
}
|
|
|
|
botFull, err := router.onUsersGetFullUser(
|
|
viewerCtx,
|
|
&tg.InputUser{UserID: bot.ID, AccessHash: bot.AccessHash},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("get bot full user: %v", err)
|
|
}
|
|
if _, ok := botFull.FullUser.GetStarsRating(); ok {
|
|
t.Fatal("bot rating is visible")
|
|
}
|
|
|
|
official, err := router.onUsersGetFullUser(
|
|
viewerCtx,
|
|
&tg.InputUser{
|
|
UserID: domain.OfficialSystemUserID,
|
|
AccessHash: domain.OfficialSystemUser().AccessHash,
|
|
},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("get official system user: %v", err)
|
|
}
|
|
if _, ok := official.FullUser.GetStarsRating(); ok {
|
|
t.Fatal("system account rating is visible")
|
|
}
|
|
// One read for the ratable viewer and none for the bot/system guards.
|
|
if ratings.ratingCalls != 1 {
|
|
t.Fatalf("rating calls = %d, want 1", ratings.ratingCalls)
|
|
}
|
|
}
|