From 243f01a5100a855687d481a3062f916b5572e875 Mon Sep 17 00:00:00 2001 From: Amund Eggen Svandal Date: Sat, 11 Jul 2026 22:51:34 +0200 Subject: [PATCH 1/2] feat: Add latest-session endpoint Add POST /v1/session-at/latest, which returns a player's most recent session in the same shape as /v1/session-at given only a uuid. It runs a preliminary GetMostRecentPlayerPIT query for the player's last stored stat, then reuses GetSessionAt anchored at that timestamp so the windowed fetch, session computation, and game-segmentation are shared. The response marshalling is extracted into a shared helper so both handlers emit identical bodies. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adapters/playerrepository/interface.go | 3 + .../adapters/playerrepository/repository.go | 68 +++++ .../playerrepository/repository_test.go | 65 +++++ internal/app/latest_session.go | 57 ++++ internal/app/latest_session_test.go | 219 +++++++++++++++ internal/ports/latest_session.go | 139 +++++++++ internal/ports/latest_session_test.go | 263 ++++++++++++++++++ internal/ports/session_at.go | 109 +++++--- main.go | 18 ++ 9 files changed, 895 insertions(+), 46 deletions(-) create mode 100644 internal/app/latest_session.go create mode 100644 internal/app/latest_session_test.go create mode 100644 internal/ports/latest_session.go create mode 100644 internal/ports/latest_session_test.go diff --git a/internal/adapters/playerrepository/interface.go b/internal/adapters/playerrepository/interface.go index 2e610597..40f2440c 100644 --- a/internal/adapters/playerrepository/interface.go +++ b/internal/adapters/playerrepository/interface.go @@ -14,5 +14,8 @@ type PlayerRepository interface { GetPlayer(ctx context.Context, playerUUID string) (*domain.PlayerPIT, error) GetHistory(ctx context.Context, playerUUID string, start, end time.Time, limit int) ([]domain.PlayerPIT, error) GetPlayerPITs(ctx context.Context, playerUUID string, start, end time.Time) ([]domain.PlayerPIT, error) + // GetMostRecentPlayerPIT returns the most recently queried stat stored for + // the player, or domain.ErrPlayerNotFound if none exist. + GetMostRecentPlayerPIT(ctx context.Context, playerUUID string) (*domain.PlayerPIT, error) FindMilestoneAchievements(ctx context.Context, playerUUID string, gamemode domain.Gamemode, stat domain.Stat, milestones []int64) ([]domain.MilestoneAchievement, error) } diff --git a/internal/adapters/playerrepository/repository.go b/internal/adapters/playerrepository/repository.go index f9719a8b..055967ce 100644 --- a/internal/adapters/playerrepository/repository.go +++ b/internal/adapters/playerrepository/repository.go @@ -608,6 +608,70 @@ func (p *PostgresPlayerRepository) GetPlayerPITs(ctx context.Context, playerUUID return stats, nil } +func (p *PostgresPlayerRepository) GetMostRecentPlayerPIT(ctx context.Context, playerUUID string) (*domain.PlayerPIT, error) { + ctx, span := p.tracer.Start(ctx, "PostgresPlayerRepository.GetMostRecentPlayerPIT") + defer span.End() + + if !strutils.UUIDIsNormalized(playerUUID) { + err := fmt.Errorf("uuid is not normalized") + reporting.Report(ctx, err, map[string]string{ + "uuid": playerUUID, + }) + return nil, err + } + + conn, err := p.db.Connx(ctx) + if err != nil { + err := fmt.Errorf("failed to get connection: %w", err) + reporting.Report(ctx, err) + return nil, err + } + defer conn.Close() + + _, err = conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(p.schema))) + if err != nil { + err := fmt.Errorf("failed to set search path: %w", err) + reporting.Report(ctx, err, map[string]string{ + "schema": p.schema, + }) + return nil, err + } + + var stat dbStat + err = conn.GetContext( + ctx, + &stat, + `select + id, data_format_version, player_uuid, queried_at, player_data + from stats + where + player_uuid = $1 + order by queried_at desc + limit 1`, + playerUUID) + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrPlayerNotFound + } + if err != nil { + err := fmt.Errorf("failed to select most recent stat: %w", err) + reporting.Report(ctx, err, map[string]string{ + "uuid": playerUUID, + }) + return nil, err + } + + player, err := dbStatToPlayerPIT(stat) + if err != nil { + err := fmt.Errorf("failed to convert db stat to playerpit: %w", err) + reporting.Report(ctx, err, map[string]string{ + "statID": stat.ID, + }) + return nil, err + } + + return player, nil +} + func (p *PostgresPlayerRepository) FindMilestoneAchievements(ctx context.Context, playerUUID string, gamemode domain.Gamemode, stat domain.Stat, milestones []int64) ([]domain.MilestoneAchievement, error) { ctx, span := p.tracer.Start(ctx, "PostgresPlayerRepository.FindMilestoneAchievements") defer span.End() @@ -785,6 +849,10 @@ func (p *StubPlayerRepository) GetPlayerPITs(ctx context.Context, playerUUID str return []domain.PlayerPIT{}, nil } +func (p *StubPlayerRepository) GetMostRecentPlayerPIT(ctx context.Context, playerUUID string) (*domain.PlayerPIT, error) { + return nil, domain.ErrPlayerNotFound +} + func (p *StubPlayerRepository) FindMilestoneAchievements(ctx context.Context, playerUUID string, gamemode domain.Gamemode, stat domain.Stat, milestones []int64) ([]domain.MilestoneAchievement, error) { return []domain.MilestoneAchievement{}, nil } diff --git a/internal/adapters/playerrepository/repository_test.go b/internal/adapters/playerrepository/repository_test.go index c50b226b..04e5475f 100644 --- a/internal/adapters/playerrepository/repository_test.go +++ b/internal/adapters/playerrepository/repository_test.go @@ -617,6 +617,71 @@ func TestPostgresPlayerRepository(t *testing.T) { }) }) + t.Run("GetMostRecentPlayerPIT", func(t *testing.T) { + t.Parallel() + p := newPostgresPlayerRepository(t, db, "get_most_recent_player_pit_tests") + + now := time.Now().Truncate(time.Millisecond) + + storePlayers := func(t *testing.T, p PlayerRepository, players ...*domain.PlayerPIT) { + t.Helper() + for _, player := range players { + err := p.StorePlayer(ctx, player) + require.NoError(t, err) + } + } + + t.Run("returns the most recently queried stat", func(t *testing.T) { + t.Parallel() + + playerUUID := domaintest.NewUUID(t) + + p1 := domaintest.NewPlayerBuilder(playerUUID).Fours().WithGamesPlayed(1).BuildPtr(now.Add(-2 * time.Hour)) + p2 := domaintest.NewPlayerBuilder(playerUUID).Fours().WithGamesPlayed(2).BuildPtr(now.Add(-1 * time.Hour)) + p3 := domaintest.NewPlayerBuilder(playerUUID).Fours().WithGamesPlayed(3).BuildPtr(now) + + // Store out of chronological order to make sure ordering is by + // queried_at, not insertion order. + storePlayers(t, p, p2, p3, p1) + + mostRecent, err := p.GetMostRecentPlayerPIT(ctx, playerUUID) + require.NoError(t, err) + require.NotNil(t, mostRecent) + require.Equal(t, playerUUID, mostRecent.UUID) + require.WithinDuration(t, p3.QueriedAt, mostRecent.QueriedAt, 0) + require.Equal(t, 3, mostRecent.Fours.GamesPlayed) + requireValidDBID(t, mostRecent.DBID) + }) + + t.Run("returns ErrPlayerNotFound when the player has no stats", func(t *testing.T) { + t.Parallel() + + playerUUID := domaintest.NewUUID(t) + + mostRecent, err := p.GetMostRecentPlayerPIT(ctx, playerUUID) + require.ErrorIs(t, err, domain.ErrPlayerNotFound) + require.Nil(t, mostRecent) + }) + + t.Run("does not return another player's stats", func(t *testing.T) { + t.Parallel() + + playerUUID := domaintest.NewUUID(t) + otherUUID := domaintest.NewUUID(t) + + own := domaintest.NewPlayerBuilder(playerUUID).Fours().WithGamesPlayed(5).BuildPtr(now.Add(-1 * time.Hour)) + // Other player has a more recent stat, which must not be returned. + other := domaintest.NewPlayerBuilder(otherUUID).Fours().WithGamesPlayed(9).BuildPtr(now) + storePlayers(t, p, own, other) + + mostRecent, err := p.GetMostRecentPlayerPIT(ctx, playerUUID) + require.NoError(t, err) + require.NotNil(t, mostRecent) + require.Equal(t, playerUUID, mostRecent.UUID) + require.Equal(t, 5, mostRecent.Fours.GamesPlayed) + }) + }) + t.Run("GetHistory", func(t *testing.T) { t.Parallel() diff --git a/internal/app/latest_session.go b/internal/app/latest_session.go new file mode 100644 index 00000000..8fa572d8 --- /dev/null +++ b/internal/app/latest_session.go @@ -0,0 +1,57 @@ +package app + +import ( + "context" + "errors" + "fmt" + + "github.com/Amund211/flashlight/internal/domain" + "github.com/Amund211/flashlight/internal/reporting" + "github.com/Amund211/flashlight/internal/strutils" +) + +type latestSessionPlayerRepository interface { + GetMostRecentPlayerPIT(ctx context.Context, playerUUID string) (*domain.PlayerPIT, error) +} + +type GetLatestSession = func( + ctx context.Context, + uuid string, +) (SessionAtResult, error) + +// BuildGetLatestSession constructs a GetLatestSession that returns the player's +// most recent session, in the exact same shape as GetSessionAt. +// +// It runs a cheap preliminary query for the timestamp of the player's most +// recently stored stat, then delegates to GetSessionAt anchored at that time. +// GetSessionAt does the rest: it fetches (and refreshes) the ±24h window around +// that time, computes the session bracketing it, and derives the game segments. +// +// If the player has no stored stats, an empty SessionAtResult (nil session) is +// returned, mirroring GetSessionAt's behaviour when no session overlaps the +// requested time. +func BuildGetLatestSession( + repo latestSessionPlayerRepository, + getSessionAt GetSessionAt, +) GetLatestSession { + return func(ctx context.Context, uuid string) (SessionAtResult, error) { + if !strutils.UUIDIsNormalized(uuid) { + err := fmt.Errorf("UUID is not normalized") + reporting.Report(ctx, err) + return SessionAtResult{}, err + } + + mostRecent, err := repo.GetMostRecentPlayerPIT(ctx, uuid) + if errors.Is(err, domain.ErrPlayerNotFound) { + // No stored stats -> no session. Same shape as GetSessionAt when + // no session overlaps the requested time. + return SessionAtResult{Session: nil, Games: nil}, nil + } + if err != nil { + // NOTE: PlayerRepository implementations handle their own error reporting + return SessionAtResult{}, fmt.Errorf("failed to get most recent player pit: %w", err) + } + + return getSessionAt(ctx, uuid, mostRecent.QueriedAt) + } +} diff --git a/internal/app/latest_session_test.go b/internal/app/latest_session_test.go new file mode 100644 index 00000000..f49143e5 --- /dev/null +++ b/internal/app/latest_session_test.go @@ -0,0 +1,219 @@ +package app_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/Amund211/flashlight/internal/app" + "github.com/Amund211/flashlight/internal/domain" + "github.com/Amund211/flashlight/internal/domaintest" +) + +type mockMostRecentRepository struct { + pit *domain.PlayerPIT + err error +} + +func (m *mockMostRecentRepository) GetMostRecentPlayerPIT(ctx context.Context, playerUUID string) (*domain.PlayerPIT, error) { + return m.pit, m.err +} + +func TestBuildGetLatestSession(t *testing.T) { + t.Parallel() + + uuid := "01234567-89ab-cdef-0123-456789abcdef" + lastQueriedAt := time.Date(2024, 6, 15, 20, 30, 0, 0, time.UTC) + + t.Run("anchors GetSessionAt at the most recent stat's time and returns its result", func(t *testing.T) { + t.Parallel() + + mostRecent := domaintest.NewPlayerBuilder(uuid).FromDB().Build(lastQueriedAt) + repo := &mockMostRecentRepository{pit: &mostRecent} + + startPIT := domaintest.NewPlayerBuilder(uuid).FromDB().Fours().WithGamesPlayed(10).Build(lastQueriedAt.Add(-time.Hour)) + want := app.SessionAtResult{ + Session: &domain.Session{Start: startPIT, End: mostRecent, Consecutive: true}, + Games: []app.GameSegment{{Start: startPIT, End: mostRecent, Game: nil}}, + } + + var gotUUID string + var gotAt time.Time + called := false + getSessionAt := func(ctx context.Context, u string, at time.Time) (app.SessionAtResult, error) { + called = true + gotUUID = u + gotAt = at + return want, nil + } + + getLatestSession := app.BuildGetLatestSession(repo, getSessionAt) + + got, err := getLatestSession(t.Context(), uuid) + require.NoError(t, err) + require.True(t, called) + require.Equal(t, uuid, gotUUID) + require.WithinDuration(t, lastQueriedAt, gotAt, 0) + require.Equal(t, want, got) + }) + + t.Run("runs the real GetSessionAt pipeline end-to-end", func(t *testing.T) { + t.Parallel() + + // Wire the real GetSessionAt — real BuildComputeSessions over a + // fixed PIT slice — instead of a mock, so BuildGetLatestSession + // exercises the actual bracket-matching and game derivation against + // ComputeSessions. Mirrors session_at_test.go. + nowFarFuture := func() time.Time { return lastQueriedAt.Add(365 * 24 * time.Hour) } + computeSessions := app.BuildComputeSessions(nowFarFuture) + fixedStats := func(stats []domain.PlayerPIT) app.GetPlayerPITs { + return func(ctx context.Context, _ string, _, _ time.Time) ([]domain.PlayerPIT, error) { + return stats, nil + } + } + + // Three doubles games over four snapshots. The most recent snapshot + // (p3) is what GetMostRecentPlayerPIT returns and where GetSessionAt + // gets anchored, so the computed session must bracket exactly that + // time and end on it. + b := domaintest.NewPlayerBuilder(uuid). + WithExperience(1000).FromDB(). + Doubles(). + WithGamesPlayed(10).WithWins(5).WithLosses(5). + WithBedsBroken(4).WithBedsLost(3). + WithFinalKills(20).WithFinalDeaths(10). + WithKills(50).WithDeaths(30) + p0 := b.Build(lastQueriedAt.Add(-45 * time.Minute)) + + // doubles +1, won + p1 := b. + WithExperience(1300). + Doubles(). + WithGamesPlayed(11).WithWins(6). + WithBedsBroken(5). + WithFinalKills(24). + WithKills(58).WithDeaths(32).Build(lastQueriedAt.Add(-30 * time.Minute)) + + // doubles +1, lost, final-died, bed-lost + p2 := b. + WithExperience(1500). + Doubles(). + WithGamesPlayed(12).WithLosses(6). + WithBedsLost(4). + WithFinalKills(26).WithFinalDeaths(11). + WithKills(62).WithDeaths(36).Build(lastQueriedAt.Add(-15 * time.Minute)) + + // doubles +1, won — most recent stat + p3 := b. + WithExperience(1800). + Doubles(). + WithGamesPlayed(13).WithWins(7). + WithBedsBroken(6). + WithFinalKills(30). + WithKills(70).WithDeaths(38).Build(lastQueriedAt) + + repo := &mockMostRecentRepository{pit: &p3} + getSessionAt := app.BuildGetSessionAt( + fixedStats([]domain.PlayerPIT{p0, p1, p2, p3}), + computeSessions, + ) + + getLatestSession := app.BuildGetLatestSession(repo, getSessionAt) + + got, err := getLatestSession(t.Context(), uuid) + require.NoError(t, err) + require.Equal(t, app.SessionAtResult{ + Session: &domain.Session{Start: p0, End: p3, Consecutive: true}, + Games: []app.GameSegment{ + {Start: p0, End: p1, Game: &domain.GameResult{ + Gamemode: domain.GamemodeDoubles, + Outcome: domain.GameOutcomeWin, + FinalKills: 4, + FinalDeath: false, + BedsBroken: 1, + BedLost: false, + Kills: 8, + Deaths: 2, + Experience: 300, + }}, + {Start: p1, End: p2, Game: &domain.GameResult{ + Gamemode: domain.GamemodeDoubles, + Outcome: domain.GameOutcomeLoss, + FinalKills: 2, + FinalDeath: true, + BedsBroken: 0, + BedLost: true, + Kills: 4, + Deaths: 4, + Experience: 200, + }}, + {Start: p2, End: p3, Game: &domain.GameResult{ + Gamemode: domain.GamemodeDoubles, + Outcome: domain.GameOutcomeWin, + FinalKills: 4, + FinalDeath: false, + BedsBroken: 1, + BedLost: false, + Kills: 8, + Deaths: 2, + Experience: 300, + }}, + }, + }, got) + }) + + t.Run("returns empty result and skips GetSessionAt when the player has no stats", func(t *testing.T) { + t.Parallel() + + repo := &mockMostRecentRepository{err: domain.ErrPlayerNotFound} + + getSessionAt := func(ctx context.Context, u string, at time.Time) (app.SessionAtResult, error) { + t.Helper() + t.Fatal("getSessionAt should not be called when the player has no stats") + return app.SessionAtResult{}, nil + } + + getLatestSession := app.BuildGetLatestSession(repo, getSessionAt) + + got, err := getLatestSession(t.Context(), uuid) + require.NoError(t, err) + require.Equal(t, app.SessionAtResult{}, got) + }) + + t.Run("propagates repository errors and skips GetSessionAt", func(t *testing.T) { + t.Parallel() + + repo := &mockMostRecentRepository{err: errors.New("boom")} + + getSessionAt := func(ctx context.Context, u string, at time.Time) (app.SessionAtResult, error) { + t.Helper() + t.Fatal("getSessionAt should not be called when the repository fails") + return app.SessionAtResult{}, nil + } + + getLatestSession := app.BuildGetLatestSession(repo, getSessionAt) + + _, err := getLatestSession(t.Context(), uuid) + require.Error(t, err) + }) + + t.Run("rejects a non-normalized uuid without touching the repository", func(t *testing.T) { + t.Parallel() + + repo := &mockMostRecentRepository{err: errors.New("repo should not be queried")} + + getSessionAt := func(ctx context.Context, u string, at time.Time) (app.SessionAtResult, error) { + t.Helper() + t.Fatal("getSessionAt should not be called for an invalid uuid") + return app.SessionAtResult{}, nil + } + + getLatestSession := app.BuildGetLatestSession(repo, getSessionAt) + + _, err := getLatestSession(t.Context(), "not-normalized") + require.Error(t, err) + }) +} diff --git a/internal/ports/latest_session.go b/internal/ports/latest_session.go new file mode 100644 index 00000000..d7fed1df --- /dev/null +++ b/internal/ports/latest_session.go @@ -0,0 +1,139 @@ +package ports + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + + "github.com/Amund211/flashlight/internal/app" + "github.com/Amund211/flashlight/internal/logging" + "github.com/Amund211/flashlight/internal/ratelimiting" + "github.com/Amund211/flashlight/internal/reporting" + "github.com/Amund211/flashlight/internal/strutils" +) + +// MakeGetLatestSessionHandler serves POST /v1/session-at/latest. It returns the +// same response body as the session-at handler, but takes only a uuid and +// resolves the time itself (the player's latest session). See +// app.BuildGetLatestSession. +func MakeGetLatestSessionHandler( + getLatestSession app.GetLatestSession, + registerUserVisit app.RegisterUserVisit, + allowedOrigins *DomainSuffixes, + rootLogger *slog.Logger, + sentryMiddleware func(http.HandlerFunc) http.HandlerFunc, + blocklistConfig BlocklistConfig, +) http.HandlerFunc { + ipLimiter, _ := ratelimiting.NewTokenBucketRateLimiter( + ratelimiting.RefillPerSecond(4), + ratelimiting.BurstSize(80), + ) + ipRateLimiter := ratelimiting.NewRequestBasedRateLimiter( + ipLimiter, + IPHashKeyFunc, + ) + userIDLimiter, _ := ratelimiting.NewTokenBucketRateLimiter( + ratelimiting.RefillPerSecond(1), + ratelimiting.BurstSize(20), + ) + userIDRateLimiter := ratelimiting.NewRequestBasedRateLimiter( + userIDLimiter, + UserIDKeyFunc, + ) + + makeOnLimitExceeded := func(rateLimiter ratelimiting.RequestRateLimiter) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + statusCode := http.StatusTooManyRequests + + logging.FromContext(ctx).InfoContext(ctx, "Rate limit exceeded", "statusCode", statusCode, "reason", "ratelimit exceeded", "key", rateLimiter.KeyFor(r)) + + http.Error(w, "Rate limit exceeded", statusCode) + } + } + + middleware := ComposeMiddlewares( + NewRequestLoggerMiddleware(rootLogger), + sentryMiddleware, + BuildBlocklistMiddleware(blocklistConfig), + buildMetricsMiddleware("session-at-latest"), + NewReportingMetaMiddleware("session-at-latest"), + BuildCORSMiddleware(allowedOrigins), + NewRateLimitMiddleware(ipRateLimiter, makeOnLimitExceeded(ipRateLimiter)), + NewRateLimitMiddleware(userIDRateLimiter, makeOnLimitExceeded(userIDRateLimiter)), + BuildRegisterUserVisitMiddleware(registerUserVisit), + ) + + handler := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + defer r.Body.Close() + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4<<10)) + if err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge) + return + } + reporting.Report(ctx, fmt.Errorf("failed to read request body: %w", err)) + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + request := struct { + UUID string `json:"uuid"` + }{} + err = json.Unmarshal(body, &request) + if err != nil { + logging.FromContext(ctx).WarnContext(ctx, "Failed to parse request body", "error", err) + http.Error(w, "Failed to parse request body", http.StatusBadRequest) + return + } + + uuid, err := strutils.NormalizeUUID(request.UUID) + if err != nil { + logging.FromContext(ctx).WarnContext(ctx, "Failed to normalize uuid", "error", err, "rawUUID", request.UUID) + http.Error(w, "invalid uuid", http.StatusBadRequest) + return + } + + logging.FromContext(ctx).InfoContext(ctx, "Handling latest-session request", + slog.String("uuid", uuid), + ) + + ctx = reporting.AddExtrasToContext(ctx, map[string]string{ + "uuid": uuid, + }) + ctx = logging.AddMetaToContext(ctx, + slog.String("uuid", uuid), + ) + + result, err := getLatestSession(ctx, uuid) + if err != nil { + // NOTE: GetLatestSession implementations handle their own error reporting + http.Error(w, "Failed to get session", http.StatusInternalServerError) + return + } + + marshalled, err := marshalRainbowSessionAtResponse(ctx, result) + if err != nil { + // NOTE: marshalRainbowSessionAtResponse handles its own error reporting + http.Error(w, "Failed to serialise response", http.StatusInternalServerError) + return + } + + logging.FromContext(ctx).InfoContext(ctx, "Returning latest session", + "hasSession", result.Session != nil, + "gamesLength", len(result.Games), + ) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(marshalled) + } + + return middleware(handler) +} diff --git a/internal/ports/latest_session_test.go b/internal/ports/latest_session_test.go new file mode 100644 index 00000000..638c409d --- /dev/null +++ b/internal/ports/latest_session_test.go @@ -0,0 +1,263 @@ +package ports_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/Amund211/flashlight/internal/app" + "github.com/Amund211/flashlight/internal/domain" + "github.com/Amund211/flashlight/internal/domaintest" + "github.com/Amund211/flashlight/internal/ports" +) + +func TestMakeGetLatestSessionHandler(t *testing.T) { + t.Parallel() + + allowedOrigins, err := ports.NewDomainSuffixes("example.com", "test.com") + require.NoError(t, err) + + testLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) + noopMiddleware := func(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + h(w, r) + } + } + + makeHandler := func(getLatestSession app.GetLatestSession) http.HandlerFunc { + stubRegisterUserVisit := func(ctx context.Context, userID string, ipHash string, userAgent string) (domain.User, error) { + return domain.User{}, nil + } + return ports.MakeGetLatestSessionHandler( + getLatestSession, + stubRegisterUserVisit, + allowedOrigins, + testLogger, + noopMiddleware, + emptyBlocklistConfig, + ) + } + + makeRequest := func(uuid string) *http.Request { + body := io.NopCloser(strings.NewReader( + fmt.Sprintf(`{"uuid":"%s"}`, uuid), + )) + return httptest.NewRequestWithContext(t.Context(), "POST", "/session-at/latest", body) + } + + uuid := "01234567-89ab-cdef-0123-456789abcdef" + at := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + type gameResponse struct { + Gamemode string `json:"gamemode"` + Outcome string `json:"outcome"` + FinalKills int `json:"finalKills"` + FinalDeath bool `json:"finalDeath"` + BedsBroken int `json:"bedsBroken"` + BedLost bool `json:"bedLost"` + Kills int `json:"kills"` + Deaths int `json:"deaths"` + Experience int64 `json:"experience"` + } + type segmentResponse struct { + Start map[string]any `json:"start"` + End map[string]any `json:"end"` + Game *gameResponse `json:"game"` + } + type sessionAtResponse struct { + Session *struct { + Start map[string]any `json:"start"` + End map[string]any `json:"end"` + Consecutive bool `json:"consecutive"` + } `json:"session"` + Games []segmentResponse `json:"games"` + } + + t.Run("forwards uuid to app method and renders games", func(t *testing.T) { + t.Parallel() + + sessionStart := time.Date(2024, 1, 1, 11, 0, 0, 0, time.UTC) + mid := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + sessionEnd := time.Date(2024, 1, 1, 13, 0, 0, 0, time.UTC) + + startPIT := domaintest.NewPlayerBuilder(uuid). + WithExperience(1000).FromDB().Fours().WithGamesPlayed(10).Build(sessionStart) + midPIT := domaintest.NewPlayerBuilder(uuid). + WithExperience(1500).FromDB().Fours().WithGamesPlayed(11).Build(mid) + endPIT := domaintest.NewPlayerBuilder(uuid). + WithExperience(2000).FromDB().Fours().WithGamesPlayed(12).Build(sessionEnd) + + result := app.SessionAtResult{ + Session: &domain.Session{ + Start: startPIT, + End: endPIT, + Consecutive: true, + }, + Games: []app.GameSegment{ + { + Start: startPIT, + End: midPIT, + Game: &domain.GameResult{ + Gamemode: domain.GamemodeDoubles, + Outcome: domain.GameOutcomeWin, + FinalKills: 4, + FinalDeath: false, + BedsBroken: 1, + BedLost: false, + Kills: 8, + Deaths: 2, + Experience: 500, + }, + }, + {Start: midPIT, End: endPIT, Game: nil}, + }, + } + + called := false + getLatestSession := func(ctx context.Context, gotUUID string) (app.SessionAtResult, error) { + called = true + require.Equal(t, uuid, gotUUID) + return result, nil + } + + handler := makeHandler(getLatestSession) + req := makeRequest(uuid) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.True(t, called) + + var response sessionAtResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + + require.NotNil(t, response.Session) + require.True(t, response.Session.Consecutive) + require.Equal(t, sessionStart.Format(time.RFC3339), response.Session.Start["queriedAt"]) + require.Equal(t, sessionEnd.Format(time.RFC3339), response.Session.End["queriedAt"]) + + require.Len(t, response.Games, 2) + require.NotNil(t, response.Games[0].Game) + require.Equal(t, "doubles", response.Games[0].Game.Gamemode) + require.Equal(t, "win", response.Games[0].Game.Outcome) + require.Equal(t, int64(500), response.Games[0].Game.Experience) + require.Nil(t, response.Games[1].Game) + }) + + t.Run("nil session is rendered as null with empty games", func(t *testing.T) { + t.Parallel() + + getLatestSession := func(ctx context.Context, gotUUID string) (app.SessionAtResult, error) { + return app.SessionAtResult{Session: nil, Games: nil}, nil + } + + handler := makeHandler(getLatestSession) + req := makeRequest(uuid) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var response sessionAtResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Nil(t, response.Session) + require.Empty(t, response.Games) + }) + + t.Run("unknown gamemode returns 500", func(t *testing.T) { + t.Parallel() + + startPIT := domaintest.NewPlayerBuilder(uuid).FromDB().Build(at) + + result := app.SessionAtResult{ + Session: &domain.Session{Start: startPIT, End: startPIT, Consecutive: true}, + Games: []app.GameSegment{ + { + Start: startPIT, + End: startPIT, + Game: &domain.GameResult{Gamemode: domain.Gamemode("bogus")}, + }, + }, + } + + getLatestSession := func(ctx context.Context, _ string) (app.SessionAtResult, error) { + return result, nil + } + + handler := makeHandler(getLatestSession) + req := makeRequest(uuid) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusInternalServerError, w.Code) + }) + + makeAssertNotCalled := func(t *testing.T) app.GetLatestSession { + return func(ctx context.Context, uuid string) (app.SessionAtResult, error) { + t.Helper() + t.Fatal("getLatestSession should not be called") + return app.SessionAtResult{}, nil + } + } + + t.Run("invalid UUID", func(t *testing.T) { + t.Parallel() + + handler := makeHandler(makeAssertNotCalled(t)) + req := makeRequest("not-a-uuid") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), "invalid uuid") + }) + + t.Run("request body exceeds size limit", func(t *testing.T) { + t.Parallel() + + handler := makeHandler(makeAssertNotCalled(t)) + oversized := fmt.Sprintf(`{"uuid":"%s"}`, strings.Repeat("a", 5<<10)) + body := io.NopCloser(strings.NewReader(oversized)) + req := httptest.NewRequestWithContext(t.Context(), "POST", "/session-at/latest", body) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusRequestEntityTooLarge, w.Code) + }) + + t.Run("malformed JSON", func(t *testing.T) { + t.Parallel() + + handler := makeHandler(makeAssertNotCalled(t)) + body := io.NopCloser(strings.NewReader("not json")) + req := httptest.NewRequestWithContext(t.Context(), "POST", "/session-at/latest", body) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("app method failure returns 500", func(t *testing.T) { + t.Parallel() + + getLatestSession := func(ctx context.Context, uuid string) (app.SessionAtResult, error) { + return app.SessionAtResult{}, fmt.Errorf("boom") + } + + handler := makeHandler(getLatestSession) + req := makeRequest(uuid) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusInternalServerError, w.Code) + }) +} diff --git a/internal/ports/session_at.go b/internal/ports/session_at.go index 3f9e9abc..28b0707c 100644 --- a/internal/ports/session_at.go +++ b/internal/ports/session_at.go @@ -1,6 +1,7 @@ package ports import ( + "context" "encoding/json" "errors" "fmt" @@ -152,58 +153,16 @@ func MakeGetSessionAtHandler( return } - response := rainbowSessionAtResponse{ - Session: nil, - Games: make([]rainbowGameSegment, 0, len(result.Games)), - } - if result.Session != nil { - rbSession := sessionToRainbowSession(result.Session) - response.Session = &rbSession - } - for _, seg := range result.Games { - var game *rainbowGameResult - if seg.Game != nil { - rainbowGamemode, gErr := gamemodeToRainbowGamemode(seg.Game.Gamemode) - if gErr != nil { - reporting.Report(ctx, fmt.Errorf("failed to convert gamemode: %w", gErr)) - http.Error(w, "Failed to serialise response", http.StatusInternalServerError) - return - } - rainbowOutcome, oErr := gameOutcomeToRainbowOutcome(seg.Game.Outcome) - if oErr != nil { - reporting.Report(ctx, fmt.Errorf("failed to convert outcome: %w", oErr)) - http.Error(w, "Failed to serialise response", http.StatusInternalServerError) - return - } - game = &rainbowGameResult{ - Gamemode: rainbowGamemode, - Outcome: rainbowOutcome, - FinalKills: seg.Game.FinalKills, - FinalDeath: seg.Game.FinalDeath, - BedsBroken: seg.Game.BedsBroken, - BedLost: seg.Game.BedLost, - Kills: seg.Game.Kills, - Deaths: seg.Game.Deaths, - Experience: seg.Game.Experience, - } - } - response.Games = append(response.Games, rainbowGameSegment{ - Start: playerToRainbowPlayerDataPIT(&seg.Start), - End: playerToRainbowPlayerDataPIT(&seg.End), - Game: game, - }) - } - - marshalled, err := json.Marshal(response) + marshalled, err := marshalRainbowSessionAtResponse(ctx, result) if err != nil { - reporting.Report(ctx, fmt.Errorf("failed to marshal response: %w", err)) - http.Error(w, "Failed to marshal response", http.StatusInternalServerError) + // NOTE: marshalRainbowSessionAtResponse handles its own error reporting + http.Error(w, "Failed to serialise response", http.StatusInternalServerError) return } logging.FromContext(ctx).InfoContext(ctx, "Returning session at", "hasSession", result.Session != nil, - "gamesLength", len(response.Games), + "gamesLength", len(result.Games), ) w.Header().Set("Content-Type", "application/json") @@ -213,3 +172,61 @@ func MakeGetSessionAtHandler( return middleware(handler) } + +// marshalRainbowSessionAtResponse converts an app.SessionAtResult into the +// rainbow-facing session-at wire format and marshals it to JSON. Both the +// session-at and latest-session handlers use it so their response bodies are +// byte-identical. Errors are reported to Sentry here; callers just surface a +// 500. +func marshalRainbowSessionAtResponse(ctx context.Context, result app.SessionAtResult) ([]byte, error) { + response := rainbowSessionAtResponse{ + Session: nil, + Games: make([]rainbowGameSegment, 0, len(result.Games)), + } + if result.Session != nil { + rbSession := sessionToRainbowSession(result.Session) + response.Session = &rbSession + } + for _, seg := range result.Games { + var game *rainbowGameResult + if seg.Game != nil { + rainbowGamemode, gErr := gamemodeToRainbowGamemode(seg.Game.Gamemode) + if gErr != nil { + err := fmt.Errorf("failed to convert gamemode: %w", gErr) + reporting.Report(ctx, err) + return nil, err + } + rainbowOutcome, oErr := gameOutcomeToRainbowOutcome(seg.Game.Outcome) + if oErr != nil { + err := fmt.Errorf("failed to convert outcome: %w", oErr) + reporting.Report(ctx, err) + return nil, err + } + game = &rainbowGameResult{ + Gamemode: rainbowGamemode, + Outcome: rainbowOutcome, + FinalKills: seg.Game.FinalKills, + FinalDeath: seg.Game.FinalDeath, + BedsBroken: seg.Game.BedsBroken, + BedLost: seg.Game.BedLost, + Kills: seg.Game.Kills, + Deaths: seg.Game.Deaths, + Experience: seg.Game.Experience, + } + } + response.Games = append(response.Games, rainbowGameSegment{ + Start: playerToRainbowPlayerDataPIT(&seg.Start), + End: playerToRainbowPlayerDataPIT(&seg.End), + Game: game, + }) + } + + marshalled, err := json.Marshal(response) + if err != nil { + err := fmt.Errorf("failed to marshal response: %w", err) + reporting.Report(ctx, err) + return nil, err + } + + return marshalled, nil +} diff --git a/main.go b/main.go index d0a6cb17..3404898d 100644 --- a/main.go +++ b/main.go @@ -202,6 +202,8 @@ func main() { getSessionAt := app.BuildGetSessionAt(getPlayerPITs, computeSessions) + getLatestSession := app.BuildGetLatestSession(playerRepo, getSessionAt) + findMilestoneAchievements := app.BuildFindMilestoneAchievements( playerRepo, getAndPersistPlayerWithCache, @@ -358,6 +360,22 @@ func main() { ), ) + handleFunc( + "OPTIONS /v1/session-at/latest", + ports.BuildCORSHandler(allowedOrigins), + ) + handleFunc( + "POST /v1/session-at/latest", + ports.MakeGetLatestSessionHandler( + getLatestSession, + registerUserVisit, + allowedOrigins, + logger.With("port", "session-at-latest"), + sentryMiddleware, + blocklistConfig, + ), + ) + handleFunc( "OPTIONS /v1/prestiges/{uuid}", ports.BuildCORSHandler(allowedOrigins), From d81274de2983e335a31da90772838cc51149fec8 Mon Sep 17 00:00:00 2001 From: Amund Eggen Svandal Date: Sun, 12 Jul 2026 02:22:01 +0200 Subject: [PATCH 2/2] fix: Anchor latest-session at the last session, not the last stat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetLatestSession anchored GetSessionAt at the most recently stored stat. An inactive player re-viewed more than 1h after their last game gets a fresh duplicate stat appended (StorePlayer only dedups within a trailing 1h window). That trailing stat isn't part of any session, so the bracket in GetSessionAt looked past the real session's end and returned "no session" despite a recent session existing. Instead, run a cheap read-only discovery pass over the player's most recent stats, compute their sessions, and anchor GetSessionAt at the latest session's End. The End is an eventful stat, so it always falls inside the bracket, and GetSessionAt still handles the refresh and game-segment derivation. The discovery pass uses a new GetRecentPlayerPITs query — full-fidelity and recency-limited, unlike the downsampling GetHistory — so reaching back is bounded by row count rather than a fixed time window, and an older-but-real last session is still found. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adapters/playerrepository/interface.go | 5 + .../adapters/playerrepository/repository.go | 83 ++++++ .../playerrepository/repository_test.go | 101 +++++++ internal/app/latest_session.go | 80 ++++-- internal/app/latest_session_test.go | 265 +++++++++++------- main.go | 2 +- 6 files changed, 412 insertions(+), 124 deletions(-) diff --git a/internal/adapters/playerrepository/interface.go b/internal/adapters/playerrepository/interface.go index 40f2440c..a45811a3 100644 --- a/internal/adapters/playerrepository/interface.go +++ b/internal/adapters/playerrepository/interface.go @@ -17,5 +17,10 @@ type PlayerRepository interface { // GetMostRecentPlayerPIT returns the most recently queried stat stored for // the player, or domain.ErrPlayerNotFound if none exist. GetMostRecentPlayerPIT(ctx context.Context, playerUUID string) (*domain.PlayerPIT, error) + // GetRecentPlayerPITs returns up to limit of the player's most recently + // queried stats, in ascending queried_at order (oldest first). Unlike + // GetHistory it does not downsample — every stored row in the most recent + // limit is returned, so the result is safe to run session computation over. + GetRecentPlayerPITs(ctx context.Context, playerUUID string, limit int) ([]domain.PlayerPIT, error) FindMilestoneAchievements(ctx context.Context, playerUUID string, gamemode domain.Gamemode, stat domain.Stat, milestones []int64) ([]domain.MilestoneAchievement, error) } diff --git a/internal/adapters/playerrepository/repository.go b/internal/adapters/playerrepository/repository.go index 055967ce..5b96fbf5 100644 --- a/internal/adapters/playerrepository/repository.go +++ b/internal/adapters/playerrepository/repository.go @@ -672,6 +672,85 @@ func (p *PostgresPlayerRepository) GetMostRecentPlayerPIT(ctx context.Context, p return player, nil } +func (p *PostgresPlayerRepository) GetRecentPlayerPITs(ctx context.Context, playerUUID string, limit int) ([]domain.PlayerPIT, error) { + ctx, span := p.tracer.Start(ctx, "PostgresPlayerRepository.GetRecentPlayerPITs") + defer span.End() + + if !strutils.UUIDIsNormalized(playerUUID) { + err := fmt.Errorf("uuid is not normalized") + reporting.Report(ctx, err, map[string]string{ + "uuid": playerUUID, + }) + return nil, err + } + + if limit < 1 || limit > 1000 { + err := fmt.Errorf("invalid limit") + reporting.Report(ctx, err, map[string]string{ + "limit": strconv.Itoa(limit), + }) + return nil, err + } + + conn, err := p.db.Connx(ctx) + if err != nil { + err := fmt.Errorf("failed to get connection: %w", err) + reporting.Report(ctx, err) + return nil, err + } + defer conn.Close() + + _, err = conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(p.schema))) + if err != nil { + err := fmt.Errorf("failed to set search path: %w", err) + reporting.Report(ctx, err, map[string]string{ + "schema": p.schema, + }) + return nil, err + } + + dbStats := make([]dbStat, 0, limit) + err = conn.SelectContext( + ctx, + &dbStats, + `select + id, data_format_version, player_uuid, queried_at, player_data + from stats + where + player_uuid = $1 + order by queried_at desc + limit $2`, + playerUUID, limit) + if err != nil { + err := fmt.Errorf("failed to select recent stats: %w", err) + reporting.Report(ctx, err, map[string]string{ + "uuid": playerUUID, + "limit": strconv.Itoa(limit), + }) + return nil, err + } + + // The query returns newest-first; reverse to ascending queried_at order so + // callers (and ComputeSessions) get a chronological slice, matching + // GetPlayerPITs' ordering. + slices.Reverse(dbStats) + + stats := make([]domain.PlayerPIT, 0, len(dbStats)) + for _, dbStat := range dbStats { + player, err := dbStatToPlayerPIT(dbStat) + if err != nil { + err := fmt.Errorf("failed to convert db stat to playerpit: %w", err) + reporting.Report(ctx, err, map[string]string{ + "statID": dbStat.ID, + }) + return nil, err + } + stats = append(stats, *player) + } + + return stats, nil +} + func (p *PostgresPlayerRepository) FindMilestoneAchievements(ctx context.Context, playerUUID string, gamemode domain.Gamemode, stat domain.Stat, milestones []int64) ([]domain.MilestoneAchievement, error) { ctx, span := p.tracer.Start(ctx, "PostgresPlayerRepository.FindMilestoneAchievements") defer span.End() @@ -853,6 +932,10 @@ func (p *StubPlayerRepository) GetMostRecentPlayerPIT(ctx context.Context, playe return nil, domain.ErrPlayerNotFound } +func (p *StubPlayerRepository) GetRecentPlayerPITs(ctx context.Context, playerUUID string, limit int) ([]domain.PlayerPIT, error) { + return []domain.PlayerPIT{}, nil +} + func (p *StubPlayerRepository) FindMilestoneAchievements(ctx context.Context, playerUUID string, gamemode domain.Gamemode, stat domain.Stat, milestones []int64) ([]domain.MilestoneAchievement, error) { return []domain.MilestoneAchievement{}, nil } diff --git a/internal/adapters/playerrepository/repository_test.go b/internal/adapters/playerrepository/repository_test.go index 04e5475f..faa3e649 100644 --- a/internal/adapters/playerrepository/repository_test.go +++ b/internal/adapters/playerrepository/repository_test.go @@ -682,6 +682,107 @@ func TestPostgresPlayerRepository(t *testing.T) { }) }) + t.Run("GetRecentPlayerPITs", func(t *testing.T) { + t.Parallel() + p := newPostgresPlayerRepository(t, db, "get_recent_player_pits_tests") + + now := time.Now().Truncate(time.Millisecond) + + storePlayers := func(t *testing.T, p PlayerRepository, players ...*domain.PlayerPIT) { + t.Helper() + for _, player := range players { + err := p.StorePlayer(ctx, player) + require.NoError(t, err) + } + } + + gamesPlayed := func(pits []domain.PlayerPIT) []int { + out := make([]int, len(pits)) + for i, pit := range pits { + out[i] = pit.Fours.GamesPlayed + } + return out + } + + t.Run("returns recent stats in ascending queried_at order", func(t *testing.T) { + t.Parallel() + + playerUUID := domaintest.NewUUID(t) + + p1 := domaintest.NewPlayerBuilder(playerUUID).Fours().WithGamesPlayed(1).BuildPtr(now.Add(-2 * time.Hour)) + p2 := domaintest.NewPlayerBuilder(playerUUID).Fours().WithGamesPlayed(2).BuildPtr(now.Add(-1 * time.Hour)) + p3 := domaintest.NewPlayerBuilder(playerUUID).Fours().WithGamesPlayed(3).BuildPtr(now) + + // Store out of chronological order to prove ordering is by + // queried_at, not insertion order. + storePlayers(t, p, p2, p3, p1) + + recent, err := p.GetRecentPlayerPITs(ctx, playerUUID, 10) + require.NoError(t, err) + require.Equal(t, []int{1, 2, 3}, gamesPlayed(recent)) + require.WithinDuration(t, p1.QueriedAt, recent[0].QueriedAt, 0) + require.WithinDuration(t, p3.QueriedAt, recent[2].QueriedAt, 0) + requireValidDBID(t, recent[0].DBID) + }) + + t.Run("returns only the most recent limit stats, still ascending", func(t *testing.T) { + t.Parallel() + + playerUUID := domaintest.NewUUID(t) + + players := make([]*domain.PlayerPIT, 5) + for i := range players { + players[i] = domaintest.NewPlayerBuilder(playerUUID). + Fours().WithGamesPlayed(i + 1). + BuildPtr(now.Add(time.Duration(i-4) * time.Hour)) + } + storePlayers(t, p, players...) + + recent, err := p.GetRecentPlayerPITs(ctx, playerUUID, 3) + require.NoError(t, err) + // The three newest (games 3,4,5), oldest-first. + require.Equal(t, []int{3, 4, 5}, gamesPlayed(recent)) + }) + + t.Run("returns empty when the player has no stats", func(t *testing.T) { + t.Parallel() + + playerUUID := domaintest.NewUUID(t) + + recent, err := p.GetRecentPlayerPITs(ctx, playerUUID, 10) + require.NoError(t, err) + require.Empty(t, recent) + }) + + t.Run("does not return another player's stats", func(t *testing.T) { + t.Parallel() + + playerUUID := domaintest.NewUUID(t) + otherUUID := domaintest.NewUUID(t) + + own := domaintest.NewPlayerBuilder(playerUUID).Fours().WithGamesPlayed(5).BuildPtr(now.Add(-1 * time.Hour)) + // Other player has a more recent stat, which must not be returned. + other := domaintest.NewPlayerBuilder(otherUUID).Fours().WithGamesPlayed(9).BuildPtr(now) + storePlayers(t, p, own, other) + + recent, err := p.GetRecentPlayerPITs(ctx, playerUUID, 10) + require.NoError(t, err) + require.Equal(t, []int{5}, gamesPlayed(recent)) + }) + + t.Run("rejects an out-of-range limit", func(t *testing.T) { + t.Parallel() + + playerUUID := domaintest.NewUUID(t) + + _, err := p.GetRecentPlayerPITs(ctx, playerUUID, 0) + require.Error(t, err) + + _, err = p.GetRecentPlayerPITs(ctx, playerUUID, 1001) + require.Error(t, err) + }) + }) + t.Run("GetHistory", func(t *testing.T) { t.Parallel() diff --git a/internal/app/latest_session.go b/internal/app/latest_session.go index 8fa572d8..f0096af8 100644 --- a/internal/app/latest_session.go +++ b/internal/app/latest_session.go @@ -2,16 +2,27 @@ package app import ( "context" - "errors" "fmt" + "strconv" "github.com/Amund211/flashlight/internal/domain" "github.com/Amund211/flashlight/internal/reporting" "github.com/Amund211/flashlight/internal/strutils" ) +// latestSessionDiscoveryLimit bounds how many of the player's most recent +// stats we scan to locate their latest session. +// +// It must comfortably exceed the number of trailing non-eventful "still-seen" +// pings plus the stats making up the session itself. StorePlayer only dedups +// stats within a trailing 1h window, so an inactive-but-watched player accrues +// at most ~one duplicate ping per hour they're viewed; a few hundred rows +// therefore covers weeks of such pings. If a real session sits entirely beyond +// this many rows we won't find it — see the report below. +const latestSessionDiscoveryLimit = 300 + type latestSessionPlayerRepository interface { - GetMostRecentPlayerPIT(ctx context.Context, playerUUID string) (*domain.PlayerPIT, error) + GetRecentPlayerPITs(ctx context.Context, playerUUID string, limit int) ([]domain.PlayerPIT, error) } type GetLatestSession = func( @@ -22,16 +33,27 @@ type GetLatestSession = func( // BuildGetLatestSession constructs a GetLatestSession that returns the player's // most recent session, in the exact same shape as GetSessionAt. // -// It runs a cheap preliminary query for the timestamp of the player's most -// recently stored stat, then delegates to GetSessionAt anchored at that time. -// GetSessionAt does the rest: it fetches (and refreshes) the ±24h window around -// that time, computes the session bracketing it, and derives the game segments. +// It runs a cheap read-only "discovery" pass over the player's most recent +// stats, computes their sessions, and takes the latest one. It then delegates +// to GetSessionAt anchored at that session's End. GetSessionAt does the rest: +// it (re)fetches and refreshes the ±24h window around that time, recomputes the +// session bracketing it, and derives the game segments. +// +// Anchoring at the last session's End — rather than at the most recently stored +// stat — is deliberate. An inactive player re-viewed more than 1h after their +// last game gets a fresh duplicate stat appended (StorePlayer only dedups +// within a trailing 1h window). That trailing stat is not part of any session, +// so anchoring GetSessionAt there would look past the real session's end and +// return "no session". The discovery pass sidesteps this: non-eventful trailing +// pings never extend a session, so the computed latest session ends on the last +// stat that actually saw activity. // -// If the player has no stored stats, an empty SessionAtResult (nil session) is -// returned, mirroring GetSessionAt's behaviour when no session overlaps the -// requested time. +// If the player has no session in the scanned window, an empty SessionAtResult +// (nil session) is returned, mirroring GetSessionAt's behaviour when no session +// overlaps the requested time. func BuildGetLatestSession( repo latestSessionPlayerRepository, + computeSessions ComputeSessions, getSessionAt GetSessionAt, ) GetLatestSession { return func(ctx context.Context, uuid string) (SessionAtResult, error) { @@ -41,17 +63,41 @@ func BuildGetLatestSession( return SessionAtResult{}, err } - mostRecent, err := repo.GetMostRecentPlayerPIT(ctx, uuid) - if errors.Is(err, domain.ErrPlayerNotFound) { - // No stored stats -> no session. Same shape as GetSessionAt when - // no session overlaps the requested time. - return SessionAtResult{Session: nil, Games: nil}, nil - } + // Discovery pass: a pure read (no refresh) used only to locate the + // anchor for GetSessionAt. + recent, err := repo.GetRecentPlayerPITs(ctx, uuid, latestSessionDiscoveryLimit) if err != nil { // NOTE: PlayerRepository implementations handle their own error reporting - return SessionAtResult{}, fmt.Errorf("failed to get most recent player pit: %w", err) + return SessionAtResult{}, fmt.Errorf("failed to get recent player pits: %w", err) + } + + if len(recent) < 2 { + // Need at least two stats to form a session. + return SessionAtResult{Session: nil, Games: nil}, nil + } + + // recent is ascending, so these bounds span the whole scanned range and + // ComputeSessions includes every session it finds. + sessions := computeSessions(ctx, recent, recent[0].QueriedAt, recent[len(recent)-1].QueriedAt) + if len(sessions) == 0 { + if len(recent) == latestSessionDiscoveryLimit { + // We scanned the full cap and still found no session: a real but + // older session may sit just beyond it. Surface it rather than + // silently returning "no session". + reporting.Report(ctx, + fmt.Errorf("no session found within latest-session discovery limit"), + map[string]string{ + "limit": strconv.Itoa(latestSessionDiscoveryLimit), + }, + ) + } + return SessionAtResult{Session: nil, Games: nil}, nil } - return getSessionAt(ctx, uuid, mostRecent.QueriedAt) + // ComputeSessions returns sessions in ascending order, so the last is the + // most recent. Its End is an eventful stat, so it's guaranteed to fall + // inside GetSessionAt's bracket. + latest := sessions[len(sessions)-1] + return getSessionAt(ctx, uuid, latest.End.QueriedAt) } } diff --git a/internal/app/latest_session_test.go b/internal/app/latest_session_test.go index f49143e5..3533f0bb 100644 --- a/internal/app/latest_session_test.go +++ b/internal/app/latest_session_test.go @@ -13,13 +13,17 @@ import ( "github.com/Amund211/flashlight/internal/domaintest" ) -type mockMostRecentRepository struct { - pit *domain.PlayerPIT - err error +type mockRecentRepository struct { + pits []domain.PlayerPIT + err error + gotLimit int + called bool } -func (m *mockMostRecentRepository) GetMostRecentPlayerPIT(ctx context.Context, playerUUID string) (*domain.PlayerPIT, error) { - return m.pit, m.err +func (m *mockRecentRepository) GetRecentPlayerPITs(ctx context.Context, playerUUID string, limit int) ([]domain.PlayerPIT, error) { + m.called = true + m.gotLimit = limit + return m.pits, m.err } func TestBuildGetLatestSession(t *testing.T) { @@ -28,16 +32,89 @@ func TestBuildGetLatestSession(t *testing.T) { uuid := "01234567-89ab-cdef-0123-456789abcdef" lastQueriedAt := time.Date(2024, 6, 15, 20, 30, 0, 0, time.UTC) - t.Run("anchors GetSessionAt at the most recent stat's time and returns its result", func(t *testing.T) { + // nowFarFuture keeps the computed sessions from being marked Ongoing so the + // tests assert on stable, completed sessions. + nowFarFuture := func() time.Time { return lastQueriedAt.Add(365 * 24 * time.Hour) } + + // twoGameSession builds a small consecutive doubles session (p0..p2) plus, + // when trailing is true, a non-eventful duplicate of p2 appended 2h later — + // the kind of "still-seen" ping StorePlayer records for an inactive player + // re-viewed more than 1h after their last game. Returns the stat slice, the + // session's first stat, and the session's last eventful stat. + buildSessionStats := func(trailing bool) (stats []domain.PlayerPIT, start, end domain.PlayerPIT) { + b := domaintest.NewPlayerBuilder(uuid). + WithExperience(1000).FromDB(). + Doubles(). + WithGamesPlayed(10).WithWins(5).WithLosses(5). + WithBedsBroken(4).WithBedsLost(3). + WithFinalKills(20).WithFinalDeaths(10). + WithKills(50).WithDeaths(30) + p0 := b.Build(lastQueriedAt.Add(-30 * time.Minute)) + + // doubles +1, won + p1 := b. + WithExperience(1300). + Doubles(). + WithGamesPlayed(11).WithWins(6). + WithBedsBroken(5). + WithFinalKills(24). + WithKills(58).WithDeaths(32).Build(lastQueriedAt.Add(-15 * time.Minute)) + + // doubles +1, lost, final-died, bed-lost — the last eventful stat + p2 := b. + WithExperience(1500). + Doubles(). + WithGamesPlayed(12).WithLosses(6). + WithBedsLost(4). + WithFinalKills(26).WithFinalDeaths(11). + WithKills(62).WithDeaths(36).Build(lastQueriedAt) + + stats = []domain.PlayerPIT{p0, p1, p2} + if trailing { + // Identical stats to p2, but queried 2h later: a trailing duplicate. + p3 := b.Build(lastQueriedAt.Add(2 * time.Hour)) + stats = append(stats, p3) + } + return stats, p0, p2 + } + + wantGames := func(p0, p1, p2 domain.PlayerPIT) []app.GameSegment { + return []app.GameSegment{ + {Start: p0, End: p1, Game: &domain.GameResult{ + Gamemode: domain.GamemodeDoubles, + Outcome: domain.GameOutcomeWin, + FinalKills: 4, + FinalDeath: false, + BedsBroken: 1, + BedLost: false, + Kills: 8, + Deaths: 2, + Experience: 300, + }}, + {Start: p1, End: p2, Game: &domain.GameResult{ + Gamemode: domain.GamemodeDoubles, + Outcome: domain.GameOutcomeLoss, + FinalKills: 2, + FinalDeath: true, + BedsBroken: 0, + BedLost: true, + Kills: 4, + Deaths: 4, + Experience: 200, + }}, + } + } + + t.Run("anchors GetSessionAt at the latest session's end and returns its result", func(t *testing.T) { t.Parallel() - mostRecent := domaintest.NewPlayerBuilder(uuid).FromDB().Build(lastQueriedAt) - repo := &mockMostRecentRepository{pit: &mostRecent} + stats, start, end := buildSessionStats(false) + computeSessions := app.BuildComputeSessions(nowFarFuture) + repo := &mockRecentRepository{pits: stats} - startPIT := domaintest.NewPlayerBuilder(uuid).FromDB().Fours().WithGamesPlayed(10).Build(lastQueriedAt.Add(-time.Hour)) want := app.SessionAtResult{ - Session: &domain.Session{Start: startPIT, End: mostRecent, Consecutive: true}, - Games: []app.GameSegment{{Start: startPIT, End: mostRecent, Game: nil}}, + Session: &domain.Session{Start: start, End: end, Consecutive: true}, + Games: []app.GameSegment{{Start: start, End: end, Game: nil}}, } var gotUUID string @@ -50,125 +127,74 @@ func TestBuildGetLatestSession(t *testing.T) { return want, nil } - getLatestSession := app.BuildGetLatestSession(repo, getSessionAt) + getLatestSession := app.BuildGetLatestSession(repo, computeSessions, getSessionAt) got, err := getLatestSession(t.Context(), uuid) require.NoError(t, err) require.True(t, called) require.Equal(t, uuid, gotUUID) - require.WithinDuration(t, lastQueriedAt, gotAt, 0) + // Anchored at the session's last eventful stat, not some other time. + require.WithinDuration(t, end.QueriedAt, gotAt, 0) + // Discovery scans a bounded, non-trivial number of recent stats. + require.Positive(t, repo.gotLimit) + require.LessOrEqual(t, repo.gotLimit, 1000) require.Equal(t, want, got) }) t.Run("runs the real GetSessionAt pipeline end-to-end", func(t *testing.T) { t.Parallel() - // Wire the real GetSessionAt — real BuildComputeSessions over a - // fixed PIT slice — instead of a mock, so BuildGetLatestSession - // exercises the actual bracket-matching and game derivation against - // ComputeSessions. Mirrors session_at_test.go. - nowFarFuture := func() time.Time { return lastQueriedAt.Add(365 * 24 * time.Hour) } + stats, _, _ := buildSessionStats(false) + p0, p1, p2 := stats[0], stats[1], stats[2] + computeSessions := app.BuildComputeSessions(nowFarFuture) - fixedStats := func(stats []domain.PlayerPIT) app.GetPlayerPITs { - return func(ctx context.Context, _ string, _, _ time.Time) ([]domain.PlayerPIT, error) { - return stats, nil - } + fixedStats := func(ctx context.Context, _ string, _, _ time.Time) ([]domain.PlayerPIT, error) { + return stats, nil } + repo := &mockRecentRepository{pits: stats} + getSessionAt := app.BuildGetSessionAt(fixedStats, computeSessions) - // Three doubles games over four snapshots. The most recent snapshot - // (p3) is what GetMostRecentPlayerPIT returns and where GetSessionAt - // gets anchored, so the computed session must bracket exactly that - // time and end on it. - b := domaintest.NewPlayerBuilder(uuid). - WithExperience(1000).FromDB(). - Doubles(). - WithGamesPlayed(10).WithWins(5).WithLosses(5). - WithBedsBroken(4).WithBedsLost(3). - WithFinalKills(20).WithFinalDeaths(10). - WithKills(50).WithDeaths(30) - p0 := b.Build(lastQueriedAt.Add(-45 * time.Minute)) + getLatestSession := app.BuildGetLatestSession(repo, computeSessions, getSessionAt) - // doubles +1, won - p1 := b. - WithExperience(1300). - Doubles(). - WithGamesPlayed(11).WithWins(6). - WithBedsBroken(5). - WithFinalKills(24). - WithKills(58).WithDeaths(32).Build(lastQueriedAt.Add(-30 * time.Minute)) + got, err := getLatestSession(t.Context(), uuid) + require.NoError(t, err) + require.Equal(t, app.SessionAtResult{ + Session: &domain.Session{Start: p0, End: p2, Consecutive: true}, + Games: wantGames(p0, p1, p2), + }, got) + }) - // doubles +1, lost, final-died, bed-lost - p2 := b. - WithExperience(1500). - Doubles(). - WithGamesPlayed(12).WithLosses(6). - WithBedsLost(4). - WithFinalKills(26).WithFinalDeaths(11). - WithKills(62).WithDeaths(36).Build(lastQueriedAt.Add(-15 * time.Minute)) + t.Run("finds the real session despite a trailing non-eventful duplicate stat", func(t *testing.T) { + t.Parallel() - // doubles +1, won — most recent stat - p3 := b. - WithExperience(1800). - Doubles(). - WithGamesPlayed(13).WithWins(7). - WithBedsBroken(6). - WithFinalKills(30). - WithKills(70).WithDeaths(38).Build(lastQueriedAt) + // The most recent stat (p3) is a duplicate ping queried 2h after the + // last game, so anchoring at it would return "no session". The discovery + // pass must instead anchor at the session's real end (p2). + stats, _, _ := buildSessionStats(true) + p0, p1, p2 := stats[0], stats[1], stats[2] - repo := &mockMostRecentRepository{pit: &p3} - getSessionAt := app.BuildGetSessionAt( - fixedStats([]domain.PlayerPIT{p0, p1, p2, p3}), - computeSessions, - ) + computeSessions := app.BuildComputeSessions(nowFarFuture) + fixedStats := func(ctx context.Context, _ string, _, _ time.Time) ([]domain.PlayerPIT, error) { + return stats, nil + } + repo := &mockRecentRepository{pits: stats} + getSessionAt := app.BuildGetSessionAt(fixedStats, computeSessions) - getLatestSession := app.BuildGetLatestSession(repo, getSessionAt) + getLatestSession := app.BuildGetLatestSession(repo, computeSessions, getSessionAt) got, err := getLatestSession(t.Context(), uuid) require.NoError(t, err) require.Equal(t, app.SessionAtResult{ - Session: &domain.Session{Start: p0, End: p3, Consecutive: true}, - Games: []app.GameSegment{ - {Start: p0, End: p1, Game: &domain.GameResult{ - Gamemode: domain.GamemodeDoubles, - Outcome: domain.GameOutcomeWin, - FinalKills: 4, - FinalDeath: false, - BedsBroken: 1, - BedLost: false, - Kills: 8, - Deaths: 2, - Experience: 300, - }}, - {Start: p1, End: p2, Game: &domain.GameResult{ - Gamemode: domain.GamemodeDoubles, - Outcome: domain.GameOutcomeLoss, - FinalKills: 2, - FinalDeath: true, - BedsBroken: 0, - BedLost: true, - Kills: 4, - Deaths: 4, - Experience: 200, - }}, - {Start: p2, End: p3, Game: &domain.GameResult{ - Gamemode: domain.GamemodeDoubles, - Outcome: domain.GameOutcomeWin, - FinalKills: 4, - FinalDeath: false, - BedsBroken: 1, - BedLost: false, - Kills: 8, - Deaths: 2, - Experience: 300, - }}, - }, + Session: &domain.Session{Start: p0, End: p2, Consecutive: true}, + Games: wantGames(p0, p1, p2), }, got) }) t.Run("returns empty result and skips GetSessionAt when the player has no stats", func(t *testing.T) { t.Parallel() - repo := &mockMostRecentRepository{err: domain.ErrPlayerNotFound} + computeSessions := app.BuildComputeSessions(nowFarFuture) + repo := &mockRecentRepository{pits: []domain.PlayerPIT{}} getSessionAt := func(ctx context.Context, u string, at time.Time) (app.SessionAtResult, error) { t.Helper() @@ -176,7 +202,31 @@ func TestBuildGetLatestSession(t *testing.T) { return app.SessionAtResult{}, nil } - getLatestSession := app.BuildGetLatestSession(repo, getSessionAt) + getLatestSession := app.BuildGetLatestSession(repo, computeSessions, getSessionAt) + + got, err := getLatestSession(t.Context(), uuid) + require.NoError(t, err) + require.Equal(t, app.SessionAtResult{}, got) + }) + + t.Run("returns empty result and skips GetSessionAt when no session is found", func(t *testing.T) { + t.Parallel() + + // Two stats, but they only differ in queried_at (an inactive player + // pinged twice) — no activity, so no session. + p0 := domaintest.NewPlayerBuilder(uuid).FromDB().Fours().WithGamesPlayed(10).Build(lastQueriedAt.Add(-3 * time.Hour)) + p1 := domaintest.NewPlayerBuilder(uuid).FromDB().Fours().WithGamesPlayed(10).Build(lastQueriedAt) + + computeSessions := app.BuildComputeSessions(nowFarFuture) + repo := &mockRecentRepository{pits: []domain.PlayerPIT{p0, p1}} + + getSessionAt := func(ctx context.Context, u string, at time.Time) (app.SessionAtResult, error) { + t.Helper() + t.Fatal("getSessionAt should not be called when no session is found") + return app.SessionAtResult{}, nil + } + + getLatestSession := app.BuildGetLatestSession(repo, computeSessions, getSessionAt) got, err := getLatestSession(t.Context(), uuid) require.NoError(t, err) @@ -186,7 +236,8 @@ func TestBuildGetLatestSession(t *testing.T) { t.Run("propagates repository errors and skips GetSessionAt", func(t *testing.T) { t.Parallel() - repo := &mockMostRecentRepository{err: errors.New("boom")} + computeSessions := app.BuildComputeSessions(nowFarFuture) + repo := &mockRecentRepository{err: errors.New("boom")} getSessionAt := func(ctx context.Context, u string, at time.Time) (app.SessionAtResult, error) { t.Helper() @@ -194,7 +245,7 @@ func TestBuildGetLatestSession(t *testing.T) { return app.SessionAtResult{}, nil } - getLatestSession := app.BuildGetLatestSession(repo, getSessionAt) + getLatestSession := app.BuildGetLatestSession(repo, computeSessions, getSessionAt) _, err := getLatestSession(t.Context(), uuid) require.Error(t, err) @@ -203,7 +254,8 @@ func TestBuildGetLatestSession(t *testing.T) { t.Run("rejects a non-normalized uuid without touching the repository", func(t *testing.T) { t.Parallel() - repo := &mockMostRecentRepository{err: errors.New("repo should not be queried")} + computeSessions := app.BuildComputeSessions(nowFarFuture) + repo := &mockRecentRepository{err: errors.New("repo should not be queried")} getSessionAt := func(ctx context.Context, u string, at time.Time) (app.SessionAtResult, error) { t.Helper() @@ -211,9 +263,10 @@ func TestBuildGetLatestSession(t *testing.T) { return app.SessionAtResult{}, nil } - getLatestSession := app.BuildGetLatestSession(repo, getSessionAt) + getLatestSession := app.BuildGetLatestSession(repo, computeSessions, getSessionAt) _, err := getLatestSession(t.Context(), "not-normalized") require.Error(t, err) + require.False(t, repo.called) }) } diff --git a/main.go b/main.go index 3404898d..24b97562 100644 --- a/main.go +++ b/main.go @@ -202,7 +202,7 @@ func main() { getSessionAt := app.BuildGetSessionAt(getPlayerPITs, computeSessions) - getLatestSession := app.BuildGetLatestSession(playerRepo, getSessionAt) + getLatestSession := app.BuildGetLatestSession(playerRepo, computeSessions, getSessionAt) findMilestoneAchievements := app.BuildFindMilestoneAchievements( playerRepo,