mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 12:07:23 -04:00
Compare commits
4 Commits
c475011c49
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77a6b0f2dd | ||
|
|
d03e480e03 | ||
|
|
0b9c3fff0a | ||
|
|
adfa934f9d |
@@ -164,8 +164,8 @@ func (c *Cache) Get(key string) ([]byte, bool) {
|
|||||||
if ttl > 0 {
|
if ttl > 0 {
|
||||||
age := time.Since(time.Unix(createdAt, 0))
|
age := time.Since(time.Unix(createdAt, 0))
|
||||||
if age > ttl {
|
if age > ttl {
|
||||||
// Expired — delete and return miss.
|
// Expired entries remain stored so get() can use them as a stale
|
||||||
_, _ = c.db.Exec(`DELETE FROM cache WHERE key = ?`, key)
|
// fallback if the live request fails. Prune() owns physical cleanup.
|
||||||
atomic.AddInt64(&c.stats.Misses, 1)
|
atomic.AddInt64(&c.stats.Misses, 1)
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -26,8 +27,12 @@ type requestPacer struct {
|
|||||||
|
|
||||||
// wait blocks until this caller's reserved slot arrives.
|
// wait blocks until this caller's reserved slot arrives.
|
||||||
func (p *requestPacer) wait() {
|
func (p *requestPacer) wait() {
|
||||||
|
_ = p.waitContext(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *requestPacer) waitContext(ctx context.Context) error {
|
||||||
if p == nil || p.interval <= 0 {
|
if p == nil || p.interval <= 0 {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -38,9 +43,19 @@ func (p *requestPacer) wait() {
|
|||||||
p.next = p.next.Add(p.interval)
|
p.next = p.next.Add(p.interval)
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
if sleep > 0 {
|
if sleep > 0 {
|
||||||
time.Sleep(sleep)
|
timer := time.NewTimer(sleep)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Keep the unused reservation in the schedule. Blindly reclaiming an
|
||||||
|
// interval can collide with later callers that already reserved their
|
||||||
|
// wake times, releasing two requests simultaneously.
|
||||||
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type OpenF1Client struct {
|
type OpenF1Client struct {
|
||||||
url string
|
url string
|
||||||
@@ -56,6 +71,26 @@ type OpenF1Client struct {
|
|||||||
staleFlag int32
|
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 {
|
func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
|
||||||
return &OpenF1Client{
|
return &OpenF1Client{
|
||||||
url: url,
|
url: url,
|
||||||
|
|||||||
123
internal/api/freshness_test.go
Normal file
123
internal/api/freshness_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -59,8 +60,14 @@ func retryAfter429(resp *http.Response) time.Duration {
|
|||||||
// Without this, concurrent fan-outs (championship hub, track prefetch) burst
|
// Without this, concurrent fan-outs (championship hub, track prefetch) burst
|
||||||
// past the free-tier limit and callers silently treat 429s as missing data.
|
// past the free-tier limit and callers silently treat 429s as missing data.
|
||||||
func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
|
func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
|
||||||
|
return c.doPacedContext(req.Context(), req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenF1Client) doPacedContext(ctx context.Context, req *http.Request) (*http.Response, error) {
|
||||||
for attempt := 0; ; attempt++ {
|
for attempt := 0; ; attempt++ {
|
||||||
c.pacer.wait()
|
if err := c.pacer.waitContext(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -70,7 +77,14 @@ func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
|
|||||||
}
|
}
|
||||||
delay := retryAfter429(resp)
|
delay := retryAfter429(resp)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
time.Sleep(delay)
|
timer := time.NewTimer(delay)
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
timer.Stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,13 +97,23 @@ func (c *OpenF1Client) doPaced(req *http.Request) (*http.Response, error) {
|
|||||||
// entry for this URL, that stale entry is returned instead of propagating the
|
// entry for this URL, that stale entry is returned instead of propagating the
|
||||||
// error. The client's staleFlag is set so the UI can show a disclaimer.
|
// error. The client's staleFlag is set so the UI can show a disclaimer.
|
||||||
func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
|
func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
|
||||||
|
return c.getContext(context.Background(), url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getContext is the cancellable form used by bounded optional web enrichment.
|
||||||
|
// A caller cancellation never falls back to stale data: the work is no longer
|
||||||
|
// relevant to that response and must stop instead of continuing in background.
|
||||||
|
func (c *OpenF1Client) getContext(ctx context.Context, url string) (io.ReadCloser, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// 1. Check the cache for a fresh (non-expired) entry.
|
// 1. Check the cache for a fresh (non-expired) entry.
|
||||||
if cachedData, ok := c.cache.Get(url); ok {
|
if cachedData, ok := c.cache.Get(url); ok {
|
||||||
return io.NopCloser(bytes.NewReader(cachedData)), nil
|
return io.NopCloser(bytes.NewReader(cachedData)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Attempt a live network request.
|
// 2. Attempt a live network request.
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Even a request-construction failure warrants a stale fallback.
|
// Even a request-construction failure warrants a stale fallback.
|
||||||
return c.tryStale(url, err)
|
return c.tryStale(url, err)
|
||||||
@@ -98,8 +122,11 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.doPaced(req)
|
resp, err := c.doPacedContext(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
return c.tryStale(url, err)
|
return c.tryStale(url, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -128,6 +155,9 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
|
|||||||
// 3. Success — read the body, store in cache, return.
|
// 3. Success — read the body, store in cache, return.
|
||||||
data, err := io.ReadAll(resp.Body)
|
data, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
return c.tryStale(url, err)
|
return c.tryStale(url, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +275,13 @@ func (c *OpenF1Client) GetDriversForSession(sessionKey int) ([]models.Driver, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenF1Client) GetDriver(sessionKey, driverNumber int) (*models.Driver, error) {
|
func (c *OpenF1Client) GetDriver(sessionKey, driverNumber int) (*models.Driver, error) {
|
||||||
body, err := c.get(fmt.Sprintf("%s/v1/drivers?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
|
return c.GetDriverContext(context.Background(), sessionKey, driverNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDriverContext is a cancellable single-driver lookup for optional bounded
|
||||||
|
// enrichment. Other public methods retain their existing background semantics.
|
||||||
|
func (c *OpenF1Client) GetDriverContext(ctx context.Context, sessionKey, driverNumber int) (*models.Driver, error) {
|
||||||
|
body, err := c.getContext(ctx, fmt.Sprintf("%s/v1/drivers?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -49,6 +51,70 @@ func TestRequestPacerNilSafe(t *testing.T) {
|
|||||||
p.wait() // must not panic
|
p.wait() // must not panic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRequestPacerCancellationDoesNotCollideReservedWaiters(t *testing.T) {
|
||||||
|
const interval = 80 * time.Millisecond
|
||||||
|
p := &requestPacer{interval: interval}
|
||||||
|
if err := p.waitContext(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
initialNext := p.next
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
waitForReservation := func(want time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(250 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
p.mu.Lock()
|
||||||
|
got := p.next
|
||||||
|
p.mu.Unlock()
|
||||||
|
if got.Equal(want) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("reservation did not reach %v", want)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxB, cancelB := context.WithCancel(context.Background())
|
||||||
|
bDone := make(chan error, 1)
|
||||||
|
go func() { bDone <- p.waitContext(ctxB) }()
|
||||||
|
waitForReservation(initialNext.Add(interval))
|
||||||
|
|
||||||
|
cDone := make(chan time.Time, 1)
|
||||||
|
go func() {
|
||||||
|
_ = p.waitContext(context.Background())
|
||||||
|
cDone <- time.Now()
|
||||||
|
}()
|
||||||
|
waitForReservation(initialNext.Add(2 * interval))
|
||||||
|
|
||||||
|
cancelStarted := time.Now()
|
||||||
|
cancelB()
|
||||||
|
select {
|
||||||
|
case err := <-bDone:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("B error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(cancelStarted); elapsed > 30*time.Millisecond {
|
||||||
|
t.Fatalf("B cancellation took %v", elapsed)
|
||||||
|
}
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
t.Fatal("B did not return promptly after cancellation")
|
||||||
|
}
|
||||||
|
|
||||||
|
dDone := make(chan time.Time, 1)
|
||||||
|
go func() {
|
||||||
|
_ = p.waitContext(context.Background())
|
||||||
|
dDone <- time.Now()
|
||||||
|
}()
|
||||||
|
waitForReservation(initialNext.Add(3 * interval))
|
||||||
|
|
||||||
|
cAt, dAt := <-cDone, <-dDone
|
||||||
|
if separation := dAt.Sub(cAt); separation < interval/2 {
|
||||||
|
t.Fatalf("C and D collided: wake separation %v, want at least %v", separation, interval/2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetRetriesOn429(t *testing.T) {
|
func TestGetRetriesOn429(t *testing.T) {
|
||||||
var calls atomic.Int32
|
var calls atomic.Int32
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
449
internal/query/context.go
Normal file
449
internal/query/context.go
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
package query
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TemporalState describes where the fan is in the current season/weekend.
|
||||||
|
type TemporalState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TemporalNoSeason TemporalState = "no_season"
|
||||||
|
TemporalBetweenWeekends TemporalState = "between_weekends"
|
||||||
|
TemporalPreSession TemporalState = "pre_session"
|
||||||
|
TemporalSessionLive TemporalState = "session_live"
|
||||||
|
TemporalSessionSettling TemporalState = "session_settling"
|
||||||
|
TemporalBetweenSessions TemporalState = "between_sessions"
|
||||||
|
TemporalPostWeekend TemporalState = "post_weekend"
|
||||||
|
TemporalSeasonComplete TemporalState = "season_complete"
|
||||||
|
|
||||||
|
preSessionWindow = 48 * time.Hour
|
||||||
|
postWeekendWindow = 48 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// LiveEvidence is the small, transport-independent subset of FIA state needed
|
||||||
|
// by the context resolver. Active identity is authoritative over the schedule.
|
||||||
|
type LiveEvidence struct {
|
||||||
|
Active bool
|
||||||
|
Final bool
|
||||||
|
MeetingName string
|
||||||
|
CircuitName string
|
||||||
|
SessionName string
|
||||||
|
SessionType string
|
||||||
|
ObservedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
Archive string `json:"archive"`
|
||||||
|
LocalAnalysis string `json:"local_analysis"`
|
||||||
|
Freshness string `json:"freshness"`
|
||||||
|
ObservedAt string `json:"observed_at,omitempty"`
|
||||||
|
Limitations []string `json:"limitations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextSession couples a session identity with its availability contract.
|
||||||
|
type ContextSession struct {
|
||||||
|
Session models.Session `json:"session"`
|
||||||
|
Meeting *models.Meeting `json:"meeting,omitempty"`
|
||||||
|
Availability ContextAvailability `json:"availability"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeekendContext is the canonical local-first previous/current/next model.
|
||||||
|
type WeekendContext struct {
|
||||||
|
Season int `json:"season,omitempty"`
|
||||||
|
TemporalState TemporalState `json:"temporal_state"`
|
||||||
|
PreviousMeeting *models.Meeting `json:"previous_meeting,omitempty"`
|
||||||
|
FocusMeeting *models.Meeting `json:"focus_meeting,omitempty"`
|
||||||
|
NextMeeting *models.Meeting `json:"next_meeting,omitempty"`
|
||||||
|
PreviousCompletedSession *ContextSession `json:"previous_completed_session,omitempty"`
|
||||||
|
ActiveSession *ContextSession `json:"active_session,omitempty"`
|
||||||
|
NextSession *ContextSession `json:"next_session,omitempty"`
|
||||||
|
DefaultAnalysisSession *ContextSession `json:"default_analysis_session,omitempty"`
|
||||||
|
ChampionshipRound int `json:"championship_round"`
|
||||||
|
TotalChampionshipRounds int `json:"total_championship_rounds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type contextCandidate struct {
|
||||||
|
meeting store.Meeting
|
||||||
|
session store.Session
|
||||||
|
start time.Time
|
||||||
|
end time.Time
|
||||||
|
counts store.SessionDatasetCounts
|
||||||
|
complete bool
|
||||||
|
archived bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveWeekendContext computes the canonical context using only the domain
|
||||||
|
// store, the service clock, and optional in-memory FIA evidence.
|
||||||
|
func (s *Service) ResolveWeekendContext(evidence LiveEvidence) (WeekendContext, error) {
|
||||||
|
now := s.now().UTC()
|
||||||
|
out := WeekendContext{TemporalState: TemporalNoSeason}
|
||||||
|
years, err := s.store.ListYears()
|
||||||
|
if err != nil || len(years) == 0 {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.Season = currentLocalSeason(years, now.Year())
|
||||||
|
meetings, err := s.store.ListMeetingsByYear(out.Season)
|
||||||
|
if err != nil {
|
||||||
|
return WeekendContext{}, err
|
||||||
|
}
|
||||||
|
if len(meetings) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
byMeeting := make(map[int][]store.Session, len(meetings))
|
||||||
|
var candidates []contextCandidate
|
||||||
|
for i := range meetings {
|
||||||
|
m := meetings[i]
|
||||||
|
sessions, listErr := s.store.ListSessionsByMeeting(m.MeetingKey)
|
||||||
|
if listErr != nil {
|
||||||
|
return WeekendContext{}, listErr
|
||||||
|
}
|
||||||
|
byMeeting[m.MeetingKey] = sessions
|
||||||
|
applySessionDisplayRange(&meetings[i], sessions)
|
||||||
|
m = meetings[i]
|
||||||
|
for _, sess := range sessions {
|
||||||
|
if m.IsCancelled || sess.IsCancelled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start, _ := parseContextTime(sess.DateStart)
|
||||||
|
end, _ := parseContextTime(sess.DateEnd)
|
||||||
|
if end.IsZero() && !start.IsZero() {
|
||||||
|
end = start.Add(3 * time.Hour)
|
||||||
|
}
|
||||||
|
counts, countErr := s.store.CountSessionDatasets(sess.SessionKey)
|
||||||
|
if countErr != nil {
|
||||||
|
return WeekendContext{}, countErr
|
||||||
|
}
|
||||||
|
archived := evidence.Final && liveMatches(evidence, m, sess)
|
||||||
|
candidates = append(candidates, contextCandidate{
|
||||||
|
meeting: m, session: sess, start: start, end: end, counts: counts,
|
||||||
|
complete: hasMeaningfulAnalysis(counts) || archived, archived: archived,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].start.Before(candidates[j].start) })
|
||||||
|
|
||||||
|
champMeetings := championshipMeetings(meetings, byMeeting)
|
||||||
|
out.TotalChampionshipRounds = len(champMeetings)
|
||||||
|
|
||||||
|
var active *contextCandidate
|
||||||
|
if evidence.Active {
|
||||||
|
for i := range candidates {
|
||||||
|
if liveMatches(evidence, candidates[i].meeting, candidates[i].session) {
|
||||||
|
active = &candidates[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if active == nil {
|
||||||
|
active = syntheticLiveCandidate(evidence, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var previous, next, defaultAnalysis *contextCandidate
|
||||||
|
for i := range candidates {
|
||||||
|
c := &candidates[i]
|
||||||
|
isActive := active != nil && active.session.SessionKey != 0 && c.session.SessionKey == active.session.SessionKey
|
||||||
|
completionEligible := c.start.IsZero() || !c.start.After(now) || c.archived
|
||||||
|
if !isActive && c.complete && completionEligible && (previous == nil || candidateTime(*c).After(candidateTime(*previous))) {
|
||||||
|
previous = c
|
||||||
|
}
|
||||||
|
if !isActive && c.complete && hasMeaningfulAnalysis(c.counts) && (c.start.IsZero() || !c.start.After(now)) && (defaultAnalysis == nil || candidateTime(*c).After(candidateTime(*defaultAnalysis))) {
|
||||||
|
defaultAnalysis = c
|
||||||
|
}
|
||||||
|
if !isActive && !c.start.IsZero() && !c.start.Before(now) && (next == nil || c.start.Before(next.start)) {
|
||||||
|
next = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if previous != nil {
|
||||||
|
out.PreviousCompletedSession = sessionRef(*previous, evidence, now)
|
||||||
|
out.PreviousMeeting = meetingModelByKey(meetings, previous.meeting.MeetingKey)
|
||||||
|
}
|
||||||
|
if defaultAnalysis != nil {
|
||||||
|
out.DefaultAnalysisSession = sessionRef(*defaultAnalysis, evidence, now)
|
||||||
|
}
|
||||||
|
if next != nil {
|
||||||
|
out.NextSession = sessionRef(*next, evidence, now)
|
||||||
|
out.NextMeeting = meetingModelByKey(meetings, next.meeting.MeetingKey)
|
||||||
|
}
|
||||||
|
if active != nil {
|
||||||
|
out.ActiveSession = sessionRef(*active, evidence, now)
|
||||||
|
out.FocusMeeting = out.ActiveSession.Meeting
|
||||||
|
out.TemporalState = TemporalSessionLive
|
||||||
|
} else {
|
||||||
|
out.FocusMeeting = chooseFocusMeeting(meetings, previous, next)
|
||||||
|
out.TemporalState = classifyTemporalState(now, previous, next, candidates, champMeetings, championshipScheduleUnknown(champMeetings, byMeeting))
|
||||||
|
}
|
||||||
|
if out.FocusMeeting != nil {
|
||||||
|
out.ChampionshipRound = championshipRound(champMeetings, int(out.FocusMeeting.MeetingKey))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentLocalSeason(years []int, current int) int {
|
||||||
|
for _, year := range years {
|
||||||
|
if year == current {
|
||||||
|
return year
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, year := range years {
|
||||||
|
if year < current {
|
||||||
|
return year
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return years[len(years)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseContextTime(value string) (time.Time, bool) {
|
||||||
|
if value == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05"} {
|
||||||
|
if parsed, err := time.Parse(layout, value); err == nil {
|
||||||
|
return parsed.UTC(), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasMeaningfulAnalysis(c store.SessionDatasetCounts) bool {
|
||||||
|
return c.Results > 0 || c.Laps > 0 || c.Stints > 0 || c.Positions > 0 || c.RaceControl > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateTime(c contextCandidate) time.Time {
|
||||||
|
if !c.end.IsZero() {
|
||||||
|
return c.end
|
||||||
|
}
|
||||||
|
return c.start
|
||||||
|
}
|
||||||
|
|
||||||
|
func liveMatches(e LiveEvidence, m store.Meeting, s store.Session) bool {
|
||||||
|
meetingMatch := normalizedContains(e.MeetingName, m.MeetingName) || normalizedContains(e.CircuitName, m.CircuitShortName)
|
||||||
|
sessionMatch := normalizedEqual(e.SessionName, s.SessionName)
|
||||||
|
if normalizeIdentity(e.SessionName) == "" {
|
||||||
|
sessionMatch = normalizedEqual(e.SessionType, s.SessionType)
|
||||||
|
}
|
||||||
|
return meetingMatch && sessionMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedEqual(a, b string) bool {
|
||||||
|
return normalizeIdentity(a) != "" && normalizeIdentity(a) == normalizeIdentity(b)
|
||||||
|
}
|
||||||
|
func normalizedContains(a, b string) bool {
|
||||||
|
a, b = normalizeIdentity(a), normalizeIdentity(b)
|
||||||
|
return a != "" && b != "" && (strings.Contains(a, b) || strings.Contains(b, a))
|
||||||
|
}
|
||||||
|
func normalizeIdentity(v string) string {
|
||||||
|
return strings.Map(func(r rune) rune {
|
||||||
|
if r >= 'A' && r <= 'Z' {
|
||||||
|
return r + ('a' - 'A')
|
||||||
|
}
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func syntheticLiveCandidate(e LiveEvidence, now time.Time) *contextCandidate {
|
||||||
|
return &contextCandidate{meeting: store.Meeting{MeetingName: e.MeetingName, CircuitShortName: e.CircuitName}, session: store.Session{SessionName: e.SessionName, SessionType: e.SessionType, DateStart: now.Format(time.RFC3339)}, start: now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySessionDisplayRange(m *store.Meeting, sessions []store.Session) {
|
||||||
|
var first, last time.Time
|
||||||
|
for _, sess := range sessions {
|
||||||
|
if sess.IsCancelled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start, ok := parseContextTime(sess.DateStart)
|
||||||
|
if ok && (first.IsZero() || start.Before(first)) {
|
||||||
|
first = start
|
||||||
|
}
|
||||||
|
end, ok := parseContextTime(sess.DateEnd)
|
||||||
|
if !ok {
|
||||||
|
end = start
|
||||||
|
}
|
||||||
|
if !end.IsZero() && (last.IsZero() || end.After(last)) {
|
||||||
|
last = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !first.IsZero() {
|
||||||
|
m.DateStart = first.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if !last.IsZero() {
|
||||||
|
m.DateEnd = last.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingModelByKey(meetings []store.Meeting, key int) *models.Meeting {
|
||||||
|
for _, meeting := range meetings {
|
||||||
|
if meeting.MeetingKey == key {
|
||||||
|
model := meetingToModel(meeting)
|
||||||
|
return &model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *ContextSession {
|
||||||
|
session := sessionToModel(c.session)
|
||||||
|
meeting := meetingToModel(c.meeting)
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.counts.Results > 0 && (c.counts.Laps+c.counts.Stints+c.counts.Positions+c.counts.RaceControl > 0) {
|
||||||
|
availability.LocalAnalysis = "complete"
|
||||||
|
} else if hasMeaningfulAnalysis(c.counts) || c.counts.Drivers+c.counts.StartingGrid+c.counts.Weather > 0 {
|
||||||
|
availability.LocalAnalysis = "partial"
|
||||||
|
} else if !c.start.IsZero() && c.start.After(now) {
|
||||||
|
availability.LocalAnalysis = "not_applicable"
|
||||||
|
} 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}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chooseFocusMeeting(meetings []store.Meeting, previous, next *contextCandidate) *models.Meeting {
|
||||||
|
if next != nil {
|
||||||
|
return meetingModelByKey(meetings, next.meeting.MeetingKey)
|
||||||
|
}
|
||||||
|
if previous != nil {
|
||||||
|
return meetingModelByKey(meetings, previous.meeting.MeetingKey)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyTemporalState(now time.Time, previous, next *contextCandidate, candidates []contextCandidate, championship []store.Meeting, scheduleUnknown bool) TemporalState {
|
||||||
|
var latestStarted *contextCandidate
|
||||||
|
for i := range candidates {
|
||||||
|
if !candidates[i].start.IsZero() && !candidates[i].start.After(now) && (latestStarted == nil || candidates[i].start.After(latestStarted.start)) {
|
||||||
|
latestStarted = &candidates[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Once a new meeting enters its preparation window, an ingest gap from an
|
||||||
|
// older meeting must not keep the product stuck in settling.
|
||||||
|
if next != nil && next.start.Sub(now) <= preSessionWindow && (latestStarted == nil || latestStarted.meeting.MeetingKey != next.meeting.MeetingKey) {
|
||||||
|
return TemporalPreSession
|
||||||
|
}
|
||||||
|
if latestStarted != nil && !latestStarted.complete && !latestStarted.end.IsZero() {
|
||||||
|
if now.Before(latestStarted.end) {
|
||||||
|
return TemporalPreSession
|
||||||
|
}
|
||||||
|
return TemporalSessionSettling
|
||||||
|
}
|
||||||
|
if previous != nil && next != nil && previous.meeting.MeetingKey == next.meeting.MeetingKey {
|
||||||
|
return TemporalBetweenSessions
|
||||||
|
}
|
||||||
|
if previous != nil && meetingFinalSession(*previous, candidates) && !candidateTime(*previous).After(now) && now.Sub(candidateTime(*previous)) <= postWeekendWindow {
|
||||||
|
return TemporalPostWeekend
|
||||||
|
}
|
||||||
|
if next != nil && next.start.Sub(now) <= preSessionWindow {
|
||||||
|
return TemporalPreSession
|
||||||
|
}
|
||||||
|
if next == nil && len(championship) > 0 && !scheduleUnknown {
|
||||||
|
return TemporalSeasonComplete
|
||||||
|
}
|
||||||
|
return TemporalBetweenWeekends
|
||||||
|
}
|
||||||
|
|
||||||
|
func championshipScheduleUnknown(meetings []store.Meeting, sessions map[int][]store.Session) bool {
|
||||||
|
for _, meeting := range meetings {
|
||||||
|
for _, session := range sessions[meeting.MeetingKey] {
|
||||||
|
if !session.IsCancelled && isChampionshipRace(session) {
|
||||||
|
if _, ok := parseContextTime(session.DateStart); !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingFinalSession(previous contextCandidate, candidates []contextCandidate) bool {
|
||||||
|
latest := previous.start
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c.meeting.MeetingKey == previous.meeting.MeetingKey && c.start.After(latest) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func championshipMeetings(meetings []store.Meeting, sessions map[int][]store.Session) []store.Meeting {
|
||||||
|
var out []store.Meeting
|
||||||
|
for _, m := range meetings {
|
||||||
|
if m.IsCancelled || isTestMeeting(m) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, sess := range sessions[m.MeetingKey] {
|
||||||
|
if !sess.IsCancelled && isChampionshipRace(sess) {
|
||||||
|
out = append(out, m)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
a, _ := parseContextTime(out[i].DateStart)
|
||||||
|
b, _ := parseContextTime(out[j].DateStart)
|
||||||
|
return a.Before(b)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTestMeeting(m store.Meeting) bool {
|
||||||
|
n := strings.ToLower(m.MeetingName + " " + m.MeetingOfficialName)
|
||||||
|
return strings.Contains(n, "test")
|
||||||
|
}
|
||||||
|
func isChampionshipRace(s store.Session) bool {
|
||||||
|
n := strings.ToLower(s.SessionName)
|
||||||
|
t := strings.ToLower(s.SessionType)
|
||||||
|
return (n == "race" || t == "race") && !strings.Contains(n, "sprint") && !strings.Contains(t, "sprint")
|
||||||
|
}
|
||||||
|
func championshipRound(meetings []store.Meeting, key int) int {
|
||||||
|
for i, m := range meetings {
|
||||||
|
if m.MeetingKey == key {
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
401
internal/query/context_test.go
Normal file
401
internal/query/context_test.go
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
package query
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func contextService(t *testing.T, now time.Time) *Service {
|
||||||
|
t.Helper()
|
||||||
|
base := openTestService(t)
|
||||||
|
return NewServiceWithClock(base.store, func() time.Time { return now })
|
||||||
|
}
|
||||||
|
|
||||||
|
func addContextMeeting(t *testing.T, svc *Service, key int, name, start, end string, cancelled bool) {
|
||||||
|
t.Helper()
|
||||||
|
if err := svc.store.UpsertMeeting(store.Meeting{MeetingKey: key, MeetingName: name, MeetingOfficialName: name, CircuitShortName: name, Year: 2026, DateStart: start, DateEnd: end, IsCancelled: cancelled}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addContextSession(t *testing.T, svc *Service, key, meeting int, name, start, end string, cancelled bool) {
|
||||||
|
t.Helper()
|
||||||
|
if err := svc.store.UpsertSession(store.Session{SessionKey: key, MeetingKey: meeting, SessionName: name, SessionType: name, DateStart: start, DateEnd: end, IsCancelled: cancelled}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeContextSession(t *testing.T, svc *Service, key, meeting int) {
|
||||||
|
t.Helper()
|
||||||
|
if err := svc.store.UpsertSessionResult(store.SessionResult{SessionKey: key, MeetingKey: meeting, DriverNumber: 1, Position: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextTemporalStates(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
now string
|
||||||
|
seed func(*testing.T, *Service)
|
||||||
|
evidence LiveEvidence
|
||||||
|
want TemporalState
|
||||||
|
}{
|
||||||
|
{name: "no season", now: "2026-06-01T12:00:00Z", want: TemporalNoSeason},
|
||||||
|
{name: "between weekends", now: "2026-06-10T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 1, "Monaco Grand Prix", "2026-06-01T09:00:00Z", "2026-06-02T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 11, 1, "Race", "2026-06-02T14:00:00Z", "2026-06-02T16:00:00Z", false)
|
||||||
|
completeContextSession(t, s, 11, 1)
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
}, want: TemporalBetweenWeekends},
|
||||||
|
{name: "pre session", now: "2026-06-19T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
}, want: TemporalPreSession},
|
||||||
|
{name: "session live overrides schedule", now: "2026-06-20T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
}, evidence: LiveEvidence{Active: true, MeetingName: "Canadian Grand Prix", CircuitName: "Canada Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1"}, want: TemporalSessionLive},
|
||||||
|
{name: "session settling", now: "2026-06-20T11:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
}, want: TemporalSessionSettling},
|
||||||
|
{name: "between sessions", now: "2026-06-20T11:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 21, 2, "Practice 1", "2026-06-20T09:00:00Z", "2026-06-20T10:00:00Z", false)
|
||||||
|
completeContextSession(t, s, 21, 2)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
}, want: TemporalBetweenSessions},
|
||||||
|
{name: "post weekend", now: "2026-06-22T18:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
completeContextSession(t, s, 22, 2)
|
||||||
|
}, want: TemporalPostWeekend},
|
||||||
|
{name: "season complete", now: "2026-06-30T12:00:00Z", seed: func(t *testing.T, s *Service) {
|
||||||
|
addContextMeeting(t, s, 2, "Canada Grand Prix", "2026-06-20T09:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
addContextSession(t, s, 22, 2, "Race", "2026-06-22T14:00:00Z", "2026-06-22T16:00:00Z", false)
|
||||||
|
completeContextSession(t, s, 22, 2)
|
||||||
|
}, want: TemporalSeasonComplete},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, tt.now)
|
||||||
|
svc := contextService(t, now)
|
||||||
|
if tt.seed != nil {
|
||||||
|
tt.seed(t, svc)
|
||||||
|
}
|
||||||
|
got, err := svc.ResolveWeekendContext(tt.evidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != tt.want {
|
||||||
|
t.Fatalf("state = %s, want %s; context=%+v", got.TemporalState, tt.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextTruthRules(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-10T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "Pre-Season Testing", "2026-02-01T00:00:00Z", "2026-02-03T00:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 10, 1, "Race", "2026-02-03T10:00:00Z", "2026-02-03T12:00:00Z", false)
|
||||||
|
addContextMeeting(t, svc, 2, "Cancelled Grand Prix", "2026-03-01T00:00:00Z", "2026-03-03T00:00:00Z", true)
|
||||||
|
addContextSession(t, svc, 20, 2, "Race", "2026-03-03T10:00:00Z", "2026-03-03T12:00:00Z", false)
|
||||||
|
addContextMeeting(t, svc, 3, "British Grand Prix", "2026-07-01T00:00:00Z", "2026-07-05T00:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 30, 3, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 31, 3, "Sprint", "2026-07-04T10:00:00Z", "2026-07-04T11:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 32, 3, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 32, 3)
|
||||||
|
addContextMeeting(t, svc, 4, "Belgian Grand Prix", "2026-07-16T00:00:00Z", "2026-07-18T00:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 40, 4, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 41, 4, "Cancelled Practice", "2026-07-18T09:00:00Z", "2026-07-18T10:00:00Z", true)
|
||||||
|
addContextSession(t, svc, 42, 4, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||||
|
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TotalChampionshipRounds != 2 || got.ChampionshipRound != 2 {
|
||||||
|
t.Fatalf("rounds = %d/%d, want 2/2", got.ChampionshipRound, got.TotalChampionshipRounds)
|
||||||
|
}
|
||||||
|
if got.DefaultAnalysisSession == nil || got.DefaultAnalysisSession.Session.SessionKey != 32 {
|
||||||
|
t.Fatalf("default analysis = %+v, want completed race 32", got.DefaultAnalysisSession)
|
||||||
|
}
|
||||||
|
if got.NextSession == nil || got.NextSession.Session.SessionKey != 40 {
|
||||||
|
t.Fatalf("next = %+v, want 40", got.NextSession)
|
||||||
|
}
|
||||||
|
if got.NextMeeting.DateStart != "2026-07-17T09:00:00Z" || got.NextMeeting.DateEnd != "2026-07-19T16:00:00Z" {
|
||||||
|
t.Fatalf("display range = %s..%s", got.NextMeeting.DateStart, got.NextMeeting.DateEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextPassedTimeDoesNotCompleteSession(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)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession != nil || got.DefaultAnalysisSession != nil {
|
||||||
|
t.Fatalf("passed schedule was treated complete: %+v", got)
|
||||||
|
}
|
||||||
|
archive, 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 archive.PreviousCompletedSession == nil || archive.PreviousCompletedSession.Availability.Archive != "available" {
|
||||||
|
t.Fatalf("final archive not used: %+v", archive)
|
||||||
|
}
|
||||||
|
if archive.DefaultAnalysisSession != nil {
|
||||||
|
t.Fatal("archive without local analysis must not become default analysis")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
completeContextSession(t, svc, 11, 1) // bad/preloaded data must not make a future session canonical
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession != nil || got.DefaultAnalysisSession != nil {
|
||||||
|
t.Fatalf("future analysis selected: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression for the v0.4.0 release blocker (#90). Production held a run
|
||||||
|
// British race (11326) and an unrun Belgian race (11334) two weekends apart.
|
||||||
|
// Analysis must resolve to the race that actually happened, even when the
|
||||||
|
// later meeting carries preloaded rows.
|
||||||
|
func TestResolveWeekendContextPrefersRunRaceOverLaterUnrunRace(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-13T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1289, "British Grand Prix", "2026-07-03T11:30:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11326, 1289, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 11326, 1289)
|
||||||
|
addContextMeeting(t, svc, 1290, "Belgian Grand Prix", "2026-07-17T11:30:00Z", "2026-07-19T15:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11334, 1290, "Race", "2026-07-19T13:00:00Z", "2026-07-19T15:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 11334, 1290)
|
||||||
|
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.DefaultAnalysisSession == nil {
|
||||||
|
t.Fatal("no analysis session resolved")
|
||||||
|
}
|
||||||
|
if key := got.DefaultAnalysisSession.Session.SessionKey; key != 11326 {
|
||||||
|
t.Fatalf("analysis session = %d, want 11326 (British); 11334 is the unrun Belgian race", key)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Session.SessionKey != 11326 {
|
||||||
|
t.Fatalf("previous completed = %+v, want 11326", got.PreviousCompletedSession)
|
||||||
|
}
|
||||||
|
if got.NextSession == nil || got.NextSession.Session.SessionKey != 11334 {
|
||||||
|
t.Fatalf("next session = %+v, want 11334", got.NextSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextSprintWeekendHandoff(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-04T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Sprint", "2026-07-04T10:00:00Z", "2026-07-04T11:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 11, 1)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalBetweenSessions || got.PreviousCompletedSession.Session.SessionKey != 11 || got.NextSession.Session.SessionKey != 12 {
|
||||||
|
t.Fatalf("sprint handoff = %+v", got)
|
||||||
|
}
|
||||||
|
if got.TotalChampionshipRounds != 1 {
|
||||||
|
t.Fatalf("sprint created extra championship round: %d", got.TotalChampionshipRounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextBoundaryTimestamps(t *testing.T) {
|
||||||
|
t.Run("pre-session window is inclusive", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-01T09:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalPreSession {
|
||||||
|
t.Fatalf("state = %s", got.TemporalState)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("scheduled end enters settling", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-03T10:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Practice 1", "2026-07-03T09:00:00Z", "2026-07-03T10:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalSessionSettling {
|
||||||
|
t.Fatalf("state = %s", got.TemporalState)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("post-weekend window is inclusive", func(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-07T16:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 12, 1)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalPostWeekend {
|
||||||
|
t.Fatalf("state = %s", got.TemporalState)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextPartialFutureSchedule(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Practice 1", "", "", false)
|
||||||
|
addContextSession(t, svc, 12, 1, "Race", "2026-07-05T14:00:00Z", "", false)
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.NextSession == nil || got.NextSession.Session.SessionKey != 12 {
|
||||||
|
t.Fatalf("partial schedule next = %+v", got.NextSession)
|
||||||
|
}
|
||||||
|
if got.FocusMeeting == nil || got.FocusMeeting.DateStart != "2026-07-05T14:00:00Z" || got.FocusMeeting.DateEnd != "2026-07-05T14:00:00Z" {
|
||||||
|
t.Fatalf("partial display range = %+v", got.FocusMeeting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextActiveSessionIsNotCompletedOrDefault(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-05T15:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 10, 1, "Qualifying", "2026-07-04T14:00:00Z", "2026-07-04T15:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 10, 1)
|
||||||
|
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
completeContextSession(t, svc, 11, 1)
|
||||||
|
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.ActiveSession == nil || got.ActiveSession.Session.SessionKey != 11 {
|
||||||
|
t.Fatalf("active = %+v", got.ActiveSession)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Session.SessionKey != 10 {
|
||||||
|
t.Fatalf("previous = %+v, want earlier completed session", got.PreviousCompletedSession)
|
||||||
|
}
|
||||||
|
if got.DefaultAnalysisSession == nil || got.DefaultAnalysisSession.Session.SessionKey != 10 {
|
||||||
|
t.Fatalf("default = %+v, want earlier completed session", got.DefaultAnalysisSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextOldIncompleteSessionDoesNotSuppressNextWeekend(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-16T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T09: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)
|
||||||
|
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T09: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)
|
||||||
|
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalPreSession {
|
||||||
|
t.Fatalf("state = %s, want %s; context=%+v", got.TemporalState, TemporalPreSession, got)
|
||||||
|
}
|
||||||
|
if got.FocusMeeting == nil || got.FocusMeeting.MeetingKey != 2 {
|
||||||
|
t.Fatalf("focus = %+v, want Belgian weekend", got.FocusMeeting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextMissingScheduleDoesNotClaimSeasonComplete(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "", "", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Race", "", "", false)
|
||||||
|
|
||||||
|
got, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != TemporalBetweenWeekends {
|
||||||
|
t.Fatalf("state = %s, want limited %s context", got.TemporalState, TemporalBetweenWeekends)
|
||||||
|
}
|
||||||
|
if got.TotalChampionshipRounds != 1 {
|
||||||
|
t.Fatalf("total rounds = %d, want scheduled round retained", got.TotalChampionshipRounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package query
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/chapters"
|
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
@@ -12,11 +13,20 @@ import (
|
|||||||
// Service assembles store-backed read models.
|
// Service assembles store-backed read models.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a query service over a domain store.
|
// NewService creates a query service over a domain store.
|
||||||
func NewService(st *store.Store) *Service {
|
func NewService(st *store.Store) *Service {
|
||||||
return &Service{store: st}
|
return NewServiceWithClock(st, time.Now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServiceWithClock creates a query service with an injected clock.
|
||||||
|
func NewServiceWithClock(st *store.Store, now func() time.Time) *Service {
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
|
return &Service{store: st, now: now}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnrichedResult is a session result with driver identity fields.
|
// EnrichedResult is a session result with driver identity fields.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
readability "codeberg.org/readeck/go-readability/v2"
|
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/models"
|
||||||
"github.com/AmanTahiliani/box-box/internal/query"
|
"github.com/AmanTahiliani/box-box/internal/query"
|
||||||
)
|
)
|
||||||
@@ -43,6 +44,7 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch parseSourceMode(r) {
|
switch parseSourceMode(r) {
|
||||||
case sourceLocal:
|
case sourceLocal:
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []models.Meeting{})
|
writeJSON(w, []models.Meeting{})
|
||||||
return
|
return
|
||||||
@@ -62,17 +64,20 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(meetings) > 0 {
|
if len(meetings) > 0 {
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, meetings)
|
writeJSON(w, meetings)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
meetings, err := s.client.GetMeetingsForYear(year)
|
client := s.client.Scoped()
|
||||||
|
meetings, err := client.GetMeetingsForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, meetings)
|
writeJSON(w, meetings)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +92,7 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch parseSourceMode(r) {
|
switch parseSourceMode(r) {
|
||||||
case sourceLocal:
|
case sourceLocal:
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []models.Session{})
|
writeJSON(w, []models.Session{})
|
||||||
return
|
return
|
||||||
@@ -106,23 +112,27 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(sessions) > 0 {
|
if len(sessions) > 0 {
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, sessions)
|
writeJSON(w, sessions)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions, err := s.client.GetSessionsForMeeting(meetingKey)
|
client := s.client.Scoped()
|
||||||
|
sessions, err := client.GetSessionsForMeeting(meetingKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, sessions)
|
writeJSON(w, sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- /api/v1/news ---
|
// --- /api/v1/news ---
|
||||||
|
|
||||||
func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) {
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []query.NewsItem{})
|
writeJSON(w, []query.NewsItem{})
|
||||||
return
|
return
|
||||||
@@ -223,6 +233,7 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch parseSourceMode(r) {
|
switch parseSourceMode(r) {
|
||||||
case sourceLocal:
|
case sourceLocal:
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []models.Driver{})
|
writeJSON(w, []models.Driver{})
|
||||||
return
|
return
|
||||||
@@ -242,6 +253,7 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
|
|||||||
if s.hasLocalQuery() {
|
if s.hasLocalQuery() {
|
||||||
drivers, err := s.query.ListDrivers(sessionKey)
|
drivers, err := s.query.ListDrivers(sessionKey)
|
||||||
if err == nil && len(drivers) > 0 {
|
if err == nil && len(drivers) > 0 {
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, drivers)
|
writeJSON(w, drivers)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, drivers)
|
writeJSON(w, drivers)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +293,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch parseSourceMode(r) {
|
switch parseSourceMode(r) {
|
||||||
case sourceLocal:
|
case sourceLocal:
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []resultWithDriver{})
|
writeJSON(w, []resultWithDriver{})
|
||||||
return
|
return
|
||||||
@@ -294,6 +309,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
if s.hasLocalQuery() {
|
if s.hasLocalQuery() {
|
||||||
results, err := s.query.ListResults(sessionKey)
|
results, err := s.query.ListResults(sessionKey)
|
||||||
if err == nil && len(results) > 0 {
|
if err == nil && len(results) > 0 {
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, enrichedResultsToAPI(results))
|
writeJSON(w, enrichedResultsToAPI(results))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -308,30 +324,42 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
results []models.SessionResult
|
results []models.SessionResult
|
||||||
drivers []models.Driver
|
drivers []models.Driver
|
||||||
resultsErr error
|
resultsErr error
|
||||||
|
driversErr error
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
)
|
)
|
||||||
|
client := s.client.Scoped()
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
go func() { defer wg.Done(); results, resultsErr = s.client.GetSessionResult(sessionKey) }()
|
go func() { defer wg.Done(); results, resultsErr = client.GetSessionResult(sessionKey) }()
|
||||||
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
|
go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if resultsErr != nil {
|
if resultsErr != nil {
|
||||||
writeError(w, resultsErr, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, resultsErr, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
driverMap := buildDriverMap(drivers)
|
driverMap := buildDriverMap(drivers)
|
||||||
|
incomplete := driversErr != nil
|
||||||
enriched := make([]resultWithDriver, 0, len(results))
|
enriched := make([]resultWithDriver, 0, len(results))
|
||||||
for _, res := range results {
|
for _, res := range results {
|
||||||
e := resultWithDriver{SessionResult: res}
|
e := resultWithDriver{SessionResult: res}
|
||||||
if d, ok := driverMap[res.DriverNumber]; ok {
|
if d, ok := driverMap[res.DriverNumber]; ok && hasDriverPresentation(d) {
|
||||||
e.NameAcronym = d.NameAcronym
|
e.NameAcronym = d.NameAcronym
|
||||||
e.FullName = d.FullName
|
e.FullName = d.FullName
|
||||||
e.TeamName = d.TeamName
|
e.TeamName = d.TeamName
|
||||||
e.TeamColour = d.TeamColour
|
e.TeamColour = d.TeamColour
|
||||||
|
} else {
|
||||||
|
incomplete = true
|
||||||
}
|
}
|
||||||
enriched = append(enriched, e)
|
enriched = append(enriched, e)
|
||||||
}
|
}
|
||||||
|
resultsFreshness := "fresh"
|
||||||
|
if len(results) == 0 {
|
||||||
|
resultsFreshness = "limited"
|
||||||
|
} else if incomplete {
|
||||||
|
resultsFreshness = "partial"
|
||||||
|
}
|
||||||
|
markOpenF1Availability(w, client, resultsFreshness)
|
||||||
writeJSON(w, enriched)
|
writeJSON(w, enriched)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +382,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch parseSourceMode(r) {
|
switch parseSourceMode(r) {
|
||||||
case sourceLocal:
|
case sourceLocal:
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []gridWithDriver{})
|
writeJSON(w, []gridWithDriver{})
|
||||||
return
|
return
|
||||||
@@ -369,6 +398,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
|
|||||||
if s.hasLocalQuery() {
|
if s.hasLocalQuery() {
|
||||||
grid, err := s.query.ListStartingGrid(sessionKey)
|
grid, err := s.query.ListStartingGrid(sessionKey)
|
||||||
if err == nil && len(grid) > 0 {
|
if err == nil && len(grid) > 0 {
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, enrichedGridToAPI(grid))
|
writeJSON(w, enrichedGridToAPI(grid))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -383,30 +413,42 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
|
|||||||
grid []models.StartingGrid
|
grid []models.StartingGrid
|
||||||
drivers []models.Driver
|
drivers []models.Driver
|
||||||
gridErr error
|
gridErr error
|
||||||
|
driversErr error
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
)
|
)
|
||||||
|
client := s.client.Scoped()
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
go func() { defer wg.Done(); grid, gridErr = s.client.GetStartingGrid(sessionKey) }()
|
go func() { defer wg.Done(); grid, gridErr = client.GetStartingGrid(sessionKey) }()
|
||||||
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
|
go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if gridErr != nil {
|
if gridErr != nil {
|
||||||
writeError(w, gridErr, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, gridErr, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
driverMap := buildDriverMap(drivers)
|
driverMap := buildDriverMap(drivers)
|
||||||
|
incomplete := driversErr != nil
|
||||||
enriched := make([]gridWithDriver, 0, len(grid))
|
enriched := make([]gridWithDriver, 0, len(grid))
|
||||||
for _, g := range grid {
|
for _, g := range grid {
|
||||||
e := gridWithDriver{StartingGrid: g}
|
e := gridWithDriver{StartingGrid: g}
|
||||||
if d, ok := driverMap[g.DriverNumber]; ok {
|
if d, ok := driverMap[g.DriverNumber]; ok && hasDriverPresentation(d) {
|
||||||
e.NameAcronym = d.NameAcronym
|
e.NameAcronym = d.NameAcronym
|
||||||
e.FullName = d.FullName
|
e.FullName = d.FullName
|
||||||
e.TeamName = d.TeamName
|
e.TeamName = d.TeamName
|
||||||
e.TeamColour = d.TeamColour
|
e.TeamColour = d.TeamColour
|
||||||
|
} else {
|
||||||
|
incomplete = true
|
||||||
}
|
}
|
||||||
enriched = append(enriched, e)
|
enriched = append(enriched, e)
|
||||||
}
|
}
|
||||||
|
gridFreshness := "fresh"
|
||||||
|
if len(grid) == 0 {
|
||||||
|
gridFreshness = "limited"
|
||||||
|
} else if incomplete {
|
||||||
|
gridFreshness = "partial"
|
||||||
|
}
|
||||||
|
markOpenF1Availability(w, client, gridFreshness)
|
||||||
writeJSON(w, enriched)
|
writeJSON(w, enriched)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,26 +461,29 @@ func (s *Server) handleLaps(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client := s.client.Scoped()
|
||||||
if dnStr := r.URL.Query().Get("driver_number"); dnStr != "" {
|
if dnStr := r.URL.Query().Get("driver_number"); dnStr != "" {
|
||||||
driverNumber, err := strconv.Atoi(dnStr)
|
driverNumber, err := strconv.Atoi(dnStr)
|
||||||
if err != nil || driverNumber == 0 {
|
if err != nil || driverNumber == 0 {
|
||||||
http.Error(w, "invalid driver_number", http.StatusBadRequest)
|
http.Error(w, "invalid driver_number", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
laps, err := s.client.GetLapsForDriver(sessionKey, driverNumber)
|
laps, err := client.GetLapsForDriver(sessionKey, driverNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, laps)
|
writeJSON(w, laps)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
laps, err := s.client.GetLapsForSession(sessionKey)
|
laps, err := client.GetLapsForSession(sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, laps)
|
writeJSON(w, laps)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,11 +495,13 @@ func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "session_key required", http.StatusBadRequest)
|
http.Error(w, "session_key required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
weather, err := s.client.GetWeather(sessionKey)
|
client := s.client.Scoped()
|
||||||
|
weather, err := client.GetWeather(sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, weather)
|
writeJSON(w, weather)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,11 +513,13 @@ func (s *Server) handleRaceControl(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "session_key required", http.StatusBadRequest)
|
http.Error(w, "session_key required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rc, err := s.client.GetRaceControl(sessionKey)
|
client := s.client.Scoped()
|
||||||
|
rc, err := client.GetRaceControl(sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, rc)
|
writeJSON(w, rc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,11 +536,13 @@ func (s *Server) handleTelemetry(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "driver_number required", http.StatusBadRequest)
|
http.Error(w, "driver_number required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
carData, err := s.client.GetCarData(sessionKey, driverNumber)
|
client := s.client.Scoped()
|
||||||
|
carData, err := client.GetCarData(sessionKey, driverNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, carData)
|
writeJSON(w, carData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,11 +554,13 @@ func (s *Server) handleOvertakes(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "session_key required", http.StatusBadRequest)
|
http.Error(w, "session_key required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
overtakes, err := s.client.GetOvertakesForSession(sessionKey)
|
client := s.client.Scoped()
|
||||||
|
overtakes, err := client.GetOvertakesForSession(sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, overtakes)
|
writeJSON(w, overtakes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,11 +577,13 @@ func (s *Server) handleTeamRadio(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "driver_number required", http.StatusBadRequest)
|
http.Error(w, "driver_number required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
radios, err := s.client.GetTeamRadio(sessionKey, driverNumber)
|
client := s.client.Scoped()
|
||||||
|
radios, err := client.GetTeamRadio(sessionKey, driverNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, radios)
|
writeJSON(w, radios)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,36 +602,42 @@ func (s *Server) handleChampionshipDrivers(w http.ResponseWriter, r *http.Reques
|
|||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = time.Now().Year()
|
year = time.Now().Year()
|
||||||
}
|
}
|
||||||
champ, err := s.client.GetDriverChampionshipForYear(year)
|
client := s.client.Scoped()
|
||||||
|
champ, err := client.GetDriverChampionshipForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(champ) == 0 {
|
if len(champ) == 0 {
|
||||||
|
markOpenF1Availability(w, client, "limited")
|
||||||
writeJSON(w, []any{})
|
writeJSON(w, []any{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
drivers, _ := s.client.GetDriversForSession(champ[0].SessionKey)
|
drivers, driversErr := client.GetDriversForSession(champ[0].SessionKey)
|
||||||
driverMap := buildDriverMapFirst(drivers)
|
driverMap := buildDriverMapFirst(drivers)
|
||||||
|
incomplete := driversErr != nil
|
||||||
|
|
||||||
enriched := make([]champDriverWithInfo, 0, len(champ))
|
enriched := make([]champDriverWithInfo, 0, len(champ))
|
||||||
for _, c := range champ {
|
for _, c := range champ {
|
||||||
e := champDriverWithInfo{ChampionshipDriver: c}
|
e := champDriverWithInfo{ChampionshipDriver: c}
|
||||||
d, ok := s.championshipDriverInfo(c.SessionKey, c.DriverNumber, driverMap)
|
d, ok := championshipDriverInfo(client, c.SessionKey, c.DriverNumber, driverMap)
|
||||||
if ok {
|
if ok && hasDriverPresentation(d) {
|
||||||
e.NameAcronym = d.NameAcronym
|
e.NameAcronym = d.NameAcronym
|
||||||
e.FullName = d.FullName
|
e.FullName = d.FullName
|
||||||
e.TeamName = d.TeamName
|
e.TeamName = d.TeamName
|
||||||
e.TeamColour = d.TeamColour
|
e.TeamColour = d.TeamColour
|
||||||
|
} else {
|
||||||
|
incomplete = true
|
||||||
}
|
}
|
||||||
enriched = append(enriched, e)
|
enriched = append(enriched, e)
|
||||||
}
|
}
|
||||||
|
markOpenF1AggregateResponse(w, client, incomplete)
|
||||||
writeJSON(w, enriched)
|
writeJSON(w, enriched)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) championshipDriverInfo(sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) {
|
func championshipDriverInfo(client *api.OpenF1Client, sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) {
|
||||||
if d, err := s.client.GetDriver(sessionKey, driverNumber); err == nil && d != nil {
|
if d, err := client.GetDriver(sessionKey, driverNumber); err == nil && d != nil {
|
||||||
return *d, true
|
return *d, true
|
||||||
}
|
}
|
||||||
d, ok := fallback[driverNumber]
|
d, ok := fallback[driverNumber]
|
||||||
@@ -590,11 +651,17 @@ func (s *Server) handleChampionshipTeams(w http.ResponseWriter, r *http.Request)
|
|||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = time.Now().Year()
|
year = time.Now().Year()
|
||||||
}
|
}
|
||||||
teams, err := s.client.GetTeamChampionshipForYear(year)
|
client := s.client.Scoped()
|
||||||
|
teams, err := client.GetTeamChampionshipForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(teams) == 0 {
|
||||||
|
markOpenF1Availability(w, client, "limited")
|
||||||
|
} else {
|
||||||
|
markOpenF1Response(w, client)
|
||||||
|
}
|
||||||
writeJSON(w, teams)
|
writeJSON(w, teams)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,6 +739,8 @@ func champHubTTL(year int, now time.Time) time.Duration {
|
|||||||
|
|
||||||
type champHubEntry struct {
|
type champHubEntry struct {
|
||||||
resp champHubResponse
|
resp champHubResponse
|
||||||
|
source string
|
||||||
|
freshness string
|
||||||
expires time.Time
|
expires time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,12 +762,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) {
|
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()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
if c.entries == nil {
|
if c.entries == nil {
|
||||||
c.entries = map[int]champHubEntry{}
|
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.
|
// fetchMeetingRaces fans fetch out across meetings with bounded concurrency.
|
||||||
@@ -740,13 +823,6 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
mode := parseSourceMode(r)
|
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 {
|
if mode == sourceLocal || mode == sourceAuto {
|
||||||
resp, ok, err := s.localChampionshipHub(year)
|
resp, ok, err := s.localChampionshipHub(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -754,21 +830,33 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ok {
|
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)
|
writeJSON(w, resp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mode == sourceLocal {
|
if mode == sourceLocal {
|
||||||
|
markDataResponse(w, "none", "limited")
|
||||||
writeJSON(w, resp)
|
writeJSON(w, resp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := s.openF1ChampionshipHub(year)
|
// At this point an auto request has no usable domain aggregate and an
|
||||||
if err != nil {
|
// explicit OpenF1 request must not be satisfied by a local cache entry.
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
if resp, source, freshness, ok := s.hubCache.getWithMetadata(year, time.Now()); ok && source == "openf1" {
|
||||||
|
markDataResponse(w, source, freshness)
|
||||||
|
writeJSON(w, resp)
|
||||||
return
|
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)
|
writeJSON(w, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,25 +885,35 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error)
|
|||||||
return aggregateChampionshipHub(year, races, inputs.Champ, inputs.Teams, inputs.DriverMap), true, nil
|
return aggregateChampionshipHub(year, races, inputs.Champ, inputs.Teams, inputs.DriverMap), true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
|
func (s *Server) openF1ChampionshipHub(client *api.OpenF1Client, year int) (champHubResponse, bool, error) {
|
||||||
champ, err := s.client.GetDriverChampionshipForYear(year)
|
champ, err := client.GetDriverChampionshipForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return champHubResponse{}, err
|
return champHubResponse{}, false, err
|
||||||
}
|
}
|
||||||
if len(champ) == 0 {
|
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{}}, true, nil
|
||||||
}
|
}
|
||||||
teams, _ := s.client.GetTeamChampionshipForYear(year)
|
teams, teamsErr := client.GetTeamChampionshipForYear(year)
|
||||||
|
|
||||||
driverInfo := map[int]models.Driver{}
|
driverInfo := map[int]models.Driver{}
|
||||||
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil {
|
driversIncomplete := false
|
||||||
|
if ds, derr := client.GetDriversForSession(champ[0].SessionKey); derr == nil {
|
||||||
driverInfo = buildDriverMapFirst(ds)
|
driverInfo = buildDriverMapFirst(ds)
|
||||||
|
} else {
|
||||||
|
driversIncomplete = true
|
||||||
|
}
|
||||||
|
for _, standing := range champ {
|
||||||
|
if !hasDriverPresentation(driverInfo[standing.DriverNumber]) {
|
||||||
|
driversIncomplete = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
races, incomplete, err := s.fetchSeasonRaces(year)
|
races, incomplete, err := fetchSeasonRaces(client, year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return champHubResponse{}, err
|
return champHubResponse{}, false, err
|
||||||
}
|
}
|
||||||
|
incomplete = incomplete || teamsErr != nil || driversIncomplete
|
||||||
|
|
||||||
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
|
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
|
||||||
ttl := champHubTTL(year, time.Now())
|
ttl := champHubTTL(year, time.Now())
|
||||||
@@ -825,16 +923,22 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
|
|||||||
// so a partial view of the season doesn't stick around for the full TTL.
|
// so a partial view of the season doesn't stick around for the full TTL.
|
||||||
ttl = champHubIncompleteTTL
|
ttl = champHubIncompleteTTL
|
||||||
}
|
}
|
||||||
s.hubCache.put(year, resp, time.Now(), ttl)
|
freshness := "fresh"
|
||||||
return resp, nil
|
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
|
// fetchSeasonRaces returns a season's GP meetings in date order, each bundled
|
||||||
// with its race results and starting grid fetched from OpenF1. incomplete
|
// with its race results and starting grid fetched from OpenF1. incomplete
|
||||||
// reports whether any per-meeting fetch failed, so callers can avoid caching a
|
// reports whether any per-meeting fetch failed, so callers can avoid caching a
|
||||||
// partial view of the season for long.
|
// partial view of the season for long.
|
||||||
func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete bool, err error) {
|
func fetchSeasonRaces(client *api.OpenF1Client, year int) (races []meetingRace, incomplete bool, err error) {
|
||||||
meetings, err := s.client.GetMeetingsForYear(year)
|
meetings, err := client.GetMeetingsForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
@@ -842,7 +946,7 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
|
|||||||
|
|
||||||
var failed atomic.Bool
|
var failed atomic.Bool
|
||||||
races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, 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 {
|
if serr != nil {
|
||||||
failed.Store(true)
|
failed.Store(true)
|
||||||
return meetingRace{}, false
|
return meetingRace{}, false
|
||||||
@@ -855,10 +959,17 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if raceKey == 0 {
|
if raceKey == 0 {
|
||||||
return meetingRace{}, false // not a GP meeting (e.g. pre-season testing)
|
if isKnownNonChampionshipMeeting(m, sessions) {
|
||||||
|
return meetingRace{}, false
|
||||||
}
|
}
|
||||||
results, rerr := s.client.GetSessionResult(raceKey)
|
failed.Store(true)
|
||||||
grid, gerr := s.client.GetStartingGrid(raceKey)
|
// The meeting list does not identify non-championship events. Skipping
|
||||||
|
// a meeting without a Race may be expected (testing), but the aggregate
|
||||||
|
// is not proven complete and must be labelled partial.
|
||||||
|
return meetingRace{}, false
|
||||||
|
}
|
||||||
|
results, rerr := client.GetSessionResult(raceKey)
|
||||||
|
grid, gerr := client.GetStartingGrid(raceKey)
|
||||||
if rerr != nil || gerr != nil {
|
if rerr != nil || gerr != nil {
|
||||||
failed.Store(true)
|
failed.Store(true)
|
||||||
}
|
}
|
||||||
@@ -867,6 +978,29 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
|
|||||||
return races, failed.Load(), nil
|
return races, failed.Load(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isKnownNonChampionshipMeeting(meeting models.Meeting, sessions []models.Session) bool {
|
||||||
|
if hasTestingToken(meeting.MeetingName + " " + meeting.MeetingOfficialName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, session := range sessions {
|
||||||
|
if hasTestingToken(session.SessionName + " " + session.SessionType) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTestingToken(value string) bool {
|
||||||
|
for _, token := range strings.FieldsFunc(strings.ToLower(value), func(r rune) bool {
|
||||||
|
return (r < 'a' || r > 'z') && (r < '0' || r > '9')
|
||||||
|
}) {
|
||||||
|
if token == "test" || token == "tests" || token == "testing" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be
|
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be
|
||||||
// unit-tested with synthetic data. races must be ordered ascending by date and
|
// unit-tested with synthetic data. races must be ordered ascending by date and
|
||||||
// contain only GP meetings (those with a Race session).
|
// contain only GP meetings (those with a Race session).
|
||||||
@@ -1077,6 +1211,7 @@ type trackOutlineResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
|
||||||
|
markLocalResponse(w, false)
|
||||||
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
|
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
|
||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = time.Now().Year()
|
year = time.Now().Year()
|
||||||
@@ -1281,14 +1416,17 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
|
|||||||
stintsErr error
|
stintsErr error
|
||||||
pitsErr error
|
pitsErr error
|
||||||
resErr error
|
resErr error
|
||||||
|
driversErr error
|
||||||
|
rcErr error
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
)
|
)
|
||||||
|
client := s.client.Scoped()
|
||||||
wg.Add(5)
|
wg.Add(5)
|
||||||
go func() { defer wg.Done(); stints, stintsErr = s.client.GetStintsForSession(sessionKey) }()
|
go func() { defer wg.Done(); stints, stintsErr = client.GetStintsForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); pits, pitsErr = s.client.GetPitStopsForSession(sessionKey) }()
|
go func() { defer wg.Done(); pits, pitsErr = client.GetPitStopsForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); results, resErr = s.client.GetSessionResult(sessionKey) }()
|
go func() { defer wg.Done(); results, resErr = client.GetSessionResult(sessionKey) }()
|
||||||
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
|
go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }()
|
go func() { defer wg.Done(); rc, rcErr = client.GetRaceControl(sessionKey) }()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if stintsErr != nil || pitsErr != nil || resErr != nil {
|
if stintsErr != nil || pitsErr != nil || resErr != nil {
|
||||||
@@ -1299,17 +1437,21 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
|
|||||||
if e == nil {
|
if e == nil {
|
||||||
e = resErr
|
e = resErr
|
||||||
}
|
}
|
||||||
writeError(w, e, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, e, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-race sessions have no stints.
|
// Empty strategy data may mean a non-race session or a race still settling.
|
||||||
if len(stints) == 0 {
|
if len(stints) == 0 {
|
||||||
|
// Without session-type evidence, an empty primary strategy dataset is
|
||||||
|
// not enough to prove "not applicable" (it may still be settling).
|
||||||
|
markOpenF1Availability(w, client, "limited")
|
||||||
writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}})
|
writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
driverMap := buildDriverMap(drivers)
|
driverMap := buildDriverMap(drivers)
|
||||||
|
incomplete := driversErr != nil || rcErr != nil
|
||||||
|
|
||||||
resultMap := make(map[int]models.SessionResult, len(results))
|
resultMap := make(map[int]models.SessionResult, len(results))
|
||||||
totalLaps := 0
|
totalLaps := 0
|
||||||
@@ -1342,6 +1484,9 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
|
|||||||
stratDrivers := make([]strategyDriver, 0, len(seenDrivers))
|
stratDrivers := make([]strategyDriver, 0, len(seenDrivers))
|
||||||
for dn := range seenDrivers {
|
for dn := range seenDrivers {
|
||||||
d := driverMap[dn]
|
d := driverMap[dn]
|
||||||
|
if !hasDriverPresentation(d) {
|
||||||
|
incomplete = true
|
||||||
|
}
|
||||||
res := resultMap[dn]
|
res := resultMap[dn]
|
||||||
|
|
||||||
sd := strategyDriver{
|
sd := strategyDriver{
|
||||||
@@ -1396,6 +1541,7 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return pi < pj
|
return pi < pj
|
||||||
})
|
})
|
||||||
|
|
||||||
|
markOpenF1AggregateResponse(w, client, incomplete)
|
||||||
writeJSON(w, strategyResponse{
|
writeJSON(w, strategyResponse{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
TotalLaps: totalLaps,
|
TotalLaps: totalLaps,
|
||||||
@@ -1495,17 +1641,27 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
|
|||||||
stints []models.Stint
|
stints []models.Stint
|
||||||
pits []models.Pit
|
pits []models.Pit
|
||||||
rc []models.RaceControl
|
rc []models.RaceControl
|
||||||
|
lapsErr error
|
||||||
|
stintsErr error
|
||||||
|
pitsErr error
|
||||||
|
rcErr error
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
)
|
)
|
||||||
|
client := s.client.Scoped()
|
||||||
wg.Add(4)
|
wg.Add(4)
|
||||||
go func() { defer wg.Done(); allLaps, _ = s.client.GetLapsForSession(sessionKey) }()
|
go func() { defer wg.Done(); allLaps, lapsErr = client.GetLapsForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); stints, _ = s.client.GetStintsForSession(sessionKey) }()
|
go func() { defer wg.Done(); stints, stintsErr = client.GetStintsForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); pits, _ = s.client.GetPitStopsForSession(sessionKey) }()
|
go func() { defer wg.Done(); pits, pitsErr = client.GetPitStopsForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }()
|
go func() { defer wg.Done(); rc, rcErr = client.GetRaceControl(sessionKey) }()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
if lapsErr != nil {
|
||||||
|
writeError(w, lapsErr, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
allDrivers, _ := s.client.GetDriversForSession(sessionKey)
|
allDrivers, driversErr := client.GetDriversForSession(sessionKey)
|
||||||
driverMap := buildDriverMap(allDrivers)
|
driverMap := buildDriverMap(allDrivers)
|
||||||
|
incomplete := stintsErr != nil || pitsErr != nil || rcErr != nil || driversErr != nil
|
||||||
|
|
||||||
// If no filter, default to first 3 unique driver numbers from lap data.
|
// If no filter, default to first 3 unique driver numbers from lap data.
|
||||||
if len(requestedDrivers) == 0 {
|
if len(requestedDrivers) == 0 {
|
||||||
@@ -1543,6 +1699,9 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
|
|||||||
compDrivers := make([]comparisonDriver, 0, len(requestedDrivers))
|
compDrivers := make([]comparisonDriver, 0, len(requestedDrivers))
|
||||||
for _, dn := range requestedDrivers {
|
for _, dn := range requestedDrivers {
|
||||||
d := driverMap[dn]
|
d := driverMap[dn]
|
||||||
|
if !hasDriverPresentation(d) {
|
||||||
|
incomplete = true
|
||||||
|
}
|
||||||
cd := comparisonDriver{
|
cd := comparisonDriver{
|
||||||
DriverNumber: dn,
|
DriverNumber: dn,
|
||||||
NameAcronym: d.NameAcronym,
|
NameAcronym: d.NameAcronym,
|
||||||
@@ -1558,6 +1717,13 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
|
|||||||
compDrivers = append(compDrivers, cd)
|
compDrivers = append(compDrivers, cd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
freshness := "fresh"
|
||||||
|
if len(allLaps) == 0 {
|
||||||
|
freshness = "limited"
|
||||||
|
} else if incomplete {
|
||||||
|
freshness = "partial"
|
||||||
|
}
|
||||||
|
markOpenF1Availability(w, client, freshness)
|
||||||
writeJSON(w, lapsComparisonResponse{
|
writeJSON(w, lapsComparisonResponse{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
SCPeriods: extractSCPeriods(rc),
|
SCPeriods: extractSCPeriods(rc),
|
||||||
|
|||||||
@@ -1,13 +1,136 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func championshipTestUpstream(t *testing.T, driversOK, meetingHasRace bool) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
completed := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/sessions":
|
||||||
|
if r.URL.Query().Get("session_name") == "Race" {
|
||||||
|
_, _ = fmt.Fprintf(w, `[{"session_key":99,"session_name":"Race","date_end":%q}]`, completed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if meetingHasRace {
|
||||||
|
_, _ = w.Write([]byte(`[{"session_key":101,"meeting_key":1,"session_name":"Race"}]`))
|
||||||
|
} else {
|
||||||
|
_, _ = w.Write([]byte(`[{"session_key":100,"meeting_key":1,"session_name":"Practice 1"}]`))
|
||||||
|
}
|
||||||
|
case "/v1/championship_drivers":
|
||||||
|
_, _ = w.Write([]byte(`[{"driver_number":1,"session_key":99,"position_current":1,"points_current":25}]`))
|
||||||
|
case "/v1/championship_teams":
|
||||||
|
_, _ = w.Write([]byte(`[{"team_name":"Red Bull","position_current":1,"points_current":25}]`))
|
||||||
|
case "/v1/drivers":
|
||||||
|
if !driversOK {
|
||||||
|
http.Error(w, "identity unavailable", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`[{"driver_number":1,"name_acronym":"VER","full_name":"Max Verstappen","team_name":"Red Bull","team_colour":"3671c6"}]`))
|
||||||
|
case "/v1/meetings":
|
||||||
|
_, _ = w.Write([]byte(`[{"meeting_key":1,"meeting_name":"Mystery Grand Prix"}]`))
|
||||||
|
case "/v1/session_result":
|
||||||
|
_, _ = w.Write([]byte(`[{"driver_number":1,"position":1,"points":25}]`))
|
||||||
|
case "/v1/starting_grid":
|
||||||
|
_, _ = w.Write([]byte(`[{"driver_number":1,"position":1}]`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenF1ChampionshipHubIdentityFailureIsPartialAndCached(t *testing.T) {
|
||||||
|
upstream := championshipTestUpstream(t, false, true)
|
||||||
|
defer upstream.Close()
|
||||||
|
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
|
||||||
|
defer client.Close()
|
||||||
|
server := NewServer(client, 0, nil)
|
||||||
|
year := time.Now().Year()
|
||||||
|
|
||||||
|
_, incomplete, err := server.openF1ChampionshipHub(client.Scoped(), year)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !incomplete {
|
||||||
|
t.Fatal("missing championship driver identity was labelled complete")
|
||||||
|
}
|
||||||
|
_, source, freshness, ok := server.hubCache.getWithMetadata(year, time.Now())
|
||||||
|
if !ok || source != "openf1" || freshness != "partial" {
|
||||||
|
t.Fatalf("cached metadata = hit %v, %q/%q", ok, source, freshness)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSeasonRacesMeetingWithoutRaceIsIncomplete(t *testing.T) {
|
||||||
|
upstream := championshipTestUpstream(t, true, false)
|
||||||
|
defer upstream.Close()
|
||||||
|
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
races, incomplete, err := fetchSeasonRaces(client.Scoped(), time.Now().Year())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !incomplete || len(races) != 0 {
|
||||||
|
t.Fatalf("no-Race meeting = races %d, incomplete %v", len(races), incomplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSeasonRacesRecognizedTestingMeetingIsNotIncomplete(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/meetings":
|
||||||
|
_, _ = w.Write([]byte(`[{"meeting_key":1253,"meeting_name":"Pre-Season Testing"}]`))
|
||||||
|
case "/v1/sessions":
|
||||||
|
_, _ = w.Write([]byte(`[{"session_key":1,"meeting_key":1253,"session_name":"Day 1","session_type":"Testing"}]`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
races, incomplete, err := fetchSeasonRaces(client.Scoped(), time.Now().Year())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if incomplete || len(races) != 0 {
|
||||||
|
t.Fatalf("recognized testing meeting = races %d, incomplete %v", len(races), incomplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKnownNonChampionshipMeetingRequiresTestingToken(t *testing.T) {
|
||||||
|
if !isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Pre-Season Testing"}, nil) {
|
||||||
|
t.Fatal("pre-season testing was not recognized")
|
||||||
|
}
|
||||||
|
if isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Fastest Grand Prix"}, nil) {
|
||||||
|
t.Fatal("substring inside a normal word was treated as testing")
|
||||||
|
}
|
||||||
|
if !isKnownNonChampionshipMeeting(models.Meeting{MeetingName: "Winter Event"}, []models.Session{{SessionType: "Test"}}) {
|
||||||
|
t.Fatal("explicit Test session was not recognized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChampionshipHubSourceLocalWithoutAggregateIsLimited(t *testing.T) {
|
||||||
|
server := NewServer(nil, 0, nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handleChampionshipHub(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/championship/hub?year=2026&source=local", nil))
|
||||||
|
if recorder.Code != http.StatusOK || recorder.Header().Get(dataSourceHeader) != "none" || recorder.Header().Get(dataFreshnessHeader) != "limited" {
|
||||||
|
t.Fatalf("empty local championship = %d %q/%q body=%s", recorder.Code, recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader), recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func raceResult(num, pos int, pts float64) models.SessionResult {
|
func raceResult(num, pos int, pts float64) models.SessionResult {
|
||||||
return models.SessionResult{DriverNumber: num, Position: pos, Points: pts}
|
return models.SessionResult{DriverNumber: num, Position: pos, Points: pts}
|
||||||
}
|
}
|
||||||
|
|||||||
120
internal/web/component_freshness_test.go
Normal file
120
internal/web/component_freshness_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func componentTestServer(t *testing.T, responses map[string]string, failures map[string]bool) *Server {
|
||||||
|
t.Helper()
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if failures[r.URL.Path] {
|
||||||
|
http.Error(w, "component unavailable", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, ok := responses[r.URL.Path]
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
return NewServer(client, 0, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAvailabilityHeaders(t *testing.T, recorder *httptest.ResponseRecorder, source, freshness string) {
|
||||||
|
t.Helper()
|
||||||
|
if recorder.Code != http.StatusOK || recorder.Header().Get(dataSourceHeader) != source || recorder.Header().Get(dataFreshnessHeader) != freshness {
|
||||||
|
t.Fatalf("response = status %d, metadata %q/%q, body=%s", recorder.Code, recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader), recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultsAndGridIdentityFailuresReportPartial(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
body string
|
||||||
|
run func(*Server, http.ResponseWriter, *http.Request)
|
||||||
|
}{
|
||||||
|
{name: "results", path: "/v1/session_result", body: `[{"driver_number":1,"position":1}]`, run: func(s *Server, w http.ResponseWriter, r *http.Request) { s.handleResults(w, r) }},
|
||||||
|
{name: "grid", path: "/v1/starting_grid", body: `[{"driver_number":1,"position":1}]`, run: func(s *Server, w http.ResponseWriter, r *http.Request) { s.handleGrid(w, r) }},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := componentTestServer(t, map[string]string{tt.path: tt.body}, map[string]bool{"/v1/drivers": true})
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
tt.run(server, recorder, httptest.NewRequest(http.MethodGet, "/api/v1/"+tt.name+"?session_key=99&source=openf1", nil))
|
||||||
|
assertAvailabilityHeaders(t, recorder, "openf1", "partial")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrategyOptionalComponentFailureReportsPartial(t *testing.T) {
|
||||||
|
server := componentTestServer(t, map[string]string{
|
||||||
|
"/v1/stints": `[{"driver_number":1,"stint_number":1,"lap_start":1,"lap_end":10,"compound":"MEDIUM"}]`,
|
||||||
|
"/v1/pit": `[]`,
|
||||||
|
"/v1/session_result": `[{"driver_number":1,"position":1,"number_of_laps":10}]`,
|
||||||
|
"/v1/race_control": `[]`,
|
||||||
|
}, map[string]bool{"/v1/drivers": true})
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handleStrategy(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/strategy?session_key=99", nil))
|
||||||
|
assertAvailabilityHeaders(t, recorder, "openf1", "partial")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrategyEmptyPrimaryDataReportsLimited(t *testing.T) {
|
||||||
|
server := componentTestServer(t, map[string]string{
|
||||||
|
"/v1/stints": `[]`,
|
||||||
|
"/v1/pit": `[]`,
|
||||||
|
"/v1/session_result": `[{"driver_number":1,"position":1,"number_of_laps":10}]`,
|
||||||
|
"/v1/drivers": `[{"driver_number":1,"full_name":"Max Verstappen","team_name":"Red Bull","team_colour":"3671c6"}]`,
|
||||||
|
"/v1/race_control": `[]`,
|
||||||
|
}, nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handleStrategy(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/strategy?session_key=99", nil))
|
||||||
|
assertAvailabilityHeaders(t, recorder, "openf1", "limited")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLapsComparisonDoesNotLabelMissingComponentsFresh(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
laps string
|
||||||
|
freshness string
|
||||||
|
}{
|
||||||
|
{name: "empty primary data", laps: `[]`, freshness: "limited"},
|
||||||
|
{name: "optional components failed", laps: `[{"driver_number":1,"lap_number":1}]`, freshness: "partial"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := componentTestServer(t, map[string]string{"/v1/laps": tt.laps}, map[string]bool{
|
||||||
|
"/v1/stints": true,
|
||||||
|
"/v1/pit": true,
|
||||||
|
"/v1/race_control": true,
|
||||||
|
"/v1/drivers": true,
|
||||||
|
})
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handleLapsComparison(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/laps/comparison?session_key=99", nil))
|
||||||
|
assertAvailabilityHeaders(t, recorder, "openf1", tt.freshness)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("primary laps failure is an error", func(t *testing.T) {
|
||||||
|
server := componentTestServer(t, nil, map[string]bool{
|
||||||
|
"/v1/laps": true,
|
||||||
|
"/v1/stints": true,
|
||||||
|
"/v1/pit": true,
|
||||||
|
"/v1/race_control": true,
|
||||||
|
})
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.handleLapsComparison(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/laps/comparison?session_key=99", nil))
|
||||||
|
if recorder.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
77
internal/web/context.go
Normal file
77
internal/web/context.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/live"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/query"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if !s.hasLocalQuery() {
|
||||||
|
markDataResponse(w, "none", "limited")
|
||||||
|
writeJSON(w, query.WeekendContext{TemporalState: query.TemporalNoSeason})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := s.hub.State()
|
||||||
|
evidence := query.LiveEvidence{}
|
||||||
|
if state.IsLive && state.Data != nil {
|
||||||
|
evidence = liveEvidence(state.Data, true, false)
|
||||||
|
} else if state.LastSnapshot != nil && terminalSessionStatus(state.LastSnapshot.SessionStatus) {
|
||||||
|
evidence = liveEvidence(state.LastSnapshot, false, true)
|
||||||
|
if state.LastSnapshotAt != nil {
|
||||||
|
evidence.ObservedAt = *state.LastSnapshotAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context, err := s.query.ResolveWeekendContext(evidence)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, http.StatusInternalServerError, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if focus := focusedContextSession(context); focus != nil {
|
||||||
|
markDataResponse(w, focus.Availability.Source, focus.Availability.Freshness)
|
||||||
|
} else {
|
||||||
|
markDataResponse(w, "none", "limited")
|
||||||
|
}
|
||||||
|
writeJSON(w, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// focusedContextSession selects the session whose state the Weekend shell is
|
||||||
|
// presenting. An older terminal/default session must never override an
|
||||||
|
// upcoming focus session's metadata.
|
||||||
|
func focusedContextSession(context query.WeekendContext) *query.ContextSession {
|
||||||
|
if context.ActiveSession != nil {
|
||||||
|
return context.ActiveSession
|
||||||
|
}
|
||||||
|
if context.FocusMeeting == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
focusKey := context.FocusMeeting.MeetingKey
|
||||||
|
for _, ref := range []*query.ContextSession{context.NextSession, context.PreviousCompletedSession, context.DefaultAnalysisSession} {
|
||||||
|
if ref != nil && ref.Meeting != nil && ref.Meeting.MeetingKey == focusKey {
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func liveEvidence(data *live.LiveStreamData, active, final bool) query.LiveEvidence {
|
||||||
|
return query.LiveEvidence{Active: active, Final: final, MeetingName: data.Session.MeetingName, CircuitName: data.Session.CircuitName, SessionName: data.Session.SessionName, SessionType: data.Session.SessionType}
|
||||||
|
}
|
||||||
|
|
||||||
|
func terminalSessionStatus(status string) bool {
|
||||||
|
normalized := strings.ToLower(strings.Map(func(r rune) rune {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, status))
|
||||||
|
switch normalized {
|
||||||
|
case "finished", "finalised", "finalized", "ended", "aborted":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
169
internal/web/context_test.go
Normal file
169
internal/web/context_test.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/live"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/query"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openContextStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.Open(filepath.Join(t.TempDir(), "context.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedContextHandler(t *testing.T, st *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 1, MeetingName: "British Grand Prix", CircuitShortName: "Silverstone", Year: 2026, DateStart: "2026-07-03T09:00:00Z", DateEnd: "2026-07-05T16:00:00Z"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertSession(store.Session{SessionKey: 11, MeetingKey: 1, SessionName: "Race", SessionType: "Race", DateStart: "2026-07-05T14:00:00Z", DateEnd: "2026-07-05T16:00:00Z"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekendContextHandlerWithoutStoreReturnsNoSeason(t *testing.T) {
|
||||||
|
s := NewServer(nil, 0, nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rr.Code)
|
||||||
|
}
|
||||||
|
var got query.WeekendContext
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != query.TemporalNoSeason {
|
||||||
|
t.Fatalf("state = %s", got.TemporalState)
|
||||||
|
}
|
||||||
|
if rr.Header().Get(dataSourceHeader) != "none" || rr.Header().Get(dataFreshnessHeader) != "limited" {
|
||||||
|
t.Fatalf("missing context metadata = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekendContextHandlerEmptyStoreReportsLimited(t *testing.T) {
|
||||||
|
st := openContextStore(t)
|
||||||
|
s := NewServer(nil, 0, st)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||||
|
if rr.Code != http.StatusOK || rr.Header().Get(dataSourceHeader) != "none" || rr.Header().Get(dataFreshnessHeader) != "limited" {
|
||||||
|
t.Fatalf("empty context = %d %q/%q body=%s", rr.Code, rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader), rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekendContextHandlerUsesLiveHubIdentityWithoutOpenF1(t *testing.T) {
|
||||||
|
st := openContextStore(t)
|
||||||
|
seedContextHandler(t, st)
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-05T13:55:00Z")
|
||||||
|
s := NewServer(nil, 0, st) // a nil OpenF1 client makes any REST dependency panic
|
||||||
|
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
|
||||||
|
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Started", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
|
||||||
|
|
||||||
|
handler, err := s.routes()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
var got query.WeekendContext
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TemporalState != query.TemporalSessionLive || got.ActiveSession == nil || got.ActiveSession.Session.SessionKey != 11 {
|
||||||
|
t.Fatalf("live context = %+v", got)
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
st := openContextStore(t)
|
||||||
|
seedContextHandler(t, st)
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-05T16:05:00Z")
|
||||||
|
s := NewServer(nil, 0, st)
|
||||||
|
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
|
||||||
|
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Finished", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||||
|
var got query.WeekendContext
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekendContextMetadataFollowsUpcomingFocusNotTerminalPrevious(t *testing.T) {
|
||||||
|
st := openContextStore(t)
|
||||||
|
seedContextHandler(t, st)
|
||||||
|
if err := st.UpsertMeeting(store.Meeting{MeetingKey: 2, MeetingName: "Belgian Grand Prix", CircuitShortName: "Spa", Year: 2026, DateStart: "2026-07-10T09:00:00Z", DateEnd: "2026-07-12T16:00:00Z"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertSession(store.Session{SessionKey: 21, MeetingKey: 2, SessionName: "Practice 1", SessionType: "Practice", DateStart: "2026-07-10T09:00:00Z", DateEnd: "2026-07-10T10:00:00Z"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-05T16:05:00Z")
|
||||||
|
s := NewServer(nil, 0, st)
|
||||||
|
s.query = query.NewServiceWithClock(st, func() time.Time { return now })
|
||||||
|
s.hub.applySnapshot(live.LiveStreamData{SessionStatus: "Finished", Session: live.LiveSessionMeta{MeetingName: "British Grand Prix", CircuitName: "Silverstone", SessionName: "Race", SessionType: "Race"}}, now)
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleWeekendContext(rr, httptest.NewRequest(http.MethodGet, "/api/v1/weekend-context", nil))
|
||||||
|
var got query.WeekendContext
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.FocusMeeting == nil || got.FocusMeeting.MeetingKey != 2 || got.NextSession == nil {
|
||||||
|
t.Fatalf("focus context = %+v", got)
|
||||||
|
}
|
||||||
|
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Freshness != "archive" {
|
||||||
|
t.Fatalf("terminal previous missing = %+v", got.PreviousCompletedSession)
|
||||||
|
}
|
||||||
|
if rr.Header().Get(dataSourceHeader) != "local" || rr.Header().Get(dataFreshnessHeader) != "local" {
|
||||||
|
t.Fatalf("focus metadata was overridden by archive = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTerminalSessionStatus(t *testing.T) {
|
||||||
|
for _, status := range []string{"Finished", "Finalised", "ENDED", "Aborted"} {
|
||||||
|
if !terminalSessionStatus(status) {
|
||||||
|
t.Errorf("%q should be terminal", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, status := range []string{"Started", "Resumed", "Inactive", ""} {
|
||||||
|
if terminalSessionStatus(status) {
|
||||||
|
t.Errorf("%q should not be terminal", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- /api/v1/driver/summary ---
|
// --- /api/v1/driver/summary ---
|
||||||
// Per-driver season summary: championship standing plus per-round race results,
|
// Per-driver season summary: championship standing plus per-round race results.
|
||||||
// aggregated server-side from the same sources as the championship hub. Caching
|
// Current-season identity/results are local-first from the domain DB. Optional
|
||||||
// relies on the OpenF1 client's HTTP cache TTLs — no extra layer here.
|
// OpenF1 enrichment (headshot / polished identity) is bounded so it cannot hang
|
||||||
|
// the profile when remote data is slow or unavailable.
|
||||||
|
|
||||||
|
// driverEnrichmentTimeout bounds optional remote enrichment so a hung OpenF1
|
||||||
|
// call never blocks a local-first profile response. Overridable in tests.
|
||||||
|
var driverEnrichmentTimeout = 2 * time.Second
|
||||||
|
|
||||||
type driverSummaryRound struct {
|
type driverSummaryRound struct {
|
||||||
MeetingKey int `json:"meeting_key"`
|
MeetingKey int `json:"meeting_key"`
|
||||||
@@ -49,6 +56,11 @@ type driverSummaryResponse struct {
|
|||||||
Cumulative []float64 `json:"cumulative"`
|
Cumulative []float64 `json:"cumulative"`
|
||||||
RoundLabels []string `json:"round_labels"`
|
RoundLabels []string `json:"round_labels"`
|
||||||
Rounds []driverSummaryRound `json:"rounds"`
|
Rounds []driverSummaryRound `json:"rounds"`
|
||||||
|
// Source is "local" when served from the domain DB, else "openf1".
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
// Enrichment is "full" when optional remote identity landed, "limited"
|
||||||
|
// when it timed out/failed, or "none" when no enrichment was attempted.
|
||||||
|
Enrichment string `json:"enrichment,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -61,12 +73,102 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = time.Now().Year()
|
year = time.Now().Year()
|
||||||
}
|
}
|
||||||
|
mode := parseSourceMode(r)
|
||||||
|
// Driver summary is local-first for current-season identity/results. When the
|
||||||
|
// caller omits ?source=, prefer auto (local then OpenF1) rather than the
|
||||||
|
// package default of openf1-only.
|
||||||
|
if r.URL.Query().Get("source") == "" {
|
||||||
|
mode = sourceAuto
|
||||||
|
}
|
||||||
|
client := s.client.Scoped()
|
||||||
|
|
||||||
champ, err := s.client.GetDriverChampionshipForYear(year)
|
if mode == sourceLocal || mode == sourceAuto {
|
||||||
if err != nil {
|
resp, sessionKey, ok, lerr := s.localDriverSummary(year, driverNumber)
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
if lerr != nil {
|
||||||
|
writeError(w, lerr, http.StatusInternalServerError, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if ok {
|
||||||
|
if mode != sourceLocal {
|
||||||
|
tryEnrichDriverSummary(r.Context(), 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
|
||||||
|
}
|
||||||
|
if mode == sourceLocal {
|
||||||
|
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, incomplete, err := openF1DriverSummary(client, year, driverNumber)
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
markOpenF1AggregateResponse(w, client, incomplete)
|
||||||
|
writeJSON(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) localDriverSummary(year, driverNumber int) (driverSummaryResponse, int, bool, error) {
|
||||||
|
if !s.hasLocalQuery() {
|
||||||
|
return driverSummaryResponse{}, 0, false, nil
|
||||||
|
}
|
||||||
|
inputs, err := s.query.GetChampionshipInputs(year)
|
||||||
|
if err != nil {
|
||||||
|
return driverSummaryResponse{}, 0, false, err
|
||||||
|
}
|
||||||
|
if len(inputs.Champ) == 0 {
|
||||||
|
return driverSummaryResponse{}, 0, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
races := make([]meetingRace, 0, len(inputs.Races))
|
||||||
|
for _, race := range inputs.Races {
|
||||||
|
races = append(races, meetingRace{
|
||||||
|
Meeting: race.Meeting,
|
||||||
|
RaceSessionKey: race.RaceSessionKey,
|
||||||
|
Results: race.Results,
|
||||||
|
Grid: race.Grid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, ok := aggregateDriverSummary(year, driverNumber, races, inputs.Champ, inputs.DriverMap)
|
||||||
|
if !ok {
|
||||||
|
return driverSummaryResponse{}, 0, false, nil
|
||||||
|
}
|
||||||
|
resp.Source = "local"
|
||||||
|
resp.Enrichment = "none"
|
||||||
|
|
||||||
|
sessionKey := 0
|
||||||
|
for _, c := range inputs.Champ {
|
||||||
|
if c.DriverNumber == driverNumber && c.SessionKey > 0 {
|
||||||
|
sessionKey = c.SessionKey
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if sessionKey == 0 && c.SessionKey > 0 {
|
||||||
|
sessionKey = c.SessionKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, sessionKey, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openF1DriverSummary(client *api.OpenF1Client, year, driverNumber int) (*driverSummaryResponse, bool, error) {
|
||||||
|
champ, err := client.GetDriverChampionshipForYear(year)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
var entry *models.ChampionshipDriver
|
var entry *models.ChampionshipDriver
|
||||||
for i := range champ {
|
for i := range champ {
|
||||||
if champ[i].DriverNumber == driverNumber {
|
if champ[i].DriverNumber == driverNumber {
|
||||||
@@ -75,30 +177,83 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if entry == nil {
|
if entry == nil {
|
||||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
return nil, false, nil
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
driverInfo := map[int]models.Driver{}
|
driverInfo := map[int]models.Driver{}
|
||||||
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil {
|
sessionKey := champ[0].SessionKey
|
||||||
|
if ds, derr := client.GetDriversForSession(sessionKey); derr == nil {
|
||||||
driverInfo = buildDriverMapFirst(ds)
|
driverInfo = buildDriverMapFirst(ds)
|
||||||
}
|
}
|
||||||
if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok {
|
d, directErr := client.GetDriver(entry.SessionKey, driverNumber)
|
||||||
driverInfo[driverNumber] = d
|
if directErr == nil && d != nil {
|
||||||
|
driverInfo[driverNumber] = *d
|
||||||
|
} else if fallback, ok := driverInfo[driverNumber]; ok {
|
||||||
|
driverInfo[driverNumber] = fallback
|
||||||
}
|
}
|
||||||
|
identityIncomplete := !hasDriverPresentation(driverInfo[driverNumber])
|
||||||
races, _, err := s.fetchSeasonRaces(year)
|
races, racesIncomplete, err := fetchSeasonRaces(client, year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
return nil, false, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
|
resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
incomplete := identityIncomplete || racesIncomplete
|
||||||
|
resp.Source = "openf1"
|
||||||
|
if identityIncomplete {
|
||||||
|
resp.Enrichment = "limited"
|
||||||
|
} else {
|
||||||
|
resp.Enrichment = "full"
|
||||||
|
}
|
||||||
|
return &resp, incomplete, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasDriverPresentation(driver models.Driver) bool {
|
||||||
|
hasName := driver.FullName != "" || driver.NameAcronym != "" || driver.BroadcastName != ""
|
||||||
|
return hasName && driver.TeamName != "" && driver.TeamColour != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 tryEnrichDriverSummary(parent context.Context, 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).
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, resp)
|
|
||||||
|
ctx, cancel := context.WithTimeout(parent, driverEnrichmentTimeout)
|
||||||
|
defer cancel()
|
||||||
|
driver, err := client.GetDriverContext(ctx, sessionKey, resp.DriverNumber)
|
||||||
|
if err != nil || driver == nil {
|
||||||
|
resp.Enrichment = "limited"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyDriverEnrichment(resp, *driver)
|
||||||
|
resp.Enrichment = "full"
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDriverEnrichment(resp *driverSummaryResponse, d models.Driver) {
|
||||||
|
if d.HeadshotURL != "" {
|
||||||
|
resp.HeadshotURL = d.HeadshotURL
|
||||||
|
}
|
||||||
|
if d.FullName != "" {
|
||||||
|
resp.FullName = d.FullName
|
||||||
|
}
|
||||||
|
if d.NameAcronym != "" {
|
||||||
|
resp.NameAcronym = d.NameAcronym
|
||||||
|
}
|
||||||
|
if d.TeamName != "" {
|
||||||
|
resp.TeamName = d.TeamName
|
||||||
|
}
|
||||||
|
if d.TeamColour != "" {
|
||||||
|
resp.TeamColour = d.TeamColour
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// aggregateDriverSummary is the pure aggregation core (no network) so it can be
|
// aggregateDriverSummary is the pure aggregation core (no network) so it can be
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) {
|
func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) {
|
||||||
@@ -134,3 +139,234 @@ func TestHandleDriverSummaryBadRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seedDriverSummaryStore(t *testing.T, st *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
meetingKey := 1201
|
||||||
|
sessionKey := 9901
|
||||||
|
|
||||||
|
if err := st.UpsertMeeting(store.Meeting{
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
MeetingName: "Bahrain GP",
|
||||||
|
CountryCode: "BHR",
|
||||||
|
CountryName: "Bahrain",
|
||||||
|
Year: 2025,
|
||||||
|
DateStart: "2025-03-02",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertMeeting: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertSession(store.Session{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
SessionName: "Race",
|
||||||
|
SessionType: "Race",
|
||||||
|
DateStart: "2025-03-02T15:00:00Z",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertSession: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertDriver(store.Driver{
|
||||||
|
DriverNumber: 1,
|
||||||
|
FullName: "Max Verstappen",
|
||||||
|
NameAcronym: "VER",
|
||||||
|
TeamName: "Red Bull",
|
||||||
|
TeamColour: "3671c6",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertDriver: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertSessionDriver(store.SessionDriver{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
DriverNumber: 1,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
FullName: "Max Verstappen",
|
||||||
|
NameAcronym: "VER",
|
||||||
|
TeamName: "Red Bull",
|
||||||
|
TeamColour: "3671c6",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertSessionDriver: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertSessionResult(store.SessionResult{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
DriverNumber: 1,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
Position: 1,
|
||||||
|
Points: 25,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertSessionResult: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.UpsertStartingGridEntry(store.StartingGridEntry{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
DriverNumber: 1,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
Position: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertStartingGridEntry: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDriverSummaryLocalFirstIgnoresHangingEnrichment(t *testing.T) {
|
||||||
|
prev := driverEnrichmentTimeout
|
||||||
|
driverEnrichmentTimeout = 40 * time.Millisecond
|
||||||
|
t.Cleanup(func() { driverEnrichmentTimeout = prev })
|
||||||
|
|
||||||
|
st := openTestStore(t)
|
||||||
|
seedDriverSummaryStore(t, st)
|
||||||
|
|
||||||
|
// Enrichment seam: OpenF1 hangs until released. Local summary must still return.
|
||||||
|
release := make(chan struct{})
|
||||||
|
cancelObserved := make(chan struct{})
|
||||||
|
hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case <-release:
|
||||||
|
case <-r.Context().Done():
|
||||||
|
close(cancelObserved)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
close(release)
|
||||||
|
hang.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
client := api.NewOpenF1Client(hang.URL, 15*time.Second)
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
srv := NewServer(client, 8080, st)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.handleDriverSummary(rec, req)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if elapsed > 500*time.Millisecond {
|
||||||
|
t.Fatalf("handler blocked on enrichment for %v", elapsed)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-cancelObserved:
|
||||||
|
case <-time.After(250 * time.Millisecond):
|
||||||
|
t.Fatal("timed-out enrichment did not cancel its upstream request")
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp driverSummaryResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Source != "local" {
|
||||||
|
t.Errorf("source = %q, want local", resp.Source)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDriverSummaryLocalFirstWithFailingEnrichment(t *testing.T) {
|
||||||
|
prev := driverEnrichmentTimeout
|
||||||
|
driverEnrichmentTimeout = 200 * time.Millisecond
|
||||||
|
t.Cleanup(func() { driverEnrichmentTimeout = prev })
|
||||||
|
|
||||||
|
st := openTestStore(t)
|
||||||
|
seedDriverSummaryStore(t, st)
|
||||||
|
|
||||||
|
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "boom", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
t.Cleanup(fail.Close)
|
||||||
|
|
||||||
|
client := api.NewOpenF1Client(fail.URL, 2*time.Second)
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
srv := NewServer(client, 8080, st)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1&source=auto", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.handleDriverSummary(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp driverSummaryResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Source != "local" {
|
||||||
|
t.Errorf("source = %q, want local", resp.Source)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDriverSummarySourceLocalOnly(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
seedDriverSummaryStore(t, st)
|
||||||
|
|
||||||
|
// Even with a broken OpenF1 client, source=local must succeed from the DB.
|
||||||
|
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "nope", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
t.Cleanup(fail.Close)
|
||||||
|
client := api.NewOpenF1Client(fail.URL, time.Second)
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
srv := NewServer(client, 8080, st)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1&source=local", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.handleDriverSummary(rec, req)
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRemoteDriverSummaryReportsIdentityAndRoundLimitations(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
driversOK bool
|
||||||
|
meetingHasRace bool
|
||||||
|
wantEnrichment string
|
||||||
|
}{
|
||||||
|
{name: "missing identity", driversOK: false, meetingHasRace: true, wantEnrichment: "limited"},
|
||||||
|
{name: "missing race round", driversOK: true, meetingHasRace: false, wantEnrichment: "full"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
upstream := championshipTestUpstream(t, tt.driversOK, tt.meetingHasRace)
|
||||||
|
defer upstream.Close()
|
||||||
|
client := api.NewOpenF1Client(upstream.URL, 2*time.Second)
|
||||||
|
defer client.Close()
|
||||||
|
server := NewServer(client, 0, nil)
|
||||||
|
year := time.Now().Year()
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/driver/summary?year=%d&driver_number=1&source=openf1", year), nil)
|
||||||
|
|
||||||
|
server.handleDriverSummary(recorder, request)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
var response driverSummaryResponse
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if response.Enrichment != tt.wantEnrichment {
|
||||||
|
t.Fatalf("enrichment = %q, want %q", response.Enrichment, tt.wantEnrichment)
|
||||||
|
}
|
||||||
|
if recorder.Header().Get(dataSourceHeader) != "openf1" || recorder.Header().Get(dataFreshnessHeader) != "partial" {
|
||||||
|
t.Fatalf("remote limitation metadata = %q/%q", recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
65
internal/web/freshness.go
Normal file
65
internal/web/freshness.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dataSourceHeader = "X-BoxBox-Data-Source"
|
||||||
|
dataFreshnessHeader = "X-BoxBox-Data-Freshness"
|
||||||
|
)
|
||||||
|
|
||||||
|
type staleResponseReporter interface {
|
||||||
|
LastResponseWasStale() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 staleResponseReporter) {
|
||||||
|
markOpenF1AggregateResponse(w, client, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markOpenF1AggregateResponse(w http.ResponseWriter, client staleResponseReporter, partial bool) {
|
||||||
|
freshness := "fresh"
|
||||||
|
if partial {
|
||||||
|
freshness = "partial"
|
||||||
|
}
|
||||||
|
markOpenF1Availability(w, client, freshness)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markOpenF1Availability(w http.ResponseWriter, client staleResponseReporter, freshness string) {
|
||||||
|
w.Header().Set(dataSourceHeader, "openf1")
|
||||||
|
if client != nil && client.LastResponseWasStale() {
|
||||||
|
w.Header().Set(dataFreshnessHeader, "stale")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if freshness == "" {
|
||||||
|
freshness = "fresh"
|
||||||
|
}
|
||||||
|
w.Header().Set(dataFreshnessHeader, freshness)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 staleResponseReporter, 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
86
internal/web/freshness_test.go
Normal file
86
internal/web/freshness_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeStaleReporter bool
|
||||||
|
|
||||||
|
func (f fakeStaleReporter) LastResponseWasStale() bool { return bool(f) }
|
||||||
|
|
||||||
|
func TestStaleFreshnessTakesPrecedenceOverPartialAndLimited(t *testing.T) {
|
||||||
|
for _, fallback := range []string{"partial", "limited"} {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
markOpenF1Availability(recorder, fakeStaleReporter(true), fallback)
|
||||||
|
if recorder.Header().Get(dataFreshnessHeader) != "stale" {
|
||||||
|
t.Fatalf("fallback %q overrode stale: %q", fallback, recorder.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -364,7 +364,15 @@ func cloneLivePositions(in map[string]live.LivePositionData) map[string]live.Liv
|
|||||||
|
|
||||||
// handleLiveState returns the current live data snapshot as JSON.
|
// handleLiveState returns the current live data snapshot as JSON.
|
||||||
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
|
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.
|
// handleSSEStream is the persistent SSE endpoint for live data.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []int{})
|
writeJSON(w, []int{})
|
||||||
return
|
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) {
|
func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) {
|
||||||
|
markLocalResponse(w, false)
|
||||||
meetingKey, err := strconv.Atoi(r.URL.Query().Get("meeting_key"))
|
meetingKey, err := strconv.Atoi(r.URL.Query().Get("meeting_key"))
|
||||||
if err != nil || meetingKey == 0 {
|
if err != nil || meetingKey == 0 {
|
||||||
http.Error(w, "meeting_key required", http.StatusBadRequest)
|
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)
|
writeError(w, err, http.StatusInternalServerError, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markLocalResponse(w, weekend.Source == query.ResponseSourcePartial)
|
||||||
writeJSON(w, weekend)
|
writeJSON(w, weekend)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ func (s *Server) handleRaceHub(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
|
markDataResponse(w, "none", "limited")
|
||||||
writeJSON(w, emptyRaceHub(sessionKey))
|
writeJSON(w, emptyRaceHub(sessionKey))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,14 @@ func (s *Server) handleRaceHub(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, err, http.StatusInternalServerError, false)
|
writeError(w, err, http.StatusInternalServerError, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
switch hub.Source {
|
||||||
|
case query.ResponseSourceNone:
|
||||||
|
markDataResponse(w, "none", "limited")
|
||||||
|
case query.ResponseSourcePartial:
|
||||||
|
markLocalResponse(w, true)
|
||||||
|
default:
|
||||||
|
markLocalResponse(w, false)
|
||||||
|
}
|
||||||
writeJSON(w, hub)
|
writeJSON(w, hub)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,11 +86,29 @@ func TestHandleRaceHubWithoutStore(t *testing.T) {
|
|||||||
if hub.Source != query.ResponseSourceNone {
|
if hub.Source != query.ResponseSourceNone {
|
||||||
t.Fatalf("source = %q, want %q", hub.Source, query.ResponseSourceNone)
|
t.Fatalf("source = %q, want %q", hub.Source, query.ResponseSourceNone)
|
||||||
}
|
}
|
||||||
|
if rec.Header().Get(dataSourceHeader) != "none" || 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 {
|
if hub.Datasets["session"].Status != query.DatasetStatusMissing {
|
||||||
t.Fatalf("session dataset = %+v, want missing", hub.Datasets["session"])
|
t.Fatalf("session dataset = %+v, want missing", hub.Datasets["session"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleRaceHubUnknownSessionWithStoreReportsLimited(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
srv := testServer(t, st)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.handleRaceHub(rec, httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=999999", nil))
|
||||||
|
|
||||||
|
var hub query.RaceHub
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hub.Source != query.ResponseSourceNone || rec.Header().Get(dataSourceHeader) != "none" || rec.Header().Get(dataFreshnessHeader) != "limited" {
|
||||||
|
t.Fatalf("unknown session = source %q, metadata %q/%q", hub.Source, rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleRaceHubWithLocalData(t *testing.T) {
|
func TestHandleRaceHubWithLocalData(t *testing.T) {
|
||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
seedRaceHubStore(t, st)
|
seedRaceHubStore(t, st)
|
||||||
@@ -117,6 +135,12 @@ func TestHandleRaceHubWithLocalData(t *testing.T) {
|
|||||||
if hub.Datasets["results"].Status != query.DatasetStatusMissing {
|
if hub.Datasets["results"].Status != query.DatasetStatusMissing {
|
||||||
t.Fatalf("results dataset = %+v, want missing", hub.Datasets["results"])
|
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) {
|
func TestHandleRaceHubIncludesChapters(t *testing.T) {
|
||||||
|
|||||||
@@ -61,15 +61,27 @@ func (s *Server) handleReplayFrames(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := assembleReplayFrames(r.Context(), s.client, sessionKey, intervalMS)
|
client := s.client.Scoped()
|
||||||
|
resp, incomplete, err := assembleReplayFrames(r.Context(), client, sessionKey, intervalMS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Availability(w, client, replayResponseFreshness(resp, incomplete))
|
||||||
writeJSON(w, resp)
|
writeJSON(w, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionKey, intervalMS int) (replayFramesResponse, error) {
|
func replayResponseFreshness(resp replayFramesResponse, incomplete bool) string {
|
||||||
|
if !incomplete {
|
||||||
|
return "fresh"
|
||||||
|
}
|
||||||
|
if len(resp.Frames) == 0 {
|
||||||
|
return "limited"
|
||||||
|
}
|
||||||
|
return "partial"
|
||||||
|
}
|
||||||
|
|
||||||
|
func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionKey, intervalMS int) (replayFramesResponse, bool, error) {
|
||||||
if intervalMS < defaultReplayIntervalMS {
|
if intervalMS < defaultReplayIntervalMS {
|
||||||
intervalMS = defaultReplayIntervalMS
|
intervalMS = defaultReplayIntervalMS
|
||||||
}
|
}
|
||||||
@@ -82,26 +94,26 @@ func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionK
|
|||||||
|
|
||||||
drivers, err := client.GetDriversForSession(sessionKey)
|
drivers, err := client.GetDriversForSession(sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp, err
|
return resp, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
driverNumbers := uniqueDriverNumbers(drivers)
|
driverNumbers := uniqueDriverNumbers(drivers)
|
||||||
if len(driverNumbers) == 0 {
|
if len(driverNumbers) == 0 {
|
||||||
return resp, nil
|
return resp, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
series, err := fetchReplayLocationSeries(ctx, client, sessionKey, driverNumbers)
|
series, err := fetchReplayLocationSeries(ctx, client, sessionKey, driverNumbers)
|
||||||
if err != nil && len(series) == 0 {
|
if err != nil && len(series) == 0 {
|
||||||
return resp, err
|
return resp, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
start, ok := earliestReplayLocationTime(series)
|
start, ok := earliestReplayLocationTime(series)
|
||||||
if !ok {
|
if !ok {
|
||||||
return resp, nil
|
return resp, true, nil
|
||||||
}
|
}
|
||||||
resp.StartTime = start.Format(time.RFC3339Nano)
|
resp.StartTime = start.Format(time.RFC3339Nano)
|
||||||
resp.Frames = snapReplayFrames(series, start, intervalMS)
|
resp.Frames = snapReplayFrames(series, start, intervalMS)
|
||||||
return resp, nil
|
return resp, err != nil || len(series) < len(driverNumbers) || len(resp.Frames) == 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func uniqueDriverNumbers(drivers []models.Driver) []int {
|
func uniqueDriverNumbers(drivers []models.Driver) []int {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package web
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -16,6 +17,7 @@ type fakeReplayClient struct {
|
|||||||
drivers []models.Driver
|
drivers []models.Driver
|
||||||
locs map[int][]models.Location
|
locs map[int][]models.Location
|
||||||
err error
|
err error
|
||||||
|
locErrs map[int]error
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
inFlight int
|
inFlight int
|
||||||
@@ -46,7 +48,38 @@ func (f *fakeReplayClient) GetLocation(sessionKey, driverNumber int) ([]models.L
|
|||||||
f.inFlight--
|
f.inFlight--
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
|
||||||
return f.locs[driverNumber], nil
|
return f.locs[driverNumber], f.locErrs[driverNumber]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleReplayFramesReportsPartialDriverSeries(t *testing.T) {
|
||||||
|
start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
|
||||||
|
client := &fakeReplayClient{
|
||||||
|
drivers: []models.Driver{{DriverNumber: 1}, {DriverNumber: 4}},
|
||||||
|
locs: map[int][]models.Location{
|
||||||
|
1: {{Date: start.Format(time.RFC3339Nano), X: 1, Y: 2}},
|
||||||
|
},
|
||||||
|
locErrs: map[int]error{4: errors.New("location unavailable")},
|
||||||
|
}
|
||||||
|
resp, incomplete, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("partial replay should remain usable: %v", err)
|
||||||
|
}
|
||||||
|
if !incomplete || len(resp.Frames) != 1 {
|
||||||
|
t.Fatalf("partial replay = incomplete %v, frames %+v", incomplete, resp.Frames)
|
||||||
|
}
|
||||||
|
if got := replayResponseFreshness(resp, incomplete); got != "partial" {
|
||||||
|
t.Fatalf("partial replay freshness = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleReplayFramesEmptyDriverSetIsLimited(t *testing.T) {
|
||||||
|
resp, incomplete, err := assembleReplayFrames(context.Background(), &fakeReplayClient{}, 99, defaultReplayIntervalMS)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !incomplete || replayResponseFreshness(resp, incomplete) != "limited" {
|
||||||
|
t.Fatalf("empty replay = incomplete %v, freshness %q", incomplete, replayResponseFreshness(resp, incomplete))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.T) {
|
func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.T) {
|
||||||
@@ -70,10 +103,16 @@ func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := assembleReplayFrames(context.Background(), client, 99, 5000)
|
resp, incomplete, err := assembleReplayFrames(context.Background(), client, 99, 5000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||||
}
|
}
|
||||||
|
if !incomplete {
|
||||||
|
t.Fatal("empty entrant location series was labelled complete")
|
||||||
|
}
|
||||||
|
if got := replayResponseFreshness(resp, incomplete); got != "partial" {
|
||||||
|
t.Fatalf("empty entrant freshness = %q", got)
|
||||||
|
}
|
||||||
if resp.SessionKey != 99 || resp.Interval != 5000 {
|
if resp.SessionKey != 99 || resp.Interval != 5000 {
|
||||||
t.Fatalf("response metadata = %+v", resp)
|
t.Fatalf("response metadata = %+v", resp)
|
||||||
}
|
}
|
||||||
@@ -112,7 +151,7 @@ func TestAssembleReplayFramesCapsFrameCount(t *testing.T) {
|
|||||||
locs: map[int][]models.Location{1: locs},
|
locs: map[int][]models.Location{1: locs},
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
|
resp, _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -139,7 +178,7 @@ func TestAssembleReplayFramesBoundsLocationFanOut(t *testing.T) {
|
|||||||
delay: 5 * time.Millisecond,
|
delay: 5 * time.Millisecond,
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS); err != nil {
|
if _, _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS); err != nil {
|
||||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||||
}
|
}
|
||||||
if client.maxInFlight > replayFetchConcurrency {
|
if client.maxInFlight > replayFetchConcurrency {
|
||||||
@@ -167,7 +206,7 @@ func TestHandleReplayFramesValidatesParamsAndFloorsInterval(t *testing.T) {
|
|||||||
client := &fakeReplayClient{drivers: []models.Driver{{DriverNumber: 1}}, locs: map[int][]models.Location{
|
client := &fakeReplayClient{drivers: []models.Driver{{DriverNumber: 1}}, locs: map[int][]models.Location{
|
||||||
1: {{Date: time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), X: 1, Y: 2}},
|
1: {{Date: time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), X: 1, Y: 2}},
|
||||||
}}
|
}}
|
||||||
resp, err := assembleReplayFrames(context.Background(), client, 99, 1000)
|
resp, _, err := assembleReplayFrames(context.Background(), client, 99, 1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ func (s *Server) routes() (http.Handler, error) {
|
|||||||
|
|
||||||
// REST API — /api/v1/laps/comparison must be registered before /api/v1/laps
|
// REST API — /api/v1/laps/comparison must be registered before /api/v1/laps
|
||||||
// because Go's ServeMux uses longest-prefix matching.
|
// because Go's ServeMux uses longest-prefix matching.
|
||||||
|
mux.HandleFunc("/api/v1/weekend-context", s.handleWeekendContext)
|
||||||
mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub)
|
mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub)
|
||||||
mux.HandleFunc("/api/v1/seasons", s.handleSeasons)
|
mux.HandleFunc("/api/v1/seasons", s.handleSeasons)
|
||||||
mux.HandleFunc("/api/v1/weekend", s.handleWeekend)
|
mux.HandleFunc("/api/v1/weekend", s.handleWeekend)
|
||||||
@@ -158,6 +159,7 @@ func withCORS(next http.Handler) http.Handler {
|
|||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
w.Header().Set("Access-Control-Expose-Headers", dataSourceHeader+", "+dataFreshnessHeader)
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user