mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
feat(server): initial implementation
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.
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user