Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 19 additions & 12 deletions chessfut-be/adapter/redis/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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}
}
57 changes: 31 additions & 26 deletions chessfut-be/adapter/redis/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"`
}
12 changes: 12 additions & 0 deletions chessfut-be/application/service/refresh_stale_cards_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -56,13 +58,15 @@ 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)

assert.NoError(t, err)
assert.Equal(t, 1, count)
repo.AssertExpectations(t)
cache.AssertNotCalled(t, "SetCard")
})
}

Expand All @@ -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)
Expand All @@ -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()

Expand Down
Loading