Files
box-box/internal/web/racehub_test.go
AmanTahiliani 7212eb9b44 feat: detect race replay chapters
Add deterministic server-side chapter detection for starts, flag periods, pit phases, decisive top-five swings, and finishes, then expose chapters on the race-hub payload.

Spike result: race-hub already loads race control, positions, and laps in one read model. The requested Detect signature does not include pit stops, so pit phases use IsPitOutLap clusters as the local deterministic pit-stop proxy.
2026-07-11 18:04:44 -04:00

366 lines
10 KiB
Go

package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/query"
"github.com/AmanTahiliani/box-box/internal/store"
)
func testServer(t *testing.T, st *store.Store) *Server {
t.Helper()
client := api.NewOpenF1Client("https://api.openf1.org", 15*time.Second)
t.Cleanup(func() { _ = client.Close() })
return NewServer(client, 8080, st)
}
func openTestStore(t *testing.T) *store.Store {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
st, err := store.Open(path)
if err != nil {
t.Fatalf("store.Open() error = %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func seedRaceHubStore(t *testing.T, st *store.Store) {
t.Helper()
meetingKey := 1229
sessionKey := 9472
if err := st.UpsertMeeting(store.Meeting{
MeetingKey: meetingKey,
MeetingName: "Monaco",
Year: 2025,
}); err != nil {
t.Fatalf("UpsertMeeting() error = %v", err)
}
if err := st.UpsertSession(store.Session{
SessionKey: sessionKey,
MeetingKey: meetingKey,
SessionName: "Race",
SessionType: "Race",
}); err != nil {
t.Fatalf("UpsertSession() error = %v", err)
}
if err := st.UpsertDriver(store.Driver{
DriverNumber: 1,
FullName: "Max Verstappen",
NameAcronym: "VER",
}); err != nil {
t.Fatalf("UpsertDriver() error = %v", err)
}
if err := st.UpsertSessionDriver(store.SessionDriver{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
}); err != nil {
t.Fatalf("UpsertSessionDriver() error = %v", err)
}
}
func TestHandleRaceHubWithoutStore(t *testing.T) {
srv := testServer(t, nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil)
rec := httptest.NewRecorder()
srv.handleRaceHub(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var hub query.RaceHub
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
t.Fatalf("decode response: %v", err)
}
if hub.Source != query.ResponseSourceNone {
t.Fatalf("source = %q, want %q", hub.Source, query.ResponseSourceNone)
}
if hub.Datasets["session"].Status != query.DatasetStatusMissing {
t.Fatalf("session dataset = %+v, want missing", hub.Datasets["session"])
}
}
func TestHandleRaceHubWithLocalData(t *testing.T) {
st := openTestStore(t)
seedRaceHubStore(t, st)
srv := testServer(t, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil)
rec := httptest.NewRecorder()
srv.handleRaceHub(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var hub query.RaceHub
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
t.Fatalf("decode response: %v", err)
}
if hub.Session == nil || hub.Meeting == nil {
t.Fatal("expected meeting and session in response")
}
if hub.Datasets["drivers"].Status != query.DatasetStatusAvailable {
t.Fatalf("drivers dataset = %+v, want available", hub.Datasets["drivers"])
}
if hub.Datasets["results"].Status != query.DatasetStatusMissing {
t.Fatalf("results dataset = %+v, want missing", hub.Datasets["results"])
}
}
func TestHandleRaceHubIncludesChapters(t *testing.T) {
st := openTestStore(t)
seedRaceHubStore(t, st)
sessionKey := 9472
meetingKey := 1229
for lap := 1; lap <= 12; lap++ {
if err := st.UpsertLap(store.Lap{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
LapNumber: lap,
DateStart: time.Date(2025, 5, 25, 13, lap-1, 0, 0, time.UTC).Format(time.RFC3339),
LapDuration: 75,
}); err != nil {
t.Fatalf("UpsertLap(%d) error = %v", lap, err)
}
}
for _, sample := range []store.PositionSample{
{SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 1},
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 4},
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 3},
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 3},
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 4},
} {
if err := st.UpsertPositionSample(sample); err != nil {
t.Fatalf("UpsertPositionSample() error = %v", err)
}
}
srv := testServer(t, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil)
rec := httptest.NewRecorder()
srv.handleRaceHub(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var hub query.RaceHub
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(hub.Chapters) == 0 {
t.Fatalf("chapters = %+v, want generated chapters", hub.Chapters)
}
if hub.Chapters[0].Kind != "start" {
t.Fatalf("first chapter = %+v, want start", hub.Chapters[0])
}
foundSwing := false
for _, chapter := range hub.Chapters {
if chapter.Kind == "decisive_swing" {
foundSwing = true
}
}
if !foundSwing {
t.Fatalf("chapters = %+v, want decisive_swing", hub.Chapters)
}
}
func TestHandleMeetingsSourceLocal(t *testing.T) {
st := openTestStore(t)
seedRaceHubStore(t, st)
srv := testServer(t, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/meetings?year=2025&source=local", nil)
rec := httptest.NewRecorder()
srv.handleMeetings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var meetings []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &meetings); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(meetings) != 1 {
t.Fatalf("meetings len = %d, want 1", len(meetings))
}
}
func TestHandleRaceHubRequiresSessionKey(t *testing.T) {
srv := testServer(t, nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub", nil)
rec := httptest.NewRecorder()
srv.handleRaceHub(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestBuildDriverMapFirstKeepsFirstDuplicateDriverNumber(t *testing.T) {
drivers := []models.Driver{
{DriverNumber: 4, NameAcronym: "NOR", TeamName: "McLaren"},
{DriverNumber: 4, NameAcronym: "NOR", TeamName: "Red Bull Racing"},
}
driverMap := buildDriverMapFirst(drivers)
driver := driverMap[4]
if driver.TeamName != "McLaren" {
t.Fatalf("team = %q, want McLaren", driver.TeamName)
}
}
func TestHandleSeasonsEmpty(t *testing.T) {
srv := testServer(t, nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/seasons", nil)
rec := httptest.NewRecorder()
srv.handleSeasons(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var years []int
if err := json.Unmarshal(rec.Body.Bytes(), &years); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(years) != 0 {
t.Fatalf("years = %v, want empty", years)
}
}
func TestHandleSeasonsWithData(t *testing.T) {
st := openTestStore(t)
seedRaceHubStore(t, st)
srv := testServer(t, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/seasons", nil)
rec := httptest.NewRecorder()
srv.handleSeasons(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var years []int
if err := json.Unmarshal(rec.Body.Bytes(), &years); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(years) != 1 || years[0] != 2025 {
t.Fatalf("years = %v, want [2025]", years)
}
}
func TestHandleNewsWithLocalData(t *testing.T) {
st := openTestStore(t)
now := time.Unix(1800000000, 0).UTC()
published := now.Add(-30 * time.Minute)
if err := st.UpsertNewsSource(store.NewsSource{
Source: "racefans-f1",
Name: "RaceFans F1",
FeedURL: "https://www.racefans.net/category/f1-news/feed/",
Category: "news",
Enabled: true,
UpdatedAt: now,
}); err != nil {
t.Fatalf("UpsertNewsSource() error = %v", err)
}
if err := st.UpsertNewsItem(store.NewsItem{
URL: "https://example.com/story",
Source: "racefans-f1",
Title: "RaceFans story",
PublishedAt: &published,
Summary: "Brief summary",
Category: "news",
FetchedAt: now,
}); err != nil {
t.Fatalf("UpsertNewsItem() error = %v", err)
}
srv := testServer(t, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/news?source=racefans-f1&limit=5", nil)
rec := httptest.NewRecorder()
srv.handleNews(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var items []query.NewsItem
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1", len(items))
}
if items[0].Source != "racefans-f1" || items[0].Title != "RaceFans story" {
t.Fatalf("items[0] = %+v, want seeded item", items[0])
}
}
func TestHandleWeekendNotFound(t *testing.T) {
st := openTestStore(t)
srv := testServer(t, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/weekend?meeting_key=1229", nil)
rec := httptest.NewRecorder()
srv.handleWeekend(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestHandleWeekendWithData(t *testing.T) {
st := openTestStore(t)
seedRaceHubStore(t, st)
srv := testServer(t, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/weekend?meeting_key=1229", nil)
rec := httptest.NewRecorder()
srv.handleWeekend(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var weekend query.Weekend
if err := json.Unmarshal(rec.Body.Bytes(), &weekend); err != nil {
t.Fatalf("decode response: %v", err)
}
if weekend.Meeting.MeetingName != "Monaco" {
t.Fatalf("meeting = %q, want Monaco", weekend.Meeting.MeetingName)
}
if len(weekend.Sessions) != 1 {
t.Fatalf("sessions len = %d, want 1", len(weekend.Sessions))
}
if weekend.DefaultSessionKey != 9472 {
t.Fatalf("default_session_key = %d, want 9472", weekend.DefaultSessionKey)
}
}
func TestHandleWeekendRequiresMeetingKey(t *testing.T) {
srv := testServer(t, nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/weekend", nil)
rec := httptest.NewRecorder()
srv.handleWeekend(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}