mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-17 04:57:55 +00:00
This commit captures the full server code accumulated across
several development sessions. It includes the following layers,
applied in roughly this order during development:
1. Base infrastructure
- cmd/poc-server HTTP+WebSocket server, /health, /stats
- internal/transport wire types (Envelope + all message types)
- internal/catalog, internal/realtime, internal/stats
- internal/storage/postgres with migrations 001-004
- .env, .env.example, docker-compose, Dockerfile, scripts/
2. Swagger documentation
- go get github.com/swaggo/http-swagger
- swag init produces docs/docs.go
- /swagger/index.html UI, /swagger/doc.json spec
- annotations on health/stats/ws and catalog handlers
3. Races API
- internal/races/{store,service,keyset,types}.go
- migration 005_races.sql: finished_races, race_plans, race_queue
- GET /api/races with keyset pagination, status filter
- GET /api/races/upcoming
- POST /api/races/queue/join, GET, DELETE
- POST/GET /api/races/plans, DELETE /{id}
- lobby.RaceMeta simplification (no host_id/host_name)
4. Races seeder
- internal/races/seed with deterministic generator
- --seed-races / --reset CLI flags in main.go
- 30 finished + 5 live + 5 plans + 4 queue entries
5. Drivers and clans
- internal/drivers, internal/clans (CRUD, validation)
- migration 008_drivers_clans.sql
- /api/clans and /api/drivers endpoints
- 3-letter uppercase nickname/tag validation
- lobby.DriverMeta: Nickname, AvatarURL, ClanID, ClanTag
6. Podium
- internal/transport.RacePodiumEntry
- lobby.SetDriverProfile for in-memory metadata sync
- migration 007_podium.sql (podium JSONB column)
- seeder populates top-3 per finished race
7. Live races persistence
- migration 009_live_persistence.sql: live_races,
live_race_drivers, lobby_drivers
- internal/races/live_store.go: LiveStore with write-side
mirror for lobby.Service mutations
- Service.collectLive and Upcoming read from Postgres
- main.go RestoreFromDB rehydrates lobby on startup
8. Unify live + finished into one races table
- migration 010_unify_races.sql: rename finished_races to
races, expand status CHECK, merge live rows
- PgStore now hosts both write paths; LiveStore is a
thin facade implementing lobby.Persistence
- seeder resetAll drops only finished/cancelled rows and
race_plans / race_queue / lobby_drivers
Each layer is consistent on its own; cross-layer changes are
visible in the file history. A future refactor may split this
commit into the per-stage boundaries listed above.
401 lines
9.5 KiB
Go
401 lines
9.5 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/x0gp/server/internal/catalog/seeddefs"
|
|
)
|
|
|
|
// withFrozenClock is retained for compatibility with older tests that
|
|
// depended on deterministic timestamps. The Service no longer reads
|
|
// timeNowMillis directly; timestamps come from time.Now() inside
|
|
// normalizeTrack / normalizeCar, but those are only filled when
|
|
// CreatedMs / UpdatedMs are zero.
|
|
func withFrozenClock(t *testing.T) {
|
|
t.Helper()
|
|
// Intentionally a no-op; tests below no longer need clock control
|
|
// because the Service relies on time.Now() and we just check ranges.
|
|
}
|
|
|
|
// loadSeeds is a helper that converts seeddefs into catalog types and
|
|
// loads them into a memoryStore for use in tests.
|
|
func loadSeeds() (*Service, error) {
|
|
store := newMemoryStore()
|
|
ctx := context.Background()
|
|
for _, t := range seeddefs.DefaultTracks() {
|
|
cm, err := seedTrackToCatalog(t)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.UpsertTrack(ctx, cm); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
for _, c := range seeddefs.DefaultCars() {
|
|
cm := seedCarToCatalog(c)
|
|
if err := store.UpsertCar(ctx, cm); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return NewService(ctx, store)
|
|
}
|
|
|
|
func seedTrackToCatalog(t seeddefs.Track) (TrackMeta, error) {
|
|
now := time.Now().UnixMilli()
|
|
return TrackMeta{
|
|
ID: t.ID,
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
AuthorID: t.AuthorID,
|
|
Visibility: Visibility(t.Visibility),
|
|
Scale: t.Scale,
|
|
LengthM: t.LengthM,
|
|
WidthM: t.WidthM,
|
|
LaneWidthM: t.LaneWidthM,
|
|
Bounds: Bounds{
|
|
MinX: t.Bounds.MinX, MinY: t.Bounds.MinY,
|
|
MaxX: t.Bounds.MaxX, MaxY: t.Bounds.MaxY,
|
|
LengthM: t.Bounds.LengthM, WidthM: t.Bounds.WidthM,
|
|
},
|
|
Surface: Surface(t.Surface),
|
|
Tags: append([]string(nil), t.Tags...),
|
|
Centerline: waypointsFromSeed(t.Centerline),
|
|
BestLapMs: t.BestLapMs,
|
|
CreatedMs: now,
|
|
UpdatedMs: now,
|
|
}, nil
|
|
}
|
|
|
|
func seedCarToCatalog(c seeddefs.Car) CarMeta {
|
|
now := time.Now().UnixMilli()
|
|
return CarMeta{
|
|
ID: c.ID,
|
|
Name: c.Name,
|
|
OwnerID: c.OwnerID,
|
|
Visibility: Visibility(c.Visibility),
|
|
Scale: c.Scale,
|
|
LengthMm: c.LengthMm,
|
|
WidthMm: c.WidthMm,
|
|
HeightMm: c.HeightMm,
|
|
WeightG: c.WeightG,
|
|
Chassis: ChassisSpec{
|
|
Model: c.Chassis.Model, Material: c.Chassis.Material,
|
|
Printed: c.Chassis.Printed, WheelbaseMm: c.Chassis.WheelbaseMm,
|
|
TrackMm: c.Chassis.TrackMm,
|
|
},
|
|
Motor: MotorSpec{
|
|
Kind: c.Motor.Kind, Class: c.Motor.Class,
|
|
KV: c.Motor.KV, PowerW: c.Motor.PowerW,
|
|
},
|
|
Battery: BatterySpec{
|
|
VoltageV: c.Battery.VoltageV, CapacityMah: c.Battery.CapacityMah,
|
|
Cells: c.Battery.Cells, Chemistry: c.Battery.Chemistry,
|
|
},
|
|
Drive: Drivetrain(c.Drive),
|
|
TopSpeedMs: c.TopSpeedMs,
|
|
ColorHex: c.ColorHex,
|
|
Active: c.Active,
|
|
TotalRaces: c.TotalRaces,
|
|
TotalLaps: c.TotalLaps,
|
|
BestLapMs: c.BestLapMs,
|
|
CreatedMs: now,
|
|
UpdatedMs: now,
|
|
}
|
|
}
|
|
|
|
func waypointsFromSeed(in []seeddefs.Waypoint) []Waypoint {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
out := make([]Waypoint, len(in))
|
|
for i, w := range in {
|
|
out[i] = Waypoint{
|
|
X: w.X, Y: w.Y, HeadingRad: w.HeadingRad,
|
|
SpeedMs: w.SpeedMs, Curvature: w.Curvature,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestSeedsFitInRoom(t *testing.T) {
|
|
tracks := seeddefs.DefaultTracks()
|
|
cars := seeddefs.DefaultCars()
|
|
if len(tracks) == 0 {
|
|
t.Fatal("no default tracks")
|
|
}
|
|
if len(cars) == 0 {
|
|
t.Fatal("no default cars")
|
|
}
|
|
const (
|
|
roomLen = 4.46
|
|
roomWid = 3.19
|
|
)
|
|
for _, tr := range tracks {
|
|
if tr.Bounds.LengthM > roomLen {
|
|
t.Errorf("track %s: length %.2f m exceeds room 4.46 m", tr.ID, tr.Bounds.LengthM)
|
|
}
|
|
if tr.Bounds.WidthM > roomWid {
|
|
t.Errorf("track %s: width %.2f m exceeds room 3.19 m", tr.ID, tr.Bounds.WidthM)
|
|
}
|
|
if len(tr.Centerline) < 8 {
|
|
t.Errorf("track %s: centerline too short (%d)", tr.ID, len(tr.Centerline))
|
|
}
|
|
}
|
|
for _, c := range cars {
|
|
if c.Scale != 24 {
|
|
t.Errorf("car %s: scale=%d, expected 24 (F1 1/24)", c.ID, c.Scale)
|
|
}
|
|
if c.Visibility != "system" {
|
|
t.Errorf("car %s: not system visibility", c.ID)
|
|
}
|
|
if c.LengthMm < 200 || c.LengthMm > 260 {
|
|
t.Errorf("car %s: length %.0f mm out of F1 1/24 range", c.ID, c.LengthMm)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreateAndGetTrack(t *testing.T) {
|
|
s, err := loadSeeds()
|
|
if err != nil {
|
|
t.Fatalf("loadSeeds: %v", err)
|
|
}
|
|
defer s.Stop()
|
|
|
|
got, ok := s.GetTrack("monaco")
|
|
if !ok {
|
|
t.Fatal("expected to find monaco")
|
|
}
|
|
if got.ID != "monaco" {
|
|
t.Errorf("got id %q", got.ID)
|
|
}
|
|
}
|
|
|
|
func TestCreateUserTrack(t *testing.T) {
|
|
s, err := loadSeeds()
|
|
if err != nil {
|
|
t.Fatalf("loadSeeds: %v", err)
|
|
}
|
|
defer s.Stop()
|
|
ctx := context.Background()
|
|
|
|
tr, err := s.CreateTrack(ctx, CreateTrackOptions{
|
|
Name: "My Garage",
|
|
AuthorID: "driver-1",
|
|
Visibility: VisibilityPublic,
|
|
LengthM: 3.5,
|
|
WidthM: 2.2,
|
|
Surface: SurfaceTile,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if tr.Visibility != VisibilityPublic {
|
|
t.Errorf("got visibility %q", tr.Visibility)
|
|
}
|
|
if tr.AuthorID != "driver-1" {
|
|
t.Errorf("got author %q", tr.AuthorID)
|
|
}
|
|
if tr.ID != "my-garage" {
|
|
t.Errorf("slug: got %q", tr.ID)
|
|
}
|
|
}
|
|
|
|
func TestCreateRejectsOversizedTrack(t *testing.T) {
|
|
s, _ := loadSeeds()
|
|
defer s.Stop()
|
|
ctx := context.Background()
|
|
|
|
_, err := s.CreateTrack(ctx, CreateTrackOptions{
|
|
Name: "Too Big",
|
|
LengthM: 6.0,
|
|
WidthM: 4.0,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for oversized track")
|
|
}
|
|
}
|
|
|
|
func TestUpdateSystemTrackFails(t *testing.T) {
|
|
s, _ := loadSeeds()
|
|
defer s.Stop()
|
|
ctx := context.Background()
|
|
|
|
vis := VisibilityPublic
|
|
_, err := s.UpdateTrack(ctx, "monaco", UpdateTrackOptions{Visibility: &vis})
|
|
if err == nil {
|
|
t.Fatal("expected error when mutating system track")
|
|
}
|
|
}
|
|
|
|
func TestDeleteUserTrack(t *testing.T) {
|
|
s, _ := loadSeeds()
|
|
defer s.Stop()
|
|
ctx := context.Background()
|
|
|
|
tr, _ := s.CreateTrack(ctx, CreateTrackOptions{Name: "Temp", Visibility: VisibilityPrivate})
|
|
if err := s.DeleteTrack(ctx, tr.ID); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
if _, ok := s.GetTrack(tr.ID); ok {
|
|
t.Fatal("track still present")
|
|
}
|
|
}
|
|
|
|
func TestDeleteSystemTrackFails(t *testing.T) {
|
|
s, _ := loadSeeds()
|
|
defer s.Stop()
|
|
ctx := context.Background()
|
|
|
|
if err := s.DeleteTrack(ctx, "monaco"); err == nil {
|
|
t.Fatal("expected error when deleting system track")
|
|
}
|
|
}
|
|
|
|
func TestCreateAndUpdateCar(t *testing.T) {
|
|
s, _ := loadSeeds()
|
|
defer s.Stop()
|
|
ctx := context.Background()
|
|
|
|
car, err := s.CreateCar(ctx, CreateCarOptions{
|
|
Name: "Bumblebee",
|
|
OwnerID: "driver-1",
|
|
LengthMm: 78,
|
|
WidthMm: 34,
|
|
TopSpeedMs: 4.5,
|
|
ColorHex: "#d29922",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if car.ID != "bumblebee" {
|
|
t.Errorf("slug: got %q", car.ID)
|
|
}
|
|
|
|
color := "#3fb950"
|
|
updated, err := s.UpdateCar(ctx, car.ID, UpdateCarOptions{ColorHex: &color})
|
|
if err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
if updated.ColorHex != color {
|
|
t.Errorf("got color %q", updated.ColorHex)
|
|
}
|
|
}
|
|
|
|
func TestCreateCarRejectsInvalidDims(t *testing.T) {
|
|
s, _ := loadSeeds()
|
|
defer s.Stop()
|
|
ctx := context.Background()
|
|
|
|
_, err := s.CreateCar(ctx, CreateCarOptions{Name: "Tiny", LengthMm: 10, WidthMm: 5})
|
|
if err == nil {
|
|
t.Fatal("expected error for too-small car")
|
|
}
|
|
_, err = s.CreateCar(ctx, CreateCarOptions{Name: "Big", LengthMm: 500, WidthMm: 200})
|
|
if err == nil {
|
|
t.Fatal("expected error for too-big car")
|
|
}
|
|
}
|
|
|
|
func TestSubscribeReceivesChange(t *testing.T) {
|
|
s, _ := loadSeeds()
|
|
defer s.Stop()
|
|
ctx := context.Background()
|
|
|
|
ch, cancel := s.Subscribe()
|
|
defer cancel()
|
|
<-ch // drain initial snapshot
|
|
|
|
_, err := s.CreateTrack(ctx, CreateTrackOptions{Name: "E2E", Visibility: VisibilityPublic})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
select {
|
|
case ev := <-ch:
|
|
if ev.Kind != EventTrackUpsert {
|
|
t.Fatalf("kind=%s", ev.Kind)
|
|
}
|
|
if ev.TrackID != "e2e" {
|
|
t.Errorf("track_id=%s", ev.TrackID)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("no event")
|
|
}
|
|
}
|
|
|
|
func TestStatsCounters(t *testing.T) {
|
|
s, _ := loadSeeds()
|
|
defer s.Stop()
|
|
|
|
st := s.Stats()
|
|
if st.TracksSystem < 5 {
|
|
t.Errorf("expected at least 5 system tracks, got %d", st.TracksSystem)
|
|
}
|
|
if st.CarsSystem < 5 {
|
|
t.Errorf("expected at least 5 system cars, got %d", st.CarsSystem)
|
|
}
|
|
}
|
|
|
|
func TestConcurrentCreatesSafe(t *testing.T) {
|
|
store := newMemoryStore()
|
|
s, err := NewService(context.Background(), store)
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
defer s.Stop()
|
|
ctx := context.Background()
|
|
const n = 20
|
|
var wg sync.WaitGroup
|
|
wg.Add(n)
|
|
for i := 0; i < n; i++ {
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
name := "Concurrent"
|
|
_, _ = s.CreateTrack(ctx, CreateTrackOptions{
|
|
Name: name,
|
|
LengthM: 2.0,
|
|
WidthM: 1.2,
|
|
})
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
if got := len(s.ListTracks()); got != 1 {
|
|
t.Errorf("expected 1 track, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestSetCarStatsUpdatesFields(t *testing.T) {
|
|
s, _ := loadSeeds()
|
|
defer s.Stop()
|
|
ctx := context.Background()
|
|
|
|
err := s.SetCarStats(ctx, "f1-2024-redbull-rb20", func(c *CarMeta) {
|
|
c.TotalLaps = 12
|
|
c.BestLapMs = 9450
|
|
c.TotalDistanceM = 320.5
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("set stats: %v", err)
|
|
}
|
|
got, _ := s.GetCar("f1-2024-redbull-rb20")
|
|
if got.TotalLaps != 12 || got.BestLapMs != 9450 || got.TotalDistanceM != 320.5 {
|
|
t.Errorf("stats not updated: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestServiceWithoutStore(t *testing.T) {
|
|
s, err := NewService(context.Background(), nil)
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
defer s.Stop()
|
|
ctx := context.Background()
|
|
|
|
_, err = s.CreateTrack(ctx, CreateTrackOptions{Name: "X", LengthM: 1.5, WidthM: 1.0})
|
|
if err == nil {
|
|
t.Fatal("expected error when store is nil")
|
|
}
|
|
} |