From dafb9b6dc52c7e6d7202e4397f8b0b561be7bd3e Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Sun, 12 Jul 2026 20:26:21 -0400 Subject: [PATCH] fix(#76): report request-scoped data freshness --- internal/api/cache.go | 4 +- internal/api/client.go | 20 +++ internal/api/freshness_test.go | 123 ++++++++++++++++ internal/query/context.go | 16 ++- internal/query/context_test.go | 48 +++++++ internal/web/api.go | 222 +++++++++++++++++++---------- internal/web/context.go | 7 + internal/web/context_test.go | 12 ++ internal/web/driversummary.go | 33 +++-- internal/web/driversummary_test.go | 9 ++ internal/web/freshness.go | 56 ++++++++ internal/web/freshness_test.go | 72 ++++++++++ internal/web/live.go | 10 +- internal/web/navigation.go | 3 + internal/web/racehub.go | 2 + internal/web/racehub_test.go | 9 ++ internal/web/replay.go | 6 +- internal/web/server.go | 1 + 18 files changed, 562 insertions(+), 91 deletions(-) create mode 100644 internal/api/freshness_test.go create mode 100644 internal/web/freshness.go create mode 100644 internal/web/freshness_test.go diff --git a/internal/api/cache.go b/internal/api/cache.go index 7057e0e..5ae2d5a 100644 --- a/internal/api/cache.go +++ b/internal/api/cache.go @@ -164,8 +164,8 @@ func (c *Cache) Get(key string) ([]byte, bool) { if ttl > 0 { age := time.Since(time.Unix(createdAt, 0)) if age > ttl { - // Expired — delete and return miss. - _, _ = c.db.Exec(`DELETE FROM cache WHERE key = ?`, key) + // Expired entries remain stored so get() can use them as a stale + // fallback if the live request fails. Prune() owns physical cleanup. atomic.AddInt64(&c.stats.Misses, 1) return nil, false } diff --git a/internal/api/client.go b/internal/api/client.go index fe04625..5bcbfe9 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -56,6 +56,26 @@ type OpenF1Client struct { staleFlag int32 } +// Scoped returns a lightweight request-scoped view of the client. Network, +// pacing and cache resources are shared, while the stale fallback indicator is +// deliberately not shared. Web handlers use this view so a stale fallback in +// one concurrent HTTP request can never mark an unrelated response as stale. +// +// The legacy client-wide stale flag remains available for the TUI, whose loads +// are intentionally aggregated into one navigation-level notice. +func (c *OpenF1Client) Scoped() *OpenF1Client { + if c == nil { + return nil + } + return &OpenF1Client{ + url: c.url, + apiKey: c.apiKey, + httpClient: c.httpClient, + cache: c.cache, + pacer: c.pacer, + } +} + func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client { return &OpenF1Client{ url: url, diff --git a/internal/api/freshness_test.go b/internal/api/freshness_test.go new file mode 100644 index 0000000..16c4fa5 --- /dev/null +++ b/internal/api/freshness_test.go @@ -0,0 +1,123 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func expireCacheEntry(t *testing.T, c *OpenF1Client, key string) { + t.Helper() + if _, err := c.cache.db.Exec(`UPDATE cache SET created_at = ? WHERE key = ?`, time.Now().Add(-48*time.Hour).Unix(), key); err != nil { + t.Fatal(err) + } +} + +func TestScopedClientReportsStaleFallbackWithoutMutatingParent(t *testing.T) { + year := time.Now().Year() + var fail atomic.Bool + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if fail.Load() { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `[{ + "meeting_key": 1, + "meeting_name": "British Grand Prix", + "year": %d + }]`, year) + })) + defer upstream.Close() + + client := NewOpenF1Client(upstream.URL, time.Second) + defer client.Close() + client.pacer.interval = 0 + key := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year) + _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key = ?`, key) + defer func() { _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key = ?`, key) }() + + if _, err := client.GetMeetingsForYear(year); err != nil { + t.Fatalf("prime cache: %v", err) + } + expireCacheEntry(t, client, key) + fail.Store(true) + + scoped := client.Scoped() + meetings, err := scoped.GetMeetingsForYear(year) + if err != nil || len(meetings) != 1 { + t.Fatalf("stale fallback = (%+v, %v)", meetings, err) + } + if !scoped.LastResponseWasStale() { + t.Fatal("scoped request did not report its stale fallback") + } + if client.LastResponseWasStale() { + t.Fatal("request-scoped fallback leaked into the parent client") + } +} + +func TestScopedClientsDoNotLeakFreshnessAcrossConcurrentRequests(t *testing.T) { + year := time.Now().Year() + staleStarted := make(chan struct{}) + releaseStale := make(chan struct{}) + var failMeetings atomic.Bool + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/meetings": + if failMeetings.Load() { + close(staleStarted) + <-releaseStale + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + } + _, _ = fmt.Fprintf(w, `[{"meeting_key":1,"meeting_name":"British Grand Prix","year":%d}]`, year) + case "/v1/sessions": + _, _ = w.Write([]byte(`[{"session_key":11,"meeting_key":2,"session_name":"Race"}]`)) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + client := NewOpenF1Client(upstream.URL, 2*time.Second) + defer client.Close() + client.pacer.interval = 0 + meetingKey := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year) + sessionKey := upstream.URL + "/v1/sessions?meeting_key=2" + _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key IN (?, ?)`, meetingKey, sessionKey) + defer func() { _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key IN (?, ?)`, meetingKey, sessionKey) }() + if _, err := client.GetMeetingsForYear(year); err != nil { + t.Fatal(err) + } + expireCacheEntry(t, client, meetingKey) + failMeetings.Store(true) + + staleClient := client.Scoped() + staleDone := make(chan error, 1) + go func() { + _, err := staleClient.GetMeetingsForYear(year) + staleDone <- err + }() + <-staleStarted + + freshClient := client.Scoped() + if _, err := freshClient.GetSessionsForMeeting(2); err != nil { + t.Fatalf("fresh concurrent request: %v", err) + } + if freshClient.LastResponseWasStale() { + t.Fatal("fresh request inherited concurrent request's stale state") + } + close(releaseStale) + if err := <-staleDone; err != nil { + t.Fatalf("stale request: %v", err) + } + if !staleClient.LastResponseWasStale() { + t.Fatal("stale request lost its own freshness state") + } + if freshClient.LastResponseWasStale() || client.LastResponseWasStale() { + t.Fatal("stale state leaked after concurrent requests completed") + } +} diff --git a/internal/query/context.go b/internal/query/context.go index 7c61e51..b876f61 100644 --- a/internal/query/context.go +++ b/internal/query/context.go @@ -40,6 +40,7 @@ type LiveEvidence struct { // ContextAvailability is structured source state for a referenced session. type ContextAvailability struct { + Source string `json:"source"` Schedule string `json:"schedule"` LiveTransport string `json:"live_transport"` LiveSession string `json:"live_session"` @@ -298,21 +299,28 @@ func meetingModelByKey(meetings []store.Meeting, key int) *models.Meeting { func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *ContextSession { session := sessionToModel(c.session) meeting := meetingToModel(c.meeting) - availability := ContextAvailability{Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "fresh", Limitations: []string{}} + // Schedule and analysis are domain-store facts. Without an ingestion + // timestamp the resolver cannot honestly call them network-fresh, so local + // is the baseline freshness vocabulary exposed to clients. + availability := ContextAvailability{Source: "local", Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "local", Limitations: []string{}} availability.LiveTransport = "unknown" if c.session.SessionKey == 0 { availability.Schedule = "unavailable" availability.Limitations = append(availability.Limitations, "schedule_identity_unmatched") } if evidence.Active && liveMatches(evidence, c.meeting, c.session) { + availability.Source = "mixed" availability.LiveTransport = "connected" availability.LiveSession = "active" + availability.Freshness = "live" if !evidence.ObservedAt.IsZero() { availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339) } } if c.archived { + availability.Source = "mixed" availability.Archive = "available" + availability.Freshness = "archive" if !evidence.ObservedAt.IsZero() { availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339) } @@ -326,6 +334,12 @@ func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *Conte } else { availability.LocalAnalysis = "pending" } + if availability.LocalAnalysis == "partial" && availability.Freshness == "local" { + availability.Freshness = "partial" + } + if c.session.SessionKey == 0 && availability.LiveSession == "active" { + availability.Source = "fia" + } return &ContextSession{Session: session, Meeting: &meeting, Availability: availability} } diff --git a/internal/query/context_test.go b/internal/query/context_test.go index 8dac292..f85ce14 100644 --- a/internal/query/context_test.go +++ b/internal/query/context_test.go @@ -160,6 +160,54 @@ func TestResolveWeekendContextPassedTimeDoesNotCompleteSession(t *testing.T) { } } +func TestResolveWeekendContextAvailabilityUsesTruthfulSources(t *testing.T) { + now, _ := time.Parse(time.RFC3339, "2026-07-05T18:00:00Z") + svc := contextService(t, now) + addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false) + addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false) + // Results without laps/stints/positions are meaningful but incomplete local + // analysis, so they must not be labelled universally fresh. + completeContextSession(t, svc, 11, 1) + addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T00:00:00Z", "2026-07-19T16:00:00Z", false) + addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false) + addContextSession(t, svc, 22, 2, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false) + + local, err := svc.ResolveWeekendContext(LiveEvidence{}) + if err != nil { + t.Fatal(err) + } + if got := local.PreviousCompletedSession.Availability; got.Source != "local" || got.Freshness != "partial" || got.LocalAnalysis != "partial" { + t.Fatalf("partial local availability = %+v", got) + } + if got := local.NextSession.Availability; got.Source != "local" || got.Freshness != "local" { + t.Fatalf("future local availability = %+v", got) + } + + liveContext, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Belgian Grand Prix", CircuitName: "Belgian Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1", ObservedAt: now}) + if err != nil { + t.Fatal(err) + } + if got := liveContext.ActiveSession.Availability; got.Source != "mixed" || got.Freshness != "live" || got.LiveSession != "active" { + t.Fatalf("FIA + local availability = %+v", got) + } + + archiveContext, err := svc.ResolveWeekendContext(LiveEvidence{Final: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now}) + if err != nil { + t.Fatal(err) + } + if got := archiveContext.PreviousCompletedSession.Availability; got.Source != "mixed" || got.Freshness != "archive" || got.Archive != "available" { + t.Fatalf("FIA archive + local availability = %+v", got) + } + + synthetic, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Unscheduled Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now}) + if err != nil { + t.Fatal(err) + } + if got := synthetic.ActiveSession.Availability; got.Source != "fia" || got.Freshness != "live" || got.Schedule != "unavailable" { + t.Fatalf("synthetic FIA availability = %+v", got) + } +} + func TestResolveWeekendContextNeverUsesFutureAnalysis(t *testing.T) { now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z") svc := contextService(t, now) diff --git a/internal/web/api.go b/internal/web/api.go index fca561e..07d6490 100644 --- a/internal/web/api.go +++ b/internal/web/api.go @@ -16,6 +16,7 @@ import ( readability "codeberg.org/readeck/go-readability/v2" + "github.com/AmanTahiliani/box-box/internal/api" "github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/query" ) @@ -43,6 +44,7 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) { switch parseSourceMode(r) { case sourceLocal: + markLocalResponse(w, false) if !s.hasLocalQuery() { writeJSON(w, []models.Meeting{}) return @@ -62,17 +64,20 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) { return } if len(meetings) > 0 { + markLocalResponse(w, false) writeJSON(w, meetings) return } } } - meetings, err := s.client.GetMeetingsForYear(year) + client := s.client.Scoped() + meetings, err := client.GetMeetingsForYear(year) if err != nil { - writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale()) return } + markOpenF1Response(w, client) writeJSON(w, meetings) } @@ -87,6 +92,7 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) { switch parseSourceMode(r) { case sourceLocal: + markLocalResponse(w, false) if !s.hasLocalQuery() { writeJSON(w, []models.Session{}) return @@ -106,23 +112,27 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) { return } if len(sessions) > 0 { + markLocalResponse(w, false) writeJSON(w, sessions) return } } } - sessions, err := s.client.GetSessionsForMeeting(meetingKey) + client := s.client.Scoped() + sessions, err := client.GetSessionsForMeeting(meetingKey) if err != nil { - writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale()) return } + markOpenF1Response(w, client) writeJSON(w, sessions) } // --- /api/v1/news --- func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) { + markLocalResponse(w, false) if !s.hasLocalQuery() { writeJSON(w, []query.NewsItem{}) return @@ -223,6 +233,7 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) { switch parseSourceMode(r) { case sourceLocal: + markLocalResponse(w, false) if !s.hasLocalQuery() { writeJSON(w, []models.Driver{}) return @@ -242,6 +253,7 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) { if s.hasLocalQuery() { drivers, err := s.query.ListDrivers(sessionKey) if err == nil && len(drivers) > 0 { + markLocalResponse(w, false) writeJSON(w, drivers) return } @@ -252,11 +264,13 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) { } } - drivers, err := s.client.GetDriversForSession(sessionKey) + client := s.client.Scoped() + drivers, err := client.GetDriversForSession(sessionKey) if err != nil { - writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale()) return } + markOpenF1Response(w, client) writeJSON(w, drivers) } @@ -279,6 +293,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) { switch parseSourceMode(r) { case sourceLocal: + markLocalResponse(w, false) if !s.hasLocalQuery() { writeJSON(w, []resultWithDriver{}) return @@ -294,6 +309,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) { if s.hasLocalQuery() { results, err := s.query.ListResults(sessionKey) if err == nil && len(results) > 0 { + markLocalResponse(w, false) writeJSON(w, enrichedResultsToAPI(results)) return } @@ -310,13 +326,14 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) { resultsErr error wg sync.WaitGroup ) + client := s.client.Scoped() wg.Add(2) - go func() { defer wg.Done(); results, resultsErr = s.client.GetSessionResult(sessionKey) }() - go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }() + go func() { defer wg.Done(); results, resultsErr = client.GetSessionResult(sessionKey) }() + go func() { defer wg.Done(); drivers, _ = client.GetDriversForSession(sessionKey) }() wg.Wait() if resultsErr != nil { - writeError(w, resultsErr, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, resultsErr, http.StatusInternalServerError, client.LastResponseWasStale()) return } @@ -332,6 +349,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) { } enriched = append(enriched, e) } + markOpenF1Response(w, client) writeJSON(w, enriched) } @@ -354,6 +372,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) { switch parseSourceMode(r) { case sourceLocal: + markLocalResponse(w, false) if !s.hasLocalQuery() { writeJSON(w, []gridWithDriver{}) return @@ -369,6 +388,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) { if s.hasLocalQuery() { grid, err := s.query.ListStartingGrid(sessionKey) if err == nil && len(grid) > 0 { + markLocalResponse(w, false) writeJSON(w, enrichedGridToAPI(grid)) return } @@ -385,13 +405,14 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) { gridErr error wg sync.WaitGroup ) + client := s.client.Scoped() wg.Add(2) - go func() { defer wg.Done(); grid, gridErr = s.client.GetStartingGrid(sessionKey) }() - go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }() + go func() { defer wg.Done(); grid, gridErr = client.GetStartingGrid(sessionKey) }() + go func() { defer wg.Done(); drivers, _ = client.GetDriversForSession(sessionKey) }() wg.Wait() if gridErr != nil { - writeError(w, gridErr, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, gridErr, http.StatusInternalServerError, client.LastResponseWasStale()) return } @@ -407,6 +428,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) { } enriched = append(enriched, e) } + markOpenF1Response(w, client) writeJSON(w, enriched) } @@ -419,26 +441,29 @@ func (s *Server) handleLaps(w http.ResponseWriter, r *http.Request) { return } + client := s.client.Scoped() if dnStr := r.URL.Query().Get("driver_number"); dnStr != "" { driverNumber, err := strconv.Atoi(dnStr) if err != nil || driverNumber == 0 { http.Error(w, "invalid driver_number", http.StatusBadRequest) return } - laps, err := s.client.GetLapsForDriver(sessionKey, driverNumber) + laps, err := client.GetLapsForDriver(sessionKey, driverNumber) if err != nil { - writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale()) return } + markOpenF1Response(w, client) writeJSON(w, laps) return } - laps, err := s.client.GetLapsForSession(sessionKey) + laps, err := client.GetLapsForSession(sessionKey) if err != nil { - writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale()) return } + markOpenF1Response(w, client) writeJSON(w, laps) } @@ -450,11 +475,13 @@ func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request) { http.Error(w, "session_key required", http.StatusBadRequest) return } - weather, err := s.client.GetWeather(sessionKey) + client := s.client.Scoped() + weather, err := client.GetWeather(sessionKey) if err != nil { - writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale()) return } + markOpenF1Response(w, client) writeJSON(w, weather) } @@ -466,11 +493,13 @@ func (s *Server) handleRaceControl(w http.ResponseWriter, r *http.Request) { http.Error(w, "session_key required", http.StatusBadRequest) return } - rc, err := s.client.GetRaceControl(sessionKey) + client := s.client.Scoped() + rc, err := client.GetRaceControl(sessionKey) if err != nil { - writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale()) return } + markOpenF1Response(w, client) writeJSON(w, rc) } @@ -487,11 +516,13 @@ func (s *Server) handleTelemetry(w http.ResponseWriter, r *http.Request) { http.Error(w, "driver_number required", http.StatusBadRequest) return } - carData, err := s.client.GetCarData(sessionKey, driverNumber) + client := s.client.Scoped() + carData, err := client.GetCarData(sessionKey, driverNumber) if err != nil { - writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale()) return } + markOpenF1Response(w, client) writeJSON(w, carData) } @@ -503,11 +534,13 @@ func (s *Server) handleOvertakes(w http.ResponseWriter, r *http.Request) { http.Error(w, "session_key required", http.StatusBadRequest) return } - overtakes, err := s.client.GetOvertakesForSession(sessionKey) + client := s.client.Scoped() + overtakes, err := client.GetOvertakesForSession(sessionKey) if err != nil { - writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale()) return } + markOpenF1Response(w, client) writeJSON(w, overtakes) } @@ -524,11 +557,13 @@ func (s *Server) handleTeamRadio(w http.ResponseWriter, r *http.Request) { http.Error(w, "driver_number required", http.StatusBadRequest) return } - radios, err := s.client.GetTeamRadio(sessionKey, driverNumber) + client := s.client.Scoped() + radios, err := client.GetTeamRadio(sessionKey, driverNumber) if err != nil { - writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale()) return } + markOpenF1Response(w, client) writeJSON(w, radios) } @@ -547,23 +582,25 @@ func (s *Server) handleChampionshipDrivers(w http.ResponseWriter, r *http.Reques if year == 0 { year = time.Now().Year() } - champ, err := s.client.GetDriverChampionshipForYear(year) + client := s.client.Scoped() + champ, err := client.GetDriverChampionshipForYear(year) if err != nil { - writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale()) return } if len(champ) == 0 { + markOpenF1Response(w, client) writeJSON(w, []any{}) return } - drivers, _ := s.client.GetDriversForSession(champ[0].SessionKey) + drivers, _ := client.GetDriversForSession(champ[0].SessionKey) driverMap := buildDriverMapFirst(drivers) enriched := make([]champDriverWithInfo, 0, len(champ)) for _, c := range champ { e := champDriverWithInfo{ChampionshipDriver: c} - d, ok := s.championshipDriverInfo(c.SessionKey, c.DriverNumber, driverMap) + d, ok := championshipDriverInfo(client, c.SessionKey, c.DriverNumber, driverMap) if ok { e.NameAcronym = d.NameAcronym e.FullName = d.FullName @@ -572,11 +609,12 @@ func (s *Server) handleChampionshipDrivers(w http.ResponseWriter, r *http.Reques } enriched = append(enriched, e) } + markOpenF1Response(w, client) writeJSON(w, enriched) } -func (s *Server) championshipDriverInfo(sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) { - if d, err := s.client.GetDriver(sessionKey, driverNumber); err == nil && d != nil { +func championshipDriverInfo(client *api.OpenF1Client, sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) { + if d, err := client.GetDriver(sessionKey, driverNumber); err == nil && d != nil { return *d, true } d, ok := fallback[driverNumber] @@ -590,11 +628,13 @@ func (s *Server) handleChampionshipTeams(w http.ResponseWriter, r *http.Request) if year == 0 { year = time.Now().Year() } - teams, err := s.client.GetTeamChampionshipForYear(year) + client := s.client.Scoped() + teams, err := client.GetTeamChampionshipForYear(year) if err != nil { - writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale()) return } + markOpenF1Response(w, client) writeJSON(w, teams) } @@ -671,8 +711,10 @@ func champHubTTL(year int, now time.Time) time.Duration { } type champHubEntry struct { - resp champHubResponse - expires time.Time + resp champHubResponse + source string + freshness string + expires time.Time } // champHubCache is an in-memory cache of aggregated hub responses keyed by @@ -693,12 +735,26 @@ func (c *champHubCache) get(year int, now time.Time) (champHubResponse, bool) { } func (c *champHubCache) put(year int, resp champHubResponse, now time.Time, ttl time.Duration) { + c.putWithMetadata(year, resp, "local", "local", now, ttl) +} + +func (c *champHubCache) getWithMetadata(year int, now time.Time) (champHubResponse, string, string, bool) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[year] + if !ok || now.After(e.expires) { + return champHubResponse{}, "", "", false + } + return e.resp, e.source, e.freshness, true +} + +func (c *champHubCache) putWithMetadata(year int, resp champHubResponse, source, freshness string, now time.Time, ttl time.Duration) { c.mu.Lock() defer c.mu.Unlock() if c.entries == nil { c.entries = map[int]champHubEntry{} } - c.entries[year] = champHubEntry{resp: resp, expires: now.Add(ttl)} + c.entries[year] = champHubEntry{resp: resp, source: source, freshness: freshness, expires: now.Add(ttl)} } // fetchMeetingRaces fans fetch out across meetings with bounded concurrency. @@ -740,13 +796,6 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) { } mode := parseSourceMode(r) - if mode != sourceLocal { - if resp, ok := s.hubCache.get(year, time.Now()); ok { - writeJSON(w, resp) - return - } - } - if mode == sourceLocal || mode == sourceAuto { resp, ok, err := s.localChampionshipHub(year) if err != nil { @@ -754,21 +803,33 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) { return } if ok { - s.hubCache.put(year, resp, time.Now(), champHubTTL(year, time.Now())) + s.hubCache.putWithMetadata(year, resp, "local", "local", time.Now(), champHubTTL(year, time.Now())) + markLocalResponse(w, false) writeJSON(w, resp) return } if mode == sourceLocal { + markLocalResponse(w, false) writeJSON(w, resp) return } } - resp, err := s.openF1ChampionshipHub(year) - if err != nil { - writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + // At this point an auto request has no usable domain aggregate and an + // explicit OpenF1 request must not be satisfied by a local cache entry. + if resp, source, freshness, ok := s.hubCache.getWithMetadata(year, time.Now()); ok && source == "openf1" { + markDataResponse(w, source, freshness) + writeJSON(w, resp) return } + + client := s.client.Scoped() + resp, incomplete, err := s.openF1ChampionshipHub(client, year) + if err != nil { + writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale()) + return + } + markOpenF1AggregateResponse(w, client, incomplete) writeJSON(w, resp) } @@ -797,25 +858,26 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error) return aggregateChampionshipHub(year, races, inputs.Champ, inputs.Teams, inputs.DriverMap), true, nil } -func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) { - champ, err := s.client.GetDriverChampionshipForYear(year) +func (s *Server) openF1ChampionshipHub(client *api.OpenF1Client, year int) (champHubResponse, bool, error) { + champ, err := client.GetDriverChampionshipForYear(year) if err != nil { - return champHubResponse{}, err + return champHubResponse{}, false, err } if len(champ) == 0 { - return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, nil + return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, false, nil } - teams, _ := s.client.GetTeamChampionshipForYear(year) + teams, teamsErr := client.GetTeamChampionshipForYear(year) driverInfo := map[int]models.Driver{} - if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil { + if ds, derr := client.GetDriversForSession(champ[0].SessionKey); derr == nil { driverInfo = buildDriverMapFirst(ds) } - races, incomplete, err := s.fetchSeasonRaces(year) + races, incomplete, err := fetchSeasonRaces(client, year) if err != nil { - return champHubResponse{}, err + return champHubResponse{}, false, err } + incomplete = incomplete || teamsErr != nil resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo) ttl := champHubTTL(year, time.Now()) @@ -825,16 +887,22 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) { // so a partial view of the season doesn't stick around for the full TTL. ttl = champHubIncompleteTTL } - s.hubCache.put(year, resp, time.Now(), ttl) - return resp, nil + freshness := "fresh" + if client.LastResponseWasStale() { + freshness = "stale" + } else if incomplete { + freshness = "partial" + } + s.hubCache.putWithMetadata(year, resp, "openf1", freshness, time.Now(), ttl) + return resp, incomplete, nil } // fetchSeasonRaces returns a season's GP meetings in date order, each bundled // with its race results and starting grid fetched from OpenF1. incomplete // reports whether any per-meeting fetch failed, so callers can avoid caching a // partial view of the season for long. -func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete bool, err error) { - meetings, err := s.client.GetMeetingsForYear(year) +func fetchSeasonRaces(client *api.OpenF1Client, year int) (races []meetingRace, incomplete bool, err error) { + meetings, err := client.GetMeetingsForYear(year) if err != nil { return nil, false, err } @@ -842,7 +910,7 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo var failed atomic.Bool races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) { - sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey)) + sessions, serr := client.GetSessionsForMeeting(int(m.MeetingKey)) if serr != nil { failed.Store(true) return meetingRace{}, false @@ -857,8 +925,8 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo if raceKey == 0 { return meetingRace{}, false // not a GP meeting (e.g. pre-season testing) } - results, rerr := s.client.GetSessionResult(raceKey) - grid, gerr := s.client.GetStartingGrid(raceKey) + results, rerr := client.GetSessionResult(raceKey) + grid, gerr := client.GetStartingGrid(raceKey) if rerr != nil || gerr != nil { failed.Store(true) } @@ -1077,6 +1145,7 @@ type trackOutlineResponse struct { } func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) { + markLocalResponse(w, false) year, _ := strconv.Atoi(r.URL.Query().Get("year")) if year == 0 { year = time.Now().Year() @@ -1283,12 +1352,13 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) { resErr error wg sync.WaitGroup ) + client := s.client.Scoped() wg.Add(5) - go func() { defer wg.Done(); stints, stintsErr = s.client.GetStintsForSession(sessionKey) }() - go func() { defer wg.Done(); pits, pitsErr = s.client.GetPitStopsForSession(sessionKey) }() - go func() { defer wg.Done(); results, resErr = s.client.GetSessionResult(sessionKey) }() - go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }() - go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }() + go func() { defer wg.Done(); stints, stintsErr = client.GetStintsForSession(sessionKey) }() + go func() { defer wg.Done(); pits, pitsErr = client.GetPitStopsForSession(sessionKey) }() + go func() { defer wg.Done(); results, resErr = client.GetSessionResult(sessionKey) }() + go func() { defer wg.Done(); drivers, _ = client.GetDriversForSession(sessionKey) }() + go func() { defer wg.Done(); rc, _ = client.GetRaceControl(sessionKey) }() wg.Wait() if stintsErr != nil || pitsErr != nil || resErr != nil { @@ -1299,12 +1369,13 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) { if e == nil { e = resErr } - writeError(w, e, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, e, http.StatusInternalServerError, client.LastResponseWasStale()) return } // Non-race sessions have no stints. if len(stints) == 0 { + markOpenF1Response(w, client) writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}}) return } @@ -1396,6 +1467,7 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) { return pi < pj }) + markOpenF1Response(w, client) writeJSON(w, strategyResponse{ SessionKey: sessionKey, TotalLaps: totalLaps, @@ -1497,14 +1569,15 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) { rc []models.RaceControl wg sync.WaitGroup ) + client := s.client.Scoped() wg.Add(4) - go func() { defer wg.Done(); allLaps, _ = s.client.GetLapsForSession(sessionKey) }() - go func() { defer wg.Done(); stints, _ = s.client.GetStintsForSession(sessionKey) }() - go func() { defer wg.Done(); pits, _ = s.client.GetPitStopsForSession(sessionKey) }() - go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }() + go func() { defer wg.Done(); allLaps, _ = client.GetLapsForSession(sessionKey) }() + go func() { defer wg.Done(); stints, _ = client.GetStintsForSession(sessionKey) }() + go func() { defer wg.Done(); pits, _ = client.GetPitStopsForSession(sessionKey) }() + go func() { defer wg.Done(); rc, _ = client.GetRaceControl(sessionKey) }() wg.Wait() - allDrivers, _ := s.client.GetDriversForSession(sessionKey) + allDrivers, _ := client.GetDriversForSession(sessionKey) driverMap := buildDriverMap(allDrivers) // If no filter, default to first 3 unique driver numbers from lap data. @@ -1558,6 +1631,7 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) { compDrivers = append(compDrivers, cd) } + markOpenF1Response(w, client) writeJSON(w, lapsComparisonResponse{ SessionKey: sessionKey, SCPeriods: extractSCPeriods(rc), diff --git a/internal/web/context.go b/internal/web/context.go index ee63deb..996489d 100644 --- a/internal/web/context.go +++ b/internal/web/context.go @@ -9,6 +9,7 @@ import ( ) func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) { + markLocalResponse(w, false) if !s.hasLocalQuery() { writeJSON(w, query.WeekendContext{TemporalState: query.TemporalNoSeason}) return @@ -29,6 +30,12 @@ func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) { writeError(w, err, http.StatusInternalServerError, false) return } + for _, ref := range []*query.ContextSession{context.ActiveSession, context.PreviousCompletedSession, context.DefaultAnalysisSession, context.NextSession} { + if ref != nil && ref.Availability.Freshness != "local" { + markDataResponse(w, ref.Availability.Source, ref.Availability.Freshness) + break + } + } writeJSON(w, context) } diff --git a/internal/web/context_test.go b/internal/web/context_test.go index f5afd0c..61e8679 100644 --- a/internal/web/context_test.go +++ b/internal/web/context_test.go @@ -76,6 +76,12 @@ func TestWeekendContextHandlerUsesLiveHubIdentityWithoutOpenF1(t *testing.T) { if got.ActiveSession.Availability.LiveTransport != "connected" || got.ActiveSession.Availability.LiveSession != "active" { t.Fatalf("availability = %+v", got.ActiveSession.Availability) } + if got.ActiveSession.Availability.Source != "mixed" || got.ActiveSession.Availability.Freshness != "live" { + t.Fatalf("live source/freshness = %+v", got.ActiveSession.Availability) + } + if rr.Header().Get(dataSourceHeader) != "mixed" || rr.Header().Get(dataFreshnessHeader) != "live" { + t.Fatalf("response source/freshness = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader)) + } } func TestWeekendContextHandlerUsesTerminalArchiveAsCompletionEvidence(t *testing.T) { @@ -94,6 +100,12 @@ func TestWeekendContextHandlerUsesTerminalArchiveAsCompletionEvidence(t *testing if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Archive != "available" { t.Fatalf("archive context = %+v", got) } + if got.PreviousCompletedSession.Availability.Source != "mixed" || got.PreviousCompletedSession.Availability.Freshness != "archive" { + t.Fatalf("archive source/freshness = %+v", got.PreviousCompletedSession.Availability) + } + if rr.Header().Get(dataSourceHeader) != "mixed" || rr.Header().Get(dataFreshnessHeader) != "archive" { + t.Fatalf("archive response source/freshness = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader)) + } if got.DefaultAnalysisSession != nil { t.Fatal("archive-only session must not become local default analysis") } diff --git a/internal/web/driversummary.go b/internal/web/driversummary.go index 633e563..ebb874c 100644 --- a/internal/web/driversummary.go +++ b/internal/web/driversummary.go @@ -6,6 +6,7 @@ import ( "strconv" "time" + "github.com/AmanTahiliani/box-box/internal/api" "github.com/AmanTahiliani/box-box/internal/models" ) @@ -78,6 +79,7 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("source") == "" { mode = sourceAuto } + client := s.client.Scoped() if mode == sourceLocal || mode == sourceAuto { resp, sessionKey, ok, lerr := s.localDriverSummary(year, driverNumber) @@ -87,7 +89,15 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) { } if ok { if mode != sourceLocal { - s.tryEnrichDriverSummary(&resp, sessionKey) + tryEnrichDriverSummary(client, &resp, sessionKey) + } + switch resp.Enrichment { + case "full": + markMixedResponse(w, client, false) + case "limited": + markDataResponse(w, "local", "limited") + default: + markLocalResponse(w, false) } writeJSON(w, resp) return @@ -98,15 +108,16 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) { } } - resp, err := s.openF1DriverSummary(year, driverNumber) + resp, err := openF1DriverSummary(client, year, driverNumber) if err != nil { - writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale()) return } if resp == nil { http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound) return } + markOpenF1Response(w, client) writeJSON(w, resp) } @@ -152,8 +163,8 @@ func (s *Server) localDriverSummary(year, driverNumber int) (driverSummaryRespon return resp, sessionKey, true, nil } -func (s *Server) openF1DriverSummary(year, driverNumber int) (*driverSummaryResponse, error) { - champ, err := s.client.GetDriverChampionshipForYear(year) +func openF1DriverSummary(client *api.OpenF1Client, year, driverNumber int) (*driverSummaryResponse, error) { + champ, err := client.GetDriverChampionshipForYear(year) if err != nil { return nil, err } @@ -170,14 +181,14 @@ func (s *Server) openF1DriverSummary(year, driverNumber int) (*driverSummaryResp driverInfo := map[int]models.Driver{} sessionKey := champ[0].SessionKey - if ds, derr := s.client.GetDriversForSession(sessionKey); derr == nil { + if ds, derr := client.GetDriversForSession(sessionKey); derr == nil { driverInfo = buildDriverMapFirst(ds) } - if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok { + if d, ok := championshipDriverInfo(client, entry.SessionKey, driverNumber, driverInfo); ok { driverInfo[driverNumber] = d } - races, _, err := s.fetchSeasonRaces(year) + races, _, err := fetchSeasonRaces(client, year) if err != nil { return nil, err } @@ -194,8 +205,8 @@ func (s *Server) openF1DriverSummary(year, driverNumber int) (*driverSummaryResp // tryEnrichDriverSummary optionally fills headshot / polished identity from // OpenF1. It never blocks longer than driverEnrichmentTimeout — on timeout or // failure the local profile remains intact with enrichment=limited. -func (s *Server) tryEnrichDriverSummary(resp *driverSummaryResponse, sessionKey int) { - if resp == nil || s.client == nil || sessionKey <= 0 { +func tryEnrichDriverSummary(client *api.OpenF1Client, resp *driverSummaryResponse, sessionKey int) { + if resp == nil || client == nil || sessionKey <= 0 { if resp != nil && resp.Enrichment == "none" { // No session to enrich from — leave as none (local identity only). } @@ -209,7 +220,7 @@ func (s *Server) tryEnrichDriverSummary(resp *driverSummaryResponse, sessionKey done := make(chan enrichResult, 1) go func() { - d, ok := s.championshipDriverInfo(sessionKey, resp.DriverNumber, nil) + d, ok := championshipDriverInfo(client, sessionKey, resp.DriverNumber, nil) done <- enrichResult{driver: d, ok: ok} }() diff --git a/internal/web/driversummary_test.go b/internal/web/driversummary_test.go index 55020b9..afb9ed3 100644 --- a/internal/web/driversummary_test.go +++ b/internal/web/driversummary_test.go @@ -250,6 +250,9 @@ func TestHandleDriverSummaryLocalFirstIgnoresHangingEnrichment(t *testing.T) { if resp.Enrichment != "limited" { t.Errorf("enrichment = %q, want limited", resp.Enrichment) } + if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "limited" { + t.Errorf("limited metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader)) + } if resp.DriverNumber != 1 || resp.NameAcronym != "VER" || resp.Points != 25 { t.Errorf("local identity/results missing: %+v", resp) } @@ -289,6 +292,9 @@ func TestHandleDriverSummaryLocalFirstWithFailingEnrichment(t *testing.T) { if resp.Enrichment != "limited" { t.Errorf("enrichment = %q, want limited", resp.Enrichment) } + if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "limited" { + t.Errorf("limited metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader)) + } if resp.FullName != "Max Verstappen" { t.Errorf("full_name = %q, want local identity", resp.FullName) } @@ -313,4 +319,7 @@ func TestHandleDriverSummarySourceLocalOnly(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) } + if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "local" { + t.Fatalf("local metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader)) + } } diff --git a/internal/web/freshness.go b/internal/web/freshness.go new file mode 100644 index 0000000..969bf96 --- /dev/null +++ b/internal/web/freshness.go @@ -0,0 +1,56 @@ +package web + +import ( + "net/http" + + "github.com/AmanTahiliani/box-box/internal/api" +) + +const ( + dataSourceHeader = "X-BoxBox-Data-Source" + dataFreshnessHeader = "X-BoxBox-Data-Freshness" +) + +// markOpenF1Response publishes request-scoped success provenance. Callers must +// pass the scoped client used for this response, never Server.client. +func markOpenF1Response(w http.ResponseWriter, client *api.OpenF1Client) { + markOpenF1AggregateResponse(w, client, false) +} + +func markOpenF1AggregateResponse(w http.ResponseWriter, client *api.OpenF1Client, partial bool) { + w.Header().Set(dataSourceHeader, "openf1") + if client != nil && client.LastResponseWasStale() { + w.Header().Set(dataFreshnessHeader, "stale") + return + } + if partial { + w.Header().Set(dataFreshnessHeader, "partial") + return + } + w.Header().Set(dataFreshnessHeader, "fresh") +} + +func markDataResponse(w http.ResponseWriter, source, freshness string) { + w.Header().Set(dataSourceHeader, source) + w.Header().Set(dataFreshnessHeader, freshness) +} + +func markLocalResponse(w http.ResponseWriter, partial bool) { + w.Header().Set(dataSourceHeader, "local") + if partial { + w.Header().Set(dataFreshnessHeader, "partial") + return + } + w.Header().Set(dataFreshnessHeader, "local") +} + +func markMixedResponse(w http.ResponseWriter, client *api.OpenF1Client, partial bool) { + w.Header().Set(dataSourceHeader, "mixed") + if client != nil && client.LastResponseWasStale() { + w.Header().Set(dataFreshnessHeader, "stale") + } else if partial { + w.Header().Set(dataFreshnessHeader, "partial") + } else { + w.Header().Set(dataFreshnessHeader, "local") + } +} diff --git a/internal/web/freshness_test.go b/internal/web/freshness_test.go new file mode 100644 index 0000000..405191f --- /dev/null +++ b/internal/web/freshness_test.go @@ -0,0 +1,72 @@ +package web + +import ( + "database/sql" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/AmanTahiliani/box-box/internal/api" + _ "modernc.org/sqlite" +) + +func TestOpenF1HandlerReportsFreshThenStaleSuccess(t *testing.T) { + year := time.Now().Year() + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, `[{"meeting_key":1,"meeting_name":"British Grand Prix","year":%d}]`, year) + })) + client := api.NewOpenF1Client(upstream.URL, time.Second) + t.Cleanup(func() { _ = client.Close() }) + server := NewServer(client, 0, nil) + cacheKey := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year) + requestURL := fmt.Sprintf("/api/v1/meetings?year=%d&source=openf1", year) + defer func() { + // Keep the shared application cache clean even if this test fails. + db, err := sql.Open("sqlite", api.DefaultCacheDBPath()+"?_busy_timeout=5000") + if err == nil { + _, _ = db.Exec(`DELETE FROM cache WHERE key = ?`, cacheKey) + _ = db.Close() + } + }() + + fresh := httptest.NewRecorder() + server.handleMeetings(fresh, httptest.NewRequest(http.MethodGet, requestURL, nil)) + if fresh.Code != http.StatusOK || fresh.Header().Get(dataSourceHeader) != "openf1" || fresh.Header().Get(dataFreshnessHeader) != "fresh" { + t.Fatalf("fresh response status/metadata = %d %q/%q body=%s", fresh.Code, fresh.Header().Get(dataSourceHeader), fresh.Header().Get(dataFreshnessHeader), fresh.Body.String()) + } + + db, err := sql.Open("sqlite", api.DefaultCacheDBPath()+"?_busy_timeout=5000") + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`UPDATE cache SET created_at = ? WHERE key = ?`, time.Now().Add(-48*time.Hour).Unix(), cacheKey); err != nil { + _ = db.Close() + t.Fatal(err) + } + _ = db.Close() + upstream.Close() + + stale := httptest.NewRecorder() + server.handleMeetings(stale, httptest.NewRequest(http.MethodGet, requestURL, nil)) + if stale.Code != http.StatusOK || stale.Header().Get(dataSourceHeader) != "openf1" || stale.Header().Get(dataFreshnessHeader) != "stale" { + t.Fatalf("stale response status/metadata = %d %q/%q body=%s", stale.Code, stale.Header().Get(dataSourceHeader), stale.Header().Get(dataFreshnessHeader), stale.Body.String()) + } +} + +func TestFreshnessHeadersAreExposedToBrowserClients(t *testing.T) { + handler := withCORS(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + markDataResponse(w, "local", "partial") + writeJSON(w, map[string]bool{"ok": true}) + })) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/test", nil)) + + if got := recorder.Header().Get("Access-Control-Expose-Headers"); got != dataSourceHeader+", "+dataFreshnessHeader { + t.Fatalf("exposed headers = %q", got) + } + if recorder.Header().Get(dataSourceHeader) != "local" || recorder.Header().Get(dataFreshnessHeader) != "partial" { + t.Fatalf("data metadata = %q/%q", recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader)) + } +} diff --git a/internal/web/live.go b/internal/web/live.go index 168ac7b..ef4598e 100644 --- a/internal/web/live.go +++ b/internal/web/live.go @@ -330,7 +330,15 @@ func cloneLivePositions(in map[string]live.LivePositionData) map[string]live.Liv // handleLiveState returns the current live data snapshot as JSON. func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) { - writeJSON(w, s.hub.State()) + state := s.hub.State() + freshness := "limited" + if state.IsLive { + freshness = "live" + } else if state.LastSnapshot != nil { + freshness = "archive" + } + markDataResponse(w, "fia", freshness) + writeJSON(w, state) } // handleSSEStream is the persistent SSE endpoint for live data. diff --git a/internal/web/navigation.go b/internal/web/navigation.go index d6b9830..d9ffd8c 100644 --- a/internal/web/navigation.go +++ b/internal/web/navigation.go @@ -9,6 +9,7 @@ import ( ) func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) { + markLocalResponse(w, false) if !s.hasLocalQuery() { writeJSON(w, []int{}) return @@ -26,6 +27,7 @@ func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) { + markLocalResponse(w, false) meetingKey, err := strconv.Atoi(r.URL.Query().Get("meeting_key")) if err != nil || meetingKey == 0 { http.Error(w, "meeting_key required", http.StatusBadRequest) @@ -46,5 +48,6 @@ func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) { writeError(w, err, http.StatusInternalServerError, false) return } + markLocalResponse(w, weekend.Source == query.ResponseSourcePartial) writeJSON(w, weekend) } diff --git a/internal/web/racehub.go b/internal/web/racehub.go index db8725e..148681f 100644 --- a/internal/web/racehub.go +++ b/internal/web/racehub.go @@ -17,6 +17,7 @@ func (s *Server) handleRaceHub(w http.ResponseWriter, r *http.Request) { } if !s.hasLocalQuery() { + markDataResponse(w, "local", "limited") writeJSON(w, emptyRaceHub(sessionKey)) return } @@ -26,6 +27,7 @@ func (s *Server) handleRaceHub(w http.ResponseWriter, r *http.Request) { writeError(w, err, http.StatusInternalServerError, false) return } + markLocalResponse(w, hub.Source == query.ResponseSourcePartial) writeJSON(w, hub) } diff --git a/internal/web/racehub_test.go b/internal/web/racehub_test.go index 50755d5..14b82ce 100644 --- a/internal/web/racehub_test.go +++ b/internal/web/racehub_test.go @@ -86,6 +86,9 @@ func TestHandleRaceHubWithoutStore(t *testing.T) { if hub.Source != query.ResponseSourceNone { t.Fatalf("source = %q, want %q", hub.Source, query.ResponseSourceNone) } + if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "limited" { + t.Fatalf("missing hub metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader)) + } if hub.Datasets["session"].Status != query.DatasetStatusMissing { t.Fatalf("session dataset = %+v, want missing", hub.Datasets["session"]) } @@ -117,6 +120,12 @@ func TestHandleRaceHubWithLocalData(t *testing.T) { if hub.Datasets["results"].Status != query.DatasetStatusMissing { t.Fatalf("results dataset = %+v, want missing", hub.Datasets["results"]) } + if hub.Source != query.ResponseSourcePartial { + t.Fatalf("source = %q, want partial", hub.Source) + } + if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "partial" { + t.Fatalf("partial hub metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader)) + } } func TestHandleRaceHubIncludesChapters(t *testing.T) { diff --git a/internal/web/replay.go b/internal/web/replay.go index 9caa7bc..f98a2ec 100644 --- a/internal/web/replay.go +++ b/internal/web/replay.go @@ -61,11 +61,13 @@ func (s *Server) handleReplayFrames(w http.ResponseWriter, r *http.Request) { } } - resp, err := assembleReplayFrames(r.Context(), s.client, sessionKey, intervalMS) + client := s.client.Scoped() + resp, err := assembleReplayFrames(r.Context(), client, sessionKey, intervalMS) if err != nil { - writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale()) return } + markOpenF1Response(w, client) writeJSON(w, resp) } diff --git a/internal/web/server.go b/internal/web/server.go index 1612966..8aae981 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -159,6 +159,7 @@ func withCORS(next http.Handler) http.Handler { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Expose-Headers", dataSourceHeader+", "+dataFreshnessHeader) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return