From 921c918c3cf6a322b2fd25b7863f8aca2220efde Mon Sep 17 00:00:00 2001 From: Fayupable <90789180+Fayupable@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:11:04 +0300 Subject: [PATCH] feat: enhance card model with URL and last online fields, update cache synchronization in refresh service --- chessfut-be/adapter/redis/mapper.go | 31 ++++++---- chessfut-be/adapter/redis/model.go | 57 ++++++++++--------- .../service/refresh_stale_cards_service.go | 12 ++++ .../refresh_stale_cards_service_test.go | 33 +++++++++++ 4 files changed, 95 insertions(+), 38 deletions(-) diff --git a/chessfut-be/adapter/redis/mapper.go b/chessfut-be/adapter/redis/mapper.go index ce1eeeb..cb41b52 100644 --- a/chessfut-be/adapter/redis/mapper.go +++ b/chessfut-be/adapter/redis/mapper.go @@ -26,6 +26,8 @@ func toCardModel(c domain.Card) cardModel { Followers: c.Player.Followers, CountryCode: c.Player.CountryCode, JoinedAt: c.Player.JoinedAt, + URL: c.Player.URL, + LastOnline: c.Player.LastOnline, FideRating: c.Stats.FideRating, TacticsRating: c.Stats.TacticsRating, PuzzleRushAccuracy: c.Stats.PuzzleRushAccuracy, @@ -50,16 +52,18 @@ func toCardModel(c domain.Card) cardModel { Attack: string(c.WorkRate.Attack), Defense: string(c.WorkRate.Defense), }, - Badges: badges, - TopOpenings: openings, - GamesSnapshot: c.GamesSnapshot, - ComputedAt: c.ComputedAt, - ExpiresAt: c.ExpiresAt, + Badges: badges, + TopOpenings: openings, + GamesSnapshot: c.GamesSnapshot, + FideSource: c.FideSource, + EffectiveFideRating: c.EffectiveFideRating, + ComputedAt: c.ComputedAt, + ExpiresAt: c.ExpiresAt, } } func toTimeControlModel(s domain.TimeControlStats) timeControlStatsModel { - return timeControlStatsModel{Rating: s.Rating, Highest: s.Highest, Wins: s.Wins, Losses: s.Losses, Draws: s.Draws} + return timeControlStatsModel{Rating: s.Rating, Highest: s.Highest, Wins: s.Wins, Losses: s.Losses, Draws: s.Draws, RD: s.RD} } func fromCardModel(m cardModel) domain.Card { @@ -80,6 +84,7 @@ func fromCardModel(m cardModel) domain.Card { Player: domain.Player{ Username: m.Username, Name: m.Name, Title: domain.Title(m.Title), Avatar: m.Avatar, Followers: m.Followers, CountryCode: m.CountryCode, JoinedAt: m.JoinedAt, + URL: m.URL, LastOnline: m.LastOnline, }, Stats: domain.PlayerStats{ FideRating: m.FideRating, @@ -107,14 +112,16 @@ func fromCardModel(m cardModel) domain.Card { Attack: domain.WorkRateLevel(m.WorkRate.Attack), Defense: domain.WorkRateLevel(m.WorkRate.Defense), }, - Badges: badges, - TopOpenings: openings, - GamesSnapshot: m.GamesSnapshot, - ComputedAt: m.ComputedAt, - ExpiresAt: m.ExpiresAt, + Badges: badges, + TopOpenings: openings, + GamesSnapshot: m.GamesSnapshot, + FideSource: m.FideSource, + EffectiveFideRating: m.EffectiveFideRating, + ComputedAt: m.ComputedAt, + ExpiresAt: m.ExpiresAt, } } func fromTimeControlModel(m timeControlStatsModel, tc domain.TimeControl) domain.TimeControlStats { - return domain.TimeControlStats{TimeControl: tc, Rating: m.Rating, Highest: m.Highest, Wins: m.Wins, Losses: m.Losses, Draws: m.Draws} + return domain.TimeControlStats{TimeControl: tc, Rating: m.Rating, Highest: m.Highest, Wins: m.Wins, Losses: m.Losses, Draws: m.Draws, RD: m.RD} } diff --git a/chessfut-be/adapter/redis/model.go b/chessfut-be/adapter/redis/model.go index 0e19726..b2dde1b 100644 --- a/chessfut-be/adapter/redis/model.go +++ b/chessfut-be/adapter/redis/model.go @@ -19,6 +19,7 @@ type timeControlStatsModel struct { Wins int `json:"wins"` Losses int `json:"losses"` Draws int `json:"draws"` + RD int `json:"rd"` } type attributesModel struct { @@ -36,30 +37,34 @@ type workRateModel struct { } type cardModel struct { - Username string `json:"username"` - Name string `json:"name"` - Title string `json:"title"` - Avatar string `json:"avatar"` - Followers int `json:"followers"` - CountryCode string `json:"country_code"` - JoinedAt time.Time `json:"joined_at"` - FideRating int `json:"fide_rating"` - TacticsRating int `json:"tactics_rating"` - PuzzleRushAccuracy float64 `json:"puzzle_rush_accuracy"` - Bullet timeControlStatsModel `json:"bullet"` - Blitz timeControlStatsModel `json:"blitz"` - Rapid timeControlStatsModel `json:"rapid"` - Daily timeControlStatsModel `json:"daily"` - CardType string `json:"card_type"` - Tier string `json:"tier"` - OVR int `json:"ovr"` - PlayStyle string `json:"play_style"` - Position string `json:"position"` - Attributes attributesModel `json:"attributes"` - WorkRate workRateModel `json:"work_rate"` - Badges []string `json:"badges"` - TopOpenings []openingStatModel `json:"top_openings"` - GamesSnapshot int `json:"games_snapshot"` - ComputedAt time.Time `json:"computed_at"` - ExpiresAt time.Time `json:"expires_at"` + Username string `json:"username"` + Name string `json:"name"` + Title string `json:"title"` + Avatar string `json:"avatar"` + Followers int `json:"followers"` + CountryCode string `json:"country_code"` + JoinedAt time.Time `json:"joined_at"` + URL string `json:"url"` + LastOnline time.Time `json:"last_online"` + FideRating int `json:"fide_rating"` + TacticsRating int `json:"tactics_rating"` + PuzzleRushAccuracy float64 `json:"puzzle_rush_accuracy"` + Bullet timeControlStatsModel `json:"bullet"` + Blitz timeControlStatsModel `json:"blitz"` + Rapid timeControlStatsModel `json:"rapid"` + Daily timeControlStatsModel `json:"daily"` + CardType string `json:"card_type"` + Tier string `json:"tier"` + OVR int `json:"ovr"` + PlayStyle string `json:"play_style"` + Position string `json:"position"` + Attributes attributesModel `json:"attributes"` + WorkRate workRateModel `json:"work_rate"` + Badges []string `json:"badges"` + TopOpenings []openingStatModel `json:"top_openings"` + GamesSnapshot int `json:"games_snapshot"` + FideSource string `json:"fide_source"` + EffectiveFideRating int `json:"effective_fide_rating"` + ComputedAt time.Time `json:"computed_at"` + ExpiresAt time.Time `json:"expires_at"` } diff --git a/chessfut-be/application/service/refresh_stale_cards_service.go b/chessfut-be/application/service/refresh_stale_cards_service.go index 04acd54..36c96b9 100644 --- a/chessfut-be/application/service/refresh_stale_cards_service.go +++ b/chessfut-be/application/service/refresh_stale_cards_service.go @@ -40,6 +40,7 @@ func (s *RefreshStaleCardsService) Execute(ctx context.Context, batchSize int) ( existing.Stats = freshStats existing.ExpiresAt = time.Now().Add(cardTTL) _ = s.cardRepository.Save(ctx, existing) + s.refreshCacheIfPresent(ctx, existing) refreshed++ continue } @@ -49,12 +50,23 @@ func (s *RefreshStaleCardsService) Execute(ctx context.Context, batchSize int) ( continue } _ = s.cardRepository.Save(ctx, rebuilt) + s.refreshCacheIfPresent(ctx, rebuilt) refreshed++ } return refreshed, nil } +// refreshCacheIfPresent keeps Redis in sync with Postgres for cards that are +// already cached (titled/popular players) — without this, a rebuilt card +// would sit correctly in Postgres while GetCard keeps serving the stale +// Redis copy indefinitely, since the read path checks Redis first. +func (s *RefreshStaleCardsService) refreshCacheIfPresent(ctx context.Context, card domain.Card) { + if _, found, err := s.cache.GetCard(ctx, card.Player.Username); err == nil && found { + _ = s.cache.SetCard(ctx, card) + } +} + func buildCardByType(ctx context.Context, client output.ChessComClientPort, username string, cardType domain.CardType) (domain.Card, error) { if cardType == domain.CardTypeDetailed { return buildDetailedCard(ctx, client, username) diff --git a/chessfut-be/application/service/refresh_stale_cards_service_test.go b/chessfut-be/application/service/refresh_stale_cards_service_test.go index 00b8540..6d9dcf9 100644 --- a/chessfut-be/application/service/refresh_stale_cards_service_test.go +++ b/chessfut-be/application/service/refresh_stale_cards_service_test.go @@ -28,6 +28,7 @@ func TestRefreshStaleCardsService_Execute(t *testing.T) { repo.On("FindStale", ctx, mock.AnythingOfType("time.Time"), 50).Return([]domain.Card{staleCard}, nil) client.On("GetStats", ctx, "hikaru").Return(freshStats, nil) repo.On("Save", ctx, mock.AnythingOfType("domain.Card")).Return(nil) + cache.On("GetCard", ctx, "hikaru").Return(domain.Card{}, false, nil) svc := NewRefreshStaleCardsService(client, repo, cache) count, err := svc.Execute(ctx, 50) @@ -37,6 +38,7 @@ func TestRefreshStaleCardsService_Execute(t *testing.T) { client.AssertNotCalled(t, "GetProfile") client.AssertExpectations(t) repo.AssertExpectations(t) + cache.AssertNotCalled(t, "SetCard") }) t.Run("rebuilds full card when game delta exceeds threshold", func(t *testing.T) { @@ -56,6 +58,7 @@ func TestRefreshStaleCardsService_Execute(t *testing.T) { client.On("GetProfile", ctx, "hikaru").Return(player, nil) client.On("GetStats", ctx, "hikaru").Return(freshStats, nil) repo.On("Save", ctx, mock.AnythingOfType("domain.Card")).Return(nil) + cache.On("GetCard", ctx, "hikaru").Return(domain.Card{}, false, nil) svc := NewRefreshStaleCardsService(client, repo, cache) count, err := svc.Execute(ctx, 50) @@ -63,6 +66,7 @@ func TestRefreshStaleCardsService_Execute(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 1, count) repo.AssertExpectations(t) + cache.AssertNotCalled(t, "SetCard") }) } @@ -88,6 +92,7 @@ func TestRefreshStaleCardsService_Execute_RebuildsDetailedCardType(t *testing.T) client.On("GetStats", ctx, "hikaru").Return(freshStats, nil) client.On("GetGames", ctx, "hikaru", mock.AnythingOfType("time.Time"), mock.AnythingOfType("time.Time")).Return(games, nil) repo.On("Save", ctx, mock.AnythingOfType("domain.Card")).Return(nil) + cache.On("GetCard", ctx, "hikaru").Return(domain.Card{}, false, nil) svc := NewRefreshStaleCardsService(client, repo, cache) count, err := svc.Execute(ctx, 50) @@ -96,7 +101,35 @@ func TestRefreshStaleCardsService_Execute_RebuildsDetailedCardType(t *testing.T) assert.Equal(t, 1, count) client.AssertExpectations(t) repo.AssertExpectations(t) + cache.AssertNotCalled(t, "SetCard") } + +func TestRefreshStaleCardsService_Execute_UpdatesCacheWhenCardIsCached(t *testing.T) { + ctx := context.Background() + staleCard := domain.Card{ + Player: domain.Player{Username: "hikaru"}, + GamesSnapshot: 100, + CardType: domain.CardTypeFast, + } + freshStats := domain.PlayerStats{Blitz: domain.TimeControlStats{Wins: 60, Losses: 40, Draws: 10}} + + client := new(mockChessComClient) + repo := new(mockCardRepository) + cache := new(mockCache) + repo.On("FindStale", ctx, mock.AnythingOfType("time.Time"), 50).Return([]domain.Card{staleCard}, nil) + client.On("GetStats", ctx, "hikaru").Return(freshStats, nil) + repo.On("Save", ctx, mock.AnythingOfType("domain.Card")).Return(nil) + cache.On("GetCard", ctx, "hikaru").Return(staleCard, true, nil) + cache.On("SetCard", ctx, mock.AnythingOfType("domain.Card")).Return(nil) + + svc := NewRefreshStaleCardsService(client, repo, cache) + count, err := svc.Execute(ctx, 50) + + assert.NoError(t, err) + assert.Equal(t, 1, count) + cache.AssertExpectations(t) +} + func TestRefreshStaleCardsService_Execute_ReturnsErrorWhenFindStaleFails(t *testing.T) { ctx := context.Background()