mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-19 05:47:02 +00:00
Add driver profile stats API, nickname update editing, and security checks
This commit is contained in:
@@ -78,6 +78,7 @@ func (s *Service) List(ctx context.Context, limit, offset int, clanID string) ([
|
||||
|
||||
// UpdateInput is the patch payload.
|
||||
type UpdateInput struct {
|
||||
Nickname string
|
||||
Name string
|
||||
AvatarURL string
|
||||
ClanID *string // nil = leave unchanged, &"" = clear, &"..." = set
|
||||
@@ -89,6 +90,13 @@ func (s *Service) Update(ctx context.Context, id string, in UpdateInput) (Driver
|
||||
if err != nil {
|
||||
return Driver{}, err
|
||||
}
|
||||
if in.Nickname != "" {
|
||||
nick := strings.ToUpper(strings.TrimSpace(in.Nickname))
|
||||
if !IsValidNickname(nick) {
|
||||
return Driver{}, fmt.Errorf("%w: nickname must be 3 uppercase ASCII letters", ErrInvalidInput)
|
||||
}
|
||||
cur.Nickname = nick
|
||||
}
|
||||
if in.Name != "" {
|
||||
cur.Name = in.Name
|
||||
}
|
||||
@@ -99,6 +107,10 @@ func (s *Service) Update(ctx context.Context, id string, in UpdateInput) (Driver
|
||||
cur.ClanID = *in.ClanID
|
||||
}
|
||||
if err := s.pg.Update(ctx, cur); err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return Driver{}, fmt.Errorf("%w: %s", ErrAlreadyExists, cur.Nickname)
|
||||
}
|
||||
return Driver{}, err
|
||||
}
|
||||
return s.pg.Get(ctx, id)
|
||||
|
||||
@@ -70,6 +70,11 @@ func (m *memoryStore) Update(ctx context.Context, d Driver) error {
|
||||
if _, ok := m.drivers[d.ID]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
for id, existing := range m.drivers {
|
||||
if id != d.ID && existing.Nickname == d.Nickname {
|
||||
return &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint"}
|
||||
}
|
||||
}
|
||||
m.drivers[d.ID] = d
|
||||
return nil
|
||||
}
|
||||
@@ -141,9 +146,10 @@ func TestDriversService(t *testing.T) {
|
||||
t.Errorf("Expected driver-1, got %s", gotByNick.ID)
|
||||
}
|
||||
|
||||
// 7. Update driver
|
||||
// 7. Update driver (including nickname)
|
||||
clanID := "clan-2"
|
||||
updated, err := svc.Update(ctx, "driver-1", UpdateInput{
|
||||
Nickname: "VET",
|
||||
Name: "Lewis Hamilton MBE",
|
||||
AvatarURL: "https://example.com/ham-mbe.png",
|
||||
ClanID: &clanID,
|
||||
@@ -151,10 +157,32 @@ func TestDriversService(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
if updated.Name != "Lewis Hamilton MBE" || updated.AvatarURL != "https://example.com/ham-mbe.png" || updated.ClanID != "clan-2" {
|
||||
if updated.Nickname != "VET" || updated.Name != "Lewis Hamilton MBE" || updated.AvatarURL != "https://example.com/ham-mbe.png" || updated.ClanID != "clan-2" {
|
||||
t.Errorf("Unexpected updated driver fields: %+v", updated)
|
||||
}
|
||||
|
||||
// 7a. Update with invalid nickname
|
||||
_, err = svc.Update(ctx, "driver-1", UpdateInput{Nickname: "TOOLONG"})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("Expected ErrInvalidInput for invalid nickname update, got: %v", err)
|
||||
}
|
||||
|
||||
// 7b. Create driver-2 to test duplicate nickname conflict
|
||||
_, err = svc.Create(ctx, CreateInput{
|
||||
ID: "driver-2",
|
||||
Nickname: "HAM",
|
||||
Name: "Lewis",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create driver-2 failed: %v", err)
|
||||
}
|
||||
|
||||
// 7c. Update driver-2 to nickname "VET" (should fail, already taken by driver-1)
|
||||
_, err = svc.Update(ctx, "driver-2", UpdateInput{Nickname: "VET"})
|
||||
if !errors.Is(err, ErrAlreadyExists) {
|
||||
t.Errorf("Expected ErrAlreadyExists for duplicate nickname update, got: %v", err)
|
||||
}
|
||||
|
||||
// 8. Delete driver
|
||||
err = svc.Delete(ctx, "driver-1")
|
||||
if err != nil {
|
||||
|
||||
@@ -165,9 +165,9 @@ func (s *PgStore) Update(ctx context.Context, d Driver) error {
|
||||
d.UpdatedMs = time.Now().UnixMilli()
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE drivers
|
||||
SET name = $2, avatar_url = $3, clan_id = $4, updated_ms = $5
|
||||
SET nickname = $2, name = $3, avatar_url = $4, clan_id = $5, updated_ms = $6
|
||||
WHERE id = $1`,
|
||||
d.ID, d.Name, d.AvatarURL, nullableClan(d.ClanID), d.UpdatedMs)
|
||||
d.ID, d.Nickname, d.Name, d.AvatarURL, nullableClan(d.ClanID), d.UpdatedMs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -674,11 +674,42 @@ type DriverCreateRequest struct {
|
||||
|
||||
// DriverUpdateRequest is the body of PUT /api/drivers/{id}.
|
||||
type DriverUpdateRequest struct {
|
||||
Name string `json:"name,omitempty" example:"Alice"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/u/ace.png"`
|
||||
Nickname string `json:"nickname,omitempty" example:"ALI"`
|
||||
Name string `json:"name,omitempty" example:"Alice"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" example:"https://cdn.example.com/u/ace.png"`
|
||||
ClanID *string `json:"clan_id,omitempty" example:"clan-1782000-1"`
|
||||
}
|
||||
|
||||
// DriverProfileResponse is the body returned by GET /api/drivers/{id}/profile.
|
||||
type DriverProfileResponse struct {
|
||||
Driver Driver `json:"driver"`
|
||||
Stats DriverStats `json:"stats"`
|
||||
LastRace *DriverRaceSummary `json:"last_race"`
|
||||
BestRace *DriverRaceSummary `json:"best_race"`
|
||||
}
|
||||
|
||||
// DriverStats contains aggregate driver statistics.
|
||||
type DriverStats struct {
|
||||
TotalRacesStarted int64 `json:"total_races_started" example:"10"`
|
||||
TotalRacesFinished int64 `json:"total_races_finished" example:"8"`
|
||||
Wins int64 `json:"wins" example:"2"`
|
||||
Podiums int64 `json:"podiums" example:"5"`
|
||||
BestLapMs int64 `json:"best_lap_ms" example:"12345"`
|
||||
TotalPlaytimeMs int64 `json:"total_playtime_ms" example:"1200000"`
|
||||
}
|
||||
|
||||
// DriverRaceSummary describes one race result in a driver's profile.
|
||||
type DriverRaceSummary struct {
|
||||
RaceID string `json:"race_id" example:"race-123"`
|
||||
RaceName string `json:"race_name" example:"Monaco Grand Prix"`
|
||||
FinishedMs int64 `json:"finished_ms" example:"1700000000000"`
|
||||
Position int `json:"position" example:"1"`
|
||||
TotalTimeMs int64 `json:"total_time_ms" example:"123456"`
|
||||
BestLapMs int64 `json:"best_lap_ms" example:"12345"`
|
||||
TrackID string `json:"track_id" example:"monaco"`
|
||||
TrackName string `json:"track_name" example:"Monaco"`
|
||||
}
|
||||
|
||||
// DriverListResponse is the body returned by GET /api/drivers.
|
||||
type DriverListResponse struct {
|
||||
Items []Driver `json:"items"`
|
||||
|
||||
Reference in New Issue
Block a user