Add driver profile stats API, nickname update editing, and security checks

This commit is contained in:
2026-07-17 23:18:16 +04:00
parent a5d2ee32c6
commit 5d2e0d59df
14 changed files with 882 additions and 403 deletions
+124
View File
@@ -393,3 +393,127 @@ func TestGetRaceAndLastFinished(t *testing.T) {
}
}
func TestDriverProfileStats(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL is empty; skipping integration test")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("failed to open pool: %v", err)
}
defer pool.Close()
store := NewPgStore(pool)
// Clean up any test records
_, _ = store.Exec(ctx, `DELETE FROM race_queue WHERE driver_id = 'test-stats-drv'`)
_, _ = store.Exec(ctx, `DELETE FROM race_drivers WHERE driver_id = 'test-stats-drv'`)
_, _ = store.Exec(ctx, `DELETE FROM races WHERE id LIKE 'test-stats-race-%'`)
_, _ = store.Exec(ctx, `DELETE FROM drivers WHERE id = 'test-stats-drv'`)
// 1. Create a driver
_, _ = store.Exec(ctx, `
INSERT INTO drivers (id, nickname, name, avatar_url, created_ms, updated_ms)
VALUES ('test-stats-drv', 'TSD', 'Test Stats Driver', '', 1700000000, 1700000000)`)
// 2. Insert races and race results
// Race 1: finished, TSD took 2nd position, best lap 15000, total time 50000
race1 := FinishedRace{
ID: "test-stats-race-1",
Name: "Race 1",
TrackID: "monaco",
MaxCars: 4,
Laps: 5,
TimeLimitS: 300,
DriverIDs: []string{"test-stats-drv"},
Status: "finished",
CreatedMs: 1700000000,
StartedMs: 1700000500,
FinishedMs: 1700060000,
DurationMs: 59500,
TotalLaps: 5,
TotalDrivers: 2,
Results: []DriverResult{
{
DriverID: "test-stats-drv",
TotalTimeMs: 50000,
BestLapMs: 15000,
Position: ptrInt(2),
},
},
}
if err := store.InsertFinished(ctx, race1); err != nil {
t.Fatalf("InsertFinished 1 failed: %v", err)
}
// Race 2: finished, TSD took 1st position (Win), best lap 14000 (Best Lap), total time 45000
race2 := FinishedRace{
ID: "test-stats-race-2",
Name: "Race 2",
TrackID: "monaco",
MaxCars: 4,
Laps: 5,
TimeLimitS: 300,
DriverIDs: []string{"test-stats-drv"},
Status: "finished",
CreatedMs: 1700100000,
StartedMs: 1700100500,
FinishedMs: 1700160000,
DurationMs: 59500,
TotalLaps: 5,
TotalDrivers: 2,
Results: []DriverResult{
{
DriverID: "test-stats-drv",
TotalTimeMs: 45000,
BestLapMs: 14000,
Position: ptrInt(1),
},
},
}
if err := store.InsertFinished(ctx, race2); err != nil {
t.Fatalf("InsertFinished 2 failed: %v", err)
}
// 3. Query stats
stats, err := store.GetDriverStats(ctx, "test-stats-drv")
if err != nil {
t.Fatalf("GetDriverStats failed: %v", err)
}
if stats.TotalRacesStarted != 2 || stats.TotalRacesFinished != 2 {
t.Errorf("expected 2 races started/finished, got: %+v", stats)
}
if stats.Wins != 1 || stats.Podiums != 2 {
t.Errorf("expected 1 win, 2 podiums, got: %+v", stats)
}
if stats.BestLapMs != 14000 {
t.Errorf("expected best lap 14000, got %d", stats.BestLapMs)
}
if stats.TotalPlaytimeMs != 95000 {
t.Errorf("expected total playtime 95000, got %d", stats.TotalPlaytimeMs)
}
// 4. Query last race (should be Race 2, finished at 1700160000)
last, err := store.GetDriverLastRace(ctx, "test-stats-drv")
if err != nil {
t.Fatalf("GetDriverLastRace failed: %v", err)
}
if last == nil || last.RaceID != "test-stats-race-2" {
t.Errorf("expected last race test-stats-race-2, got: %+v", last)
}
// 5. Query best race (should be Race 2, because position 1 is better than 2)
best, err := store.GetDriverBestRace(ctx, "test-stats-drv")
if err != nil {
t.Fatalf("GetDriverBestRace failed: %v", err)
}
if best == nil || best.RaceID != "test-stats-race-2" {
t.Errorf("expected best race test-stats-race-2, got: %+v", best)
}
}
func ptrInt(v int) *int { return &v }
+17
View File
@@ -837,6 +837,23 @@ func (s *Service) GetLastFinished(ctx context.Context) (FinishedRace, error) {
return s.pg.GetLastFinished(ctx)
}
// GetDriverProfileStats returns the driver stats and summaries of their last and best races.
func (s *Service) GetDriverProfileStats(ctx context.Context, driverID string) (DriverStats, *DriverRaceSummary, *DriverRaceSummary, error) {
stats, err := s.pg.GetDriverStats(ctx, driverID)
if err != nil {
return DriverStats{}, nil, nil, err
}
last, err := s.pg.GetDriverLastRace(ctx, driverID)
if err != nil {
return DriverStats{}, nil, nil, err
}
best, err := s.pg.GetDriverBestRace(ctx, driverID)
if err != nil {
return DriverStats{}, nil, nil, err
}
return stats, last, best, nil
}
// ---------------------------------------------------------------------------
// Scheduler
// ---------------------------------------------------------------------------
+129
View File
@@ -1046,3 +1046,132 @@ func (f FinishedRace) ToLobbyMeta() lobby.RaceMeta {
StartedMs: f.StartedMs,
}
}
// DriverStats holds database-calculated stats.
type DriverStats struct {
TotalRacesStarted int64
TotalRacesFinished int64
Wins int64
Podiums int64
BestLapMs int64
TotalPlaytimeMs int64
}
// DriverRaceSummary holds a summary of a single finished race.
type DriverRaceSummary struct {
RaceID string
RaceName string
FinishedMs int64
Position int
TotalTimeMs int64
BestLapMs int64
TrackID string
TrackName string
}
// GetDriverStats aggregates statistics for a given driver from finished races.
func (s *PgStore) GetDriverStats(ctx context.Context, driverID string) (DriverStats, error) {
row := s.pool.QueryRow(ctx, `
SELECT
COUNT(*)::bigint as total_races_started,
COUNT(CASE WHEN rd.position IS NOT NULL OR rd.total_time_ms > 0 THEN 1 END)::bigint as total_races_finished,
COUNT(CASE WHEN rd.position = 1 THEN 1 END)::bigint as wins,
COUNT(CASE WHEN rd.position >= 1 AND rd.position <= 3 THEN 1 END)::bigint as podiums,
COALESCE(MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms END), 0)::bigint as best_lap_ms,
COALESCE(SUM(rd.total_time_ms), 0)::bigint as total_playtime_ms
FROM race_drivers rd
JOIN races r ON rd.race_id = r.id
WHERE rd.driver_id = $1 AND r.status = 'finished'`, driverID)
var stats DriverStats
err := row.Scan(
&stats.TotalRacesStarted,
&stats.TotalRacesFinished,
&stats.Wins,
&stats.Podiums,
&stats.BestLapMs,
&stats.TotalPlaytimeMs,
)
if err != nil {
return DriverStats{}, fmt.Errorf("query driver stats: %w", err)
}
return stats, nil
}
// GetDriverLastRace returns the last completed race for a driver.
func (s *PgStore) GetDriverLastRace(ctx context.Context, driverID string) (*DriverRaceSummary, error) {
row := s.pool.QueryRow(ctx, `
SELECT
rd.race_id,
r.name,
r.finished_ms,
COALESCE(rd.position, 0) as position,
rd.total_time_ms,
rd.best_lap_ms,
r.track_id,
COALESCE(t.name, '') as track_name
FROM race_drivers rd
JOIN races r ON rd.race_id = r.id
LEFT JOIN tracks t ON r.track_id = t.id
WHERE rd.driver_id = $1 AND r.status = 'finished'
ORDER BY r.finished_ms DESC, r.id DESC
LIMIT 1`, driverID)
var sum DriverRaceSummary
err := row.Scan(
&sum.RaceID,
&sum.RaceName,
&sum.FinishedMs,
&sum.Position,
&sum.TotalTimeMs,
&sum.BestLapMs,
&sum.TrackID,
&sum.TrackName,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("query driver last race: %w", err)
}
return &sum, nil
}
// GetDriverBestRace returns the best completed race for a driver (by position, then time).
func (s *PgStore) GetDriverBestRace(ctx context.Context, driverID string) (*DriverRaceSummary, error) {
row := s.pool.QueryRow(ctx, `
SELECT
rd.race_id,
r.name,
r.finished_ms,
COALESCE(rd.position, 0) as position,
rd.total_time_ms,
rd.best_lap_ms,
r.track_id,
COALESCE(t.name, '') as track_name
FROM race_drivers rd
JOIN races r ON rd.race_id = r.id
LEFT JOIN tracks t ON r.track_id = t.id
WHERE rd.driver_id = $1 AND r.status = 'finished' AND rd.position IS NOT NULL AND rd.position > 0
ORDER BY rd.position ASC, rd.total_time_ms ASC, r.finished_ms DESC, r.id DESC
LIMIT 1`, driverID)
var sum DriverRaceSummary
err := row.Scan(
&sum.RaceID,
&sum.RaceName,
&sum.FinishedMs,
&sum.Position,
&sum.TotalTimeMs,
&sum.BestLapMs,
&sum.TrackID,
&sum.TrackName,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("query driver best race: %w", err)
}
return &sum, nil
}