diff --git a/internal/adapters/playerrepository/interface.go b/internal/adapters/playerrepository/interface.go index 2e610597..a45811a3 100644 --- a/internal/adapters/playerrepository/interface.go +++ b/internal/adapters/playerrepository/interface.go @@ -14,5 +14,13 @@ 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) + // 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 f9719a8b..5b96fbf5 100644 --- a/internal/adapters/playerrepository/repository.go +++ b/internal/adapters/playerrepository/repository.go @@ -608,6 +608,149 @@ 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) 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() @@ -785,6 +928,14 @@ 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) 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 c50b226b..faa3e649 100644 --- a/internal/adapters/playerrepository/repository_test.go +++ b/internal/adapters/playerrepository/repository_test.go @@ -617,6 +617,172 @@ 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("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 new file mode 100644 index 00000000..f0096af8 --- /dev/null +++ b/internal/app/latest_session.go @@ -0,0 +1,103 @@ +package app + +import ( + "context" + "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 { + GetRecentPlayerPITs(ctx context.Context, playerUUID string, limit int) ([]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 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 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) { + if !strutils.UUIDIsNormalized(uuid) { + err := fmt.Errorf("UUID is not normalized") + reporting.Report(ctx, err) + return SessionAtResult{}, err + } + + // 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 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 + } + + // 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 new file mode 100644 index 00000000..3533f0bb --- /dev/null +++ b/internal/app/latest_session_test.go @@ -0,0 +1,272 @@ +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 mockRecentRepository struct { + pits []domain.PlayerPIT + err error + gotLimit int + called bool +} + +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) { + t.Parallel() + + uuid := "01234567-89ab-cdef-0123-456789abcdef" + lastQueriedAt := time.Date(2024, 6, 15, 20, 30, 0, 0, time.UTC) + + // 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() + + stats, start, end := buildSessionStats(false) + computeSessions := app.BuildComputeSessions(nowFarFuture) + repo := &mockRecentRepository{pits: stats} + + want := app.SessionAtResult{ + Session: &domain.Session{Start: start, End: end, Consecutive: true}, + Games: []app.GameSegment{{Start: start, End: end, 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, computeSessions, getSessionAt) + + got, err := getLatestSession(t.Context(), uuid) + require.NoError(t, err) + require.True(t, called) + require.Equal(t, uuid, gotUUID) + // 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() + + stats, _, _ := buildSessionStats(false) + p0, p1, p2 := stats[0], stats[1], stats[2] + + 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, computeSessions, getSessionAt) + + 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) + }) + + t.Run("finds the real session despite a trailing non-eventful duplicate stat", func(t *testing.T) { + t.Parallel() + + // 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] + + 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, computeSessions, getSessionAt) + + 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) + }) + + t.Run("returns empty result and skips GetSessionAt when the player has no stats", func(t *testing.T) { + t.Parallel() + + computeSessions := app.BuildComputeSessions(nowFarFuture) + repo := &mockRecentRepository{pits: []domain.PlayerPIT{}} + + 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, 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) + require.Equal(t, app.SessionAtResult{}, got) + }) + + t.Run("propagates repository errors and skips GetSessionAt", func(t *testing.T) { + t.Parallel() + + 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() + t.Fatal("getSessionAt should not be called when the repository fails") + return app.SessionAtResult{}, nil + } + + getLatestSession := app.BuildGetLatestSession(repo, computeSessions, 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() + + 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() + t.Fatal("getSessionAt should not be called for an invalid uuid") + return app.SessionAtResult{}, nil + } + + getLatestSession := app.BuildGetLatestSession(repo, computeSessions, getSessionAt) + + _, err := getLatestSession(t.Context(), "not-normalized") + require.Error(t, err) + require.False(t, repo.called) + }) +} 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..24b97562 100644 --- a/main.go +++ b/main.go @@ -202,6 +202,8 @@ func main() { getSessionAt := app.BuildGetSessionAt(getPlayerPITs, computeSessions) + getLatestSession := app.BuildGetLatestSession(playerRepo, computeSessions, 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),