package weatherapi import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) type roundTripperFunc func(*http.Request) (*http.Response, error) func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } func TestFetchBundleFromFixtures(t *testing.T) { var requested []string server := fixtureServer(t, nil, &requested) client := newTestClient(t, server.URL+"/", nil) bundle, err := client.FetchBundle(context.Background()) if err != nil { t.Fatalf("FetchBundle() error = %v", err) } if bundle.Observation == nil || bundle.Observation.StationID != "KSTL" { t.Fatalf("Observation = %#v, want KSTL observation", bundle.Observation) } if bundle.Current == nil || bundle.Current.ConditionText != "Partly cloudy" { t.Fatalf("Current = %#v, want current conditions", bundle.Current) } if bundle.Hourly == nil || len(bundle.Hourly.Periods) != 1 { t.Fatalf("Hourly = %#v, want one hourly period", bundle.Hourly) } if bundle.Narrative == nil || bundle.Narrative.Product != "narrative" { t.Fatalf("Narrative = %#v, want narrative product", bundle.Narrative) } if bundle.Alerts == nil || bundle.Alerts.AsOf == nil { t.Fatalf("Alerts = %#v, want alert run", bundle.Alerts) } if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 { t.Fatalf("Discussion = %#v, want key messages", bundle.Discussion) } if bundle.Discussion.ShortTerm == nil || bundle.Discussion.ShortTerm.Text != "A weak boundary may trigger isolated showers." { t.Fatalf("Discussion.ShortTerm = %#v, want short-term AFD text", bundle.Discussion.ShortTerm) } if bundle.Discussion.LongTerm == nil || bundle.Discussion.LongTerm.Text != "Warmer temperatures and periodic rain chances continue into the weekend." { t.Fatalf("Discussion.LongTerm = %#v, want long-term AFD text", bundle.Discussion.LongTerm) } if bundle.WeatherStory == nil || bundle.WeatherStory.Title != "Several Chances for Rain Through Monday" { t.Fatalf("WeatherStory = %#v, want latest weather story", bundle.WeatherStory) } if bundle.WeatherStory.UpdatedAt == nil { t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp") } if bundle.SPCConvectiveOutlooks == nil || len(bundle.SPCConvectiveOutlooks.Outlooks) != 1 { t.Fatalf("SPCConvectiveOutlooks = %#v, want one outlook", bundle.SPCConvectiveOutlooks) } if len(bundle.SPCConvectiveOutlooks.Outlooks[0].Geometry) == 0 { t.Fatalf("SPCConvectiveOutlooks.Outlooks[0].Geometry is empty, want GeoJSON") } if len(bundle.SPCConvectiveOutlooks.Discussions) != 1 || bundle.SPCConvectiveOutlooks.Discussions[0].Headline != "Severe storms possible" { t.Fatalf("SPCConvectiveOutlooks.Discussions = %#v, want one discussion", bundle.SPCConvectiveOutlooks.Discussions) } if len(bundle.Sources) != 8 { t.Fatalf("Sources length = %d, want 8", len(bundle.Sources)) } if len(bundle.Warnings) != 0 { t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings)) } wantPaths := []string{ "/observations", "/conditions/current", "/forecast/hourly", "/forecast/narrative", "/alerts/active", "/discussion", "/weatherstories/latest", convectiveOutlooksEndpoint, } if len(requested) != len(wantPaths) { t.Fatalf("requested paths = %v, want %d source endpoints", requested, len(wantPaths)) } if !strings.HasPrefix(requested[0], defaultWarmupEndpoint+"?") && requested[0] != defaultWarmupEndpoint { t.Fatalf("first requested path = %q, want warmup endpoint %s", requested[0], defaultWarmupEndpoint) } for _, want := range wantPaths { if !containsPath(requested, want) { t.Fatalf("requested paths = %v, want %s", requested, want) } } if got := countPath(requested, currentConditionsEndpoint); got != 1 { t.Fatalf("conditions/current requests = %d, want 1; requested paths = %v", got, requested) } if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") { t.Fatalf("requested paths = %v, want full hourly endpoint only", requested) } if !containsPath(requested, "/forecast/narrative") || containsPath(requested, "/forecast/narrative/today") { t.Fatalf("requested paths = %v, want full narrative endpoint only", requested) } if !containsPath(requested, "/weatherstories/latest") { t.Fatalf("requested paths = %v, want weather story endpoint", requested) } if !containsPath(requested, convectiveOutlooksEndpoint) { t.Fatalf("requested paths = %v, want convective outlook endpoint", requested) } } func TestFetchBundleMergesConcurrentSourcesInSourceOrder(t *testing.T) { paths := []string{ "/observations", "/forecast/hourly", "/forecast/narrative", "/alerts/active", "/discussion", "/weatherstories/latest", convectiveOutlooksEndpoint, } started := make(chan string, len(paths)) release := make(map[string]chan struct{}, len(paths)) for _, path := range paths { release[path] = make(chan struct{}) } var releaseOnce sync.Once releaseAll := func() { releaseOnce.Do(func() { for i := len(paths) - 1; i >= 0; i-- { close(release[paths[i]]) } }) } t.Cleanup(releaseAll) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == currentConditionsEndpoint { if !serveWeatherFixture(w, r) { http.NotFound(w, r) } return } ready, ok := release[r.URL.Path] if !ok { http.NotFound(w, r) return } started <- r.URL.Path <-ready if !serveWeatherFixture(w, r) { http.NotFound(w, r) } })) defer server.Close() client := newTestClient(t, server.URL+"/", nil) type fetchResult struct { bundle *weatherdata.Bundle err error } result := make(chan fetchResult, 1) go func() { bundle, err := client.FetchBundle(context.Background()) result <- fetchResult{bundle: bundle, err: err} }() seen := make(map[string]bool, len(paths)) for range paths { select { case path := <-started: seen[path] = true case <-time.After(time.Second): t.Fatalf("independent requests started = %v, want %v", seen, paths) } } releaseAll() select { case got := <-result: if got.err != nil { t.Fatalf("FetchBundle() error = %v", got.err) } wantSources := []string{ config.MissingSourceObservations, config.MissingSourceCurrent, "hourly", config.MissingSourceNarrative, config.MissingSourceAlerts, config.MissingSourceDiscussion, config.MissingSourceWeatherStory, sourceSPCConvectiveOutlooks, } gotSources := make([]string, 0, len(got.bundle.Sources)) for _, source := range got.bundle.Sources { gotSources = append(gotSources, source.Name) } if strings.Join(gotSources, ",") != strings.Join(wantSources, ",") { t.Fatalf("source order = %v, want %v", gotSources, wantSources) } case <-time.After(time.Second): t.Fatal("FetchBundle() did not finish after all source responses were released") } } func TestFetchBundleReportsConcurrentFailuresInSourceOrder(t *testing.T) { var requested []string server := fixtureServer(t, map[string]handlerOverride{ "/forecast/hourly": {status: http.StatusBadRequest, body: `invalid hourly request`}, "/forecast/narrative": {status: http.StatusBadRequest, body: `invalid narrative request`}, }, &requested) client := newTestClient(t, server.URL+"/", nil) _, err := client.FetchBundle(context.Background()) if err == nil { t.Fatal("FetchBundle() error = nil, want source error") } if !strings.Contains(err.Error(), "/forecast/hourly") { t.Fatalf("error = %q, want the earlier hourly source failure", err.Error()) } if !containsPath(requested, "/forecast/narrative") { t.Fatalf("requested paths = %v, want independent narrative request", requested) } } func TestFetchBundleCancelsConcurrentSourceRequests(t *testing.T) { paths := []string{ "/observations", "/forecast/hourly", "/forecast/narrative", "/alerts/active", "/discussion", "/weatherstories/latest", convectiveOutlooksEndpoint, } started := make(chan string, len(paths)) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == currentConditionsEndpoint { if !serveWeatherFixture(w, r) { http.NotFound(w, r) } return } for _, path := range paths { if r.URL.Path == path { started <- path <-r.Context().Done() return } } http.NotFound(w, r) })) defer server.Close() client := newTestClient(t, server.URL+"/", nil) ctx, cancel := context.WithCancel(context.Background()) defer cancel() result := make(chan error, 1) go func() { _, err := client.FetchBundle(ctx) result <- err }() for range paths { select { case <-started: case <-time.After(time.Second): cancel() t.Fatal("not all independent requests started before cancellation") } } cancel() select { case err := <-result: if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) { t.Fatalf("FetchBundle() error = %v, want context cancellation", err) } case <-time.After(time.Second): t.Fatal("FetchBundle() did not return after cancellation") } } func TestFetchBundleRejectsInvalidHourlyPrecipitationProbability(t *testing.T) { for _, probability := range []string{"-1", "101"} { t.Run(probability, func(t *testing.T) { server := fixtureServer(t, map[string]handlerOverride{ "/forecast/hourly": {status: http.StatusOK, body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T14:00:00Z","probabilityOfPrecipitationPercent":` + probability + `}]}}`}, }, nil) client := newTestClient(t, server.URL+"/", nil) _, err := client.FetchBundle(context.Background()) if err == nil || !strings.Contains(err.Error(), "invalid precipitation probability") { t.Fatalf("FetchBundle() error = %v, want invalid precipitation probability", err) } }) } } func TestFetchBundleBuildsExpectedQueries(t *testing.T) { var requested []string server := fixtureServer(t, nil, &requested) client := newTestClient(t, server.URL+"/", nil) _, err := client.FetchBundle(context.Background()) if err != nil { t.Fatalf("FetchBundle() error = %v", err) } for _, rawURL := range requested { if !strings.Contains(rawURL, "format=json") { t.Fatalf("request %q missing format=json", rawURL) } if strings.HasPrefix(rawURL, "/weatherstories/") { if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || strings.Contains(rawURL, "tz=") { t.Fatalf("weather story request %q should use format only", rawURL) } continue } if strings.HasPrefix(rawURL, convectiveOutlooksEndpoint) { if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || !strings.Contains(rawURL, "tz=America%2FChicago") { t.Fatalf("convective outlook request %q should use format and tz only", rawURL) } continue } if !strings.Contains(rawURL, "units=us") { t.Fatalf("request %q missing units=us", rawURL) } if strings.HasPrefix(rawURL, "/forecast/") { if !strings.Contains(rawURL, "precision=0") || !strings.Contains(rawURL, "tz=America%2FChicago") { t.Fatalf("forecast request %q missing precision or tz", rawURL) } continue } if rawURL == defaultWarmupEndpoint || strings.HasPrefix(rawURL, defaultWarmupEndpoint+"?") || strings.HasPrefix(rawURL, "/observations?") { if !strings.Contains(rawURL, "precision=0") { t.Fatalf("request %q missing precision=0", rawURL) } } } } func TestFetchBundleRecordsSourceHash(t *testing.T) { server := fixtureServer(t, nil, nil) client := newTestClient(t, server.URL+"/", nil) bundle, err := client.FetchBundle(context.Background()) if err != nil { t.Fatalf("FetchBundle() error = %v", err) } observation := sourceByName(t, bundle.Sources, "observations") want := hashFixtureData(t, "observations.json") if observation.DataSHA256 != want { t.Fatalf("DataSHA256 = %q, want %q", observation.DataSHA256, want) } story := sourceByName(t, bundle.Sources, "weather_story") if story.Endpoint != "/weatherstories/latest" { t.Fatalf("weather story endpoint = %q, want /weatherstories/latest", story.Endpoint) } if story.DataSHA256 != hashFixtureData(t, "weather_story.json") { t.Fatalf("weather story DataSHA256 = %q, want fixture hash", story.DataSHA256) } if story.IssuedAt == nil || story.UpdatedAt == nil { t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt) } outlooks := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks) if outlooks.Endpoint != convectiveOutlooksEndpoint { t.Fatalf("convective outlook endpoint = %q, want %s", outlooks.Endpoint, convectiveOutlooksEndpoint) } if outlooks.Query["format"] != "json" || outlooks.Query["tz"] != "America/Chicago" || outlooks.Query["units"] != "" || outlooks.Query["precision"] != "" { t.Fatalf("convective outlook query = %#v, want format and tz only", outlooks.Query) } if outlooks.DataSHA256 != hashFixtureData(t, "convective_outlooks.json") { t.Fatalf("convective outlook DataSHA256 = %q, want fixture hash", outlooks.DataSHA256) } if outlooks.Missing { t.Fatal("convective outlook source Missing = true, want false") } if outlooks.IssuedAt == nil || outlooks.IssuedAt.Format(time.RFC3339) != "2026-05-29T15:45:00Z" { t.Fatalf("convective outlook IssuedAt = %#v, want run issuedAt", outlooks.IssuedAt) } if outlooks.UpdatedAt == nil || outlooks.UpdatedAt.Format(time.RFC3339) != "2026-05-29T16:05:00Z" { t.Fatalf("convective outlook UpdatedAt = %#v, want run updatedAt", outlooks.UpdatedAt) } } func TestHTTPErrorIsActionable(t *testing.T) { const marker = "upstream-secret-marker" server := fixtureServer(t, map[string]handlerOverride{ "/forecast/hourly": {status: http.StatusBadGateway, body: marker + strings.Repeat("x", 4096)}, }, nil) client := newTestClient(t, server.URL+"/", nil) _, err := client.FetchBundle(context.Background()) if err == nil { t.Fatal("FetchBundle() error = nil, want HTTP error") } if !strings.Contains(err.Error(), "/forecast/hourly") || !strings.Contains(err.Error(), "502") { t.Fatalf("error = %q, want endpoint and status", err.Error()) } if strings.Contains(err.Error(), marker) { t.Fatalf("error = %q, must not contain upstream response text", err.Error()) } } func TestWarmupRetriesBeforeFetchBundle(t *testing.T) { var requested []string var warmupCalls int server := fixtureServer(t, map[string]handlerOverride{ defaultWarmupEndpoint: {handler: func(w http.ResponseWriter, r *http.Request) { warmupCalls++ if warmupCalls == 1 { w.WriteHeader(http.StatusBadGateway) _, _ = w.Write([]byte("vpn waking up")) return } http.ServeFile(w, r, filepath.Join("testdata", "current.json")) }}, }, &requested) client := newTestClient(t, server.URL+"/", nil) bundle, err := client.FetchBundle(context.Background()) if err != nil { t.Fatalf("FetchBundle() error = %v", err) } if bundle.Current == nil { t.Fatal("Current = nil, want successful fetch after warmup retry") } if warmupCalls != 2 { t.Fatalf("conditions/current calls = %d, want failed and successful warmup attempts", warmupCalls) } if len(requested) < 2 || !containsPath(requested[:2], defaultWarmupEndpoint) { t.Fatalf("initial requests = %v, want warmup endpoint retries", requested) } } func TestWarmupFailureStopsBeforeSourceFetches(t *testing.T) { var requested []string server := fixtureServer(t, map[string]handlerOverride{ defaultWarmupEndpoint: {status: http.StatusBadGateway, body: `vpn unavailable`}, }, &requested) client := newTestClient(t, server.URL+"/", nil) client.warmupAttempts = 2 _, err := client.FetchBundle(context.Background()) if err == nil { t.Fatal("FetchBundle() error = nil, want warmup failure") } if !strings.Contains(err.Error(), "warm up weather API") || !strings.Contains(err.Error(), defaultWarmupEndpoint) || !strings.Contains(err.Error(), "2 attempts") || !strings.Contains(err.Error(), "502") { t.Fatalf("error = %q, want warmup endpoint, attempts, and status", err.Error()) } if got := countPath(requested, defaultWarmupEndpoint); got != 2 { t.Fatalf("warmup requests = %d, want 2; all requests = %v", got, requested) } if containsPath(requested, "/observations") { t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested) } } func TestWarmupDoesNotRetryPermanentStatus(t *testing.T) { var requested []string server := fixtureServer(t, map[string]handlerOverride{ defaultWarmupEndpoint: {status: http.StatusNotFound, body: `not found`}, }, &requested) client := newTestClient(t, server.URL+"/", nil) _, err := client.FetchBundle(context.Background()) if err == nil || !strings.Contains(err.Error(), "404") { t.Fatalf("FetchBundle() error = %v, want non-retryable warmup status", err) } if got := countPath(requested, defaultWarmupEndpoint); got != 1 { t.Fatalf("warmup requests = %d, want 1; all requests = %v", got, requested) } if containsPath(requested, "/observations") { t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested) } } func TestWarmupErrorDiagnosticsRedactResponseBody(t *testing.T) { const marker = "upstream-secret-marker" server := fixtureServer(t, map[string]handlerOverride{ defaultWarmupEndpoint: {status: http.StatusNotFound, body: marker + strings.Repeat("x", 4096)}, }, nil) client := newTestClient(t, server.URL+"/", nil) _, err := client.FetchBundle(context.Background()) if err == nil { t.Fatal("FetchBundle() error = nil, want warmup error") } if !strings.Contains(err.Error(), defaultWarmupEndpoint) || !strings.Contains(err.Error(), "404") { t.Fatalf("error = %q, want warmup endpoint and status", err.Error()) } if strings.Contains(err.Error(), marker) { t.Fatalf("error = %q, must not contain upstream response text", err.Error()) } } func TestFetchAcceptsResponseAtBodyLimit(t *testing.T) { body := paddedJSON(t, `{"data":null}`, int(maxResponseBodyBytes)) server := fixtureServer(t, map[string]handlerOverride{ "/forecast/narrative": {handler: func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(body)) }}, }, nil) client := newTestClient(t, server.URL+"/", nil) if _, err := client.FetchBundle(context.Background()); err != nil { t.Fatalf("FetchBundle() error = %v", err) } } func TestFetchRejectsOversizedResponseWithoutRetry(t *testing.T) { var requested []string var narrativeCalls int oversizedBody := paddedJSON(t, `{"data":null}`, int(maxResponseBodyBytes)) + "x" server := fixtureServer(t, map[string]handlerOverride{ "/forecast/narrative": {handler: func(w http.ResponseWriter, r *http.Request) { narrativeCalls++ _, _ = w.Write([]byte(oversizedBody)) }}, }, &requested) client := newTestClient(t, server.URL+"/", nil) _, err := client.FetchBundle(context.Background()) if err == nil { t.Fatal("FetchBundle() error = nil, want oversized response error") } if !strings.Contains(err.Error(), "/forecast/narrative") || !strings.Contains(err.Error(), errResponseBodyTooLarge.Error()) { t.Fatalf("error = %q, want endpoint and response limit", err.Error()) } if narrativeCalls != 1 { t.Fatalf("narrative calls = %d, want no retry", narrativeCalls) } if !containsPath(requested, "/alerts/active") { t.Fatalf("requested paths = %v, want independent source requests despite narrative failure", requested) } } func TestWarmupRejectsOversizedResponseWithoutRetry(t *testing.T) { var requested []string oversizedBody := paddedJSON(t, `{"data":{}}`, int(maxResponseBodyBytes)) + "x" server := fixtureServer(t, map[string]handlerOverride{ defaultWarmupEndpoint: {handler: func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(oversizedBody)) }}, }, &requested) client := newTestClient(t, server.URL+"/", nil) client.warmupAttempts = 2 _, err := client.FetchBundle(context.Background()) if err == nil { t.Fatal("FetchBundle() error = nil, want oversized warmup response error") } if !strings.Contains(err.Error(), defaultWarmupEndpoint) || !strings.Contains(err.Error(), errResponseBodyTooLarge.Error()) { t.Fatalf("error = %q, want warmup endpoint and response limit", err.Error()) } if got := countPath(requested, defaultWarmupEndpoint); got != 1 { t.Fatalf("warmup requests = %d, want no retry; all requests = %v", got, requested) } if containsPath(requested, "/observations") { t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested) } } func TestFetchRetriesRetryableStatus(t *testing.T) { var hourlyCalls int server := fixtureServer(t, map[string]handlerOverride{ "/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) { hourlyCalls++ if hourlyCalls == 1 { w.WriteHeader(http.StatusBadGateway) _, _ = w.Write([]byte("temporary upstream failure")) return } http.ServeFile(w, r, filepath.Join("testdata", "hourly.json")) }}, }, nil) client := newTestClient(t, server.URL+"/", nil) bundle, err := client.FetchBundle(context.Background()) if err != nil { t.Fatalf("FetchBundle() error = %v", err) } if bundle.Hourly == nil { t.Fatal("Hourly = nil, want successful fetch after retry") } if hourlyCalls != 2 { t.Fatalf("hourly calls = %d, want 2", hourlyCalls) } } func TestFetchDoesNotRetryNonRetryableStatus(t *testing.T) { var hourlyCalls int server := fixtureServer(t, map[string]handlerOverride{ "/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) { hourlyCalls++ w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte("not found")) }}, }, nil) client := newTestClient(t, server.URL+"/", nil) _, err := client.FetchBundle(context.Background()) if err == nil { t.Fatal("FetchBundle() error = nil, want non-retryable status error") } if hourlyCalls != 1 { t.Fatalf("hourly calls = %d, want no retry", hourlyCalls) } } func TestNewValidatesWeatherAPIBaseURLSchemeWithoutRequests(t *testing.T) { requests := 0 httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { requests++ return nil, errors.New("unexpected request") })} tests := []struct { name string baseURL string wantErr string }{ {name: "local HTTP", baseURL: "http://127.0.0.1:8080/weather/"}, {name: "local HTTPS", baseURL: "https://127.0.0.1:8443/weather/"}, {name: "unsupported scheme", baseURL: "ftp://weather.example.test/", wantErr: "weather_api.base_url must use http or https"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := testConfig(tt.baseURL) _, err := New(cfg, WithHTTPClient(httpClient)) if tt.wantErr == "" { if err != nil { t.Fatalf("New() error = %v", err) } } else if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("New() error = %v, want %q", err, tt.wantErr) } }) } if requests != 0 { t.Fatalf("HTTP requests = %d, want none", requests) } } func TestFetchDoesNotRetryMalformedEnvelope(t *testing.T) { var hourlyCalls int server := fixtureServer(t, map[string]handlerOverride{ "/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) { hourlyCalls++ w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`not-json`)) }}, }, nil) client := newTestClient(t, server.URL+"/", nil) _, err := client.FetchBundle(context.Background()) if err == nil { t.Fatal("FetchBundle() error = nil, want envelope decode error") } if hourlyCalls != 1 { t.Fatalf("hourly calls = %d, want no retry", hourlyCalls) } } func TestRequiredHourlyForecast(t *testing.T) { var requested []string server := fixtureServer(t, map[string]handlerOverride{ "/forecast/hourly": {status: http.StatusOK, body: `{"data": null}`}, }, &requested) client := newTestClient(t, server.URL+"/", nil) _, err := client.FetchBundle(context.Background()) if err == nil { t.Fatal("FetchBundle() error = nil, want required hourly error") } if !strings.Contains(err.Error(), "hourly forecast data") { t.Fatalf("error = %q, want hourly context", err.Error()) } if got := countPath(requested, "/forecast/hourly"); got != 1 { t.Fatalf("hourly requests = %d, want no retry; all requests = %v", got, requested) } } func TestRequiredHourlyForecastValidatesPeriodBounds(t *testing.T) { tests := []struct { name string body string wantErr bool }{ { name: "valid period", body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T14:00:00Z"}]}}`, }, { name: "missing start", body: `{"data":{"periods":[{"endTime":"2026-05-29T14:00:00Z"}]}}`, wantErr: true, }, { name: "missing end", body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z"}]}}`, wantErr: true, }, { name: "empty range", body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T13:00:00Z"}]}}`, wantErr: true, }, { name: "reversed range", body: `{"data":{"periods":[{"startTime":"2026-05-29T14:00:00Z","endTime":"2026-05-29T13:00:00Z"}]}}`, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var requested []string server := fixtureServer(t, map[string]handlerOverride{ "/forecast/hourly": {status: http.StatusOK, body: tt.body}, }, &requested) client := newTestClient(t, server.URL+"/", nil) bundle, err := client.FetchBundle(context.Background()) if tt.wantErr { if err == nil || !strings.Contains(err.Error(), "hourly forecast") || !strings.Contains(err.Error(), "time bounds") { t.Fatalf("FetchBundle() error = %v, want hourly time-bounds failure", err) } if got := countPath(requested, "/forecast/hourly"); got != 1 { t.Fatalf("hourly requests = %d, want no retry; all requests = %v", got, requested) } return } if err != nil { t.Fatalf("FetchBundle() error = %v", err) } if bundle.Hourly == nil || len(bundle.Hourly.Periods) != 1 { t.Fatalf("Hourly = %#v, want accepted hourly period", bundle.Hourly) } }) } } func TestNullAlertsMeansNoActiveAlerts(t *testing.T) { server := fixtureServer(t, map[string]handlerOverride{ "/alerts/active": {status: http.StatusOK, body: `{"data": null}`}, }, nil) client := newTestClient(t, server.URL+"/", nil) bundle, err := client.FetchBundle(context.Background()) if err != nil { t.Fatalf("FetchBundle() error = %v", err) } if bundle.Alerts == nil { t.Fatal("Alerts = nil, want checked empty alert run") } if len(bundle.Alerts.Alerts) != 0 { t.Fatalf("Alerts length = %d, want no active alerts", len(bundle.Alerts.Alerts)) } source := sourceByName(t, bundle.Sources, "alerts") if source.Missing { t.Fatalf("alerts source Missing = true, want false") } if source.DataSHA256 == "" { t.Fatal("alerts DataSHA256 is empty, want hash for explicit null payload") } for _, warning := range bundle.Warnings { if warning.Source == "alerts" { t.Fatalf("warnings = %#v, want no alerts warning", bundle.Warnings) } } } func TestMissingSPCConvectiveOutlooksUsesPolicy(t *testing.T) { server := fixtureServer(t, map[string]handlerOverride{ convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data": null}`}, }, nil) client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{ sourceSPCConvectiveOutlooks: config.MissingSourceWarn, }) bundle, err := client.FetchBundle(context.Background()) if err != nil { t.Fatalf("FetchBundle() error = %v", err) } if bundle.SPCConvectiveOutlooks != nil { t.Fatalf("SPCConvectiveOutlooks = %#v, want nil for missing source", bundle.SPCConvectiveOutlooks) } source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks) if !source.Missing || len(source.Warnings) != 1 { t.Fatalf("convective outlook source = %#v, want missing source warning", source) } } func TestEmptySPCConvectiveOutlooksAreCheckedData(t *testing.T) { server := fixtureServer(t, map[string]handlerOverride{ convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`}, }, nil) client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{ sourceSPCConvectiveOutlooks: config.MissingSourceWarn, }) bundle, err := client.FetchBundle(context.Background()) if err != nil { t.Fatalf("FetchBundle() error = %v", err) } if bundle.SPCConvectiveOutlooks == nil { t.Fatal("SPCConvectiveOutlooks = nil, want checked empty run") } if len(bundle.SPCConvectiveOutlooks.Outlooks) != 0 || len(bundle.SPCConvectiveOutlooks.Discussions) != 0 { t.Fatalf("SPCConvectiveOutlooks = %#v, want empty arrays", bundle.SPCConvectiveOutlooks) } source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks) if source.Missing || len(source.Warnings) != 0 { t.Fatalf("convective outlook source = %#v, want non-missing source without warnings", source) } if source.IssuedAt == nil || source.IssuedAt.Format(time.RFC3339) != "2026-05-29T16:00:00Z" { t.Fatalf("convective outlook IssuedAt = %#v, want fallback to asOf", source.IssuedAt) } for _, warning := range bundle.Warnings { if warning.Source == sourceSPCConvectiveOutlooks { t.Fatalf("warnings = %#v, want no convective outlook warning", bundle.Warnings) } } } func TestMissingSourcePolicyWarnNoneError(t *testing.T) { tests := []struct { name string policy config.MissingSourcePolicy wantErr bool wantWarns int wantSource bool }{ {name: "warn", policy: config.MissingSourceWarn, wantWarns: 1, wantSource: true}, {name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true}, {name: "error", policy: config.MissingSourceError, wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { server := fixtureServer(t, map[string]handlerOverride{ "/observations": {status: http.StatusOK, body: `{"data": null}`}, }, nil) cfg := testConfig(server.URL + "/") cfg.MissingSource.Default = tt.policy cfg.MissingSource.Sources = map[string]config.MissingSourcePolicy{ "hourly": tt.policy, } client, err := New(cfg, WithClock(fixedNow)) if err != nil { t.Fatalf("New() error = %v", err) } bundle, err := client.FetchBundle(context.Background()) if tt.wantErr { if err == nil { t.Fatal("FetchBundle() error = nil, want policy error") } return } if err != nil { t.Fatalf("FetchBundle() error = %v", err) } if len(bundle.Warnings) != tt.wantWarns { t.Fatalf("Warnings length = %d, want %d", len(bundle.Warnings), tt.wantWarns) } if tt.wantSource { source := sourceByName(t, bundle.Sources, "observations") if !source.Missing { t.Fatalf("observations source Missing = false, want true") } } }) } } func TestMalformedNonRequiredSourceUsesPolicy(t *testing.T) { server := fixtureServer(t, map[string]handlerOverride{ "/conditions/current": {status: http.StatusOK, body: `{"data": {"temperatureF": "hot"}}`}, }, nil) client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{ "current": config.MissingSourceWarn, }) bundle, err := client.FetchBundle(context.Background()) if err != nil { t.Fatalf("FetchBundle() error = %v", err) } source := sourceByName(t, bundle.Sources, "current") if !source.Missing || len(source.Warnings) != 1 { t.Fatalf("current source = %#v, want missing source warning", source) } } func TestMissingWeatherStoryUsesPolicy(t *testing.T) { server := fixtureServer(t, map[string]handlerOverride{ "/weatherstories/latest": {status: http.StatusOK, body: `{"data": null}`}, }, nil) client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{ "weather_story": config.MissingSourceWarn, }) bundle, err := client.FetchBundle(context.Background()) if err != nil { t.Fatalf("FetchBundle() error = %v", err) } if bundle.WeatherStory != nil { t.Fatalf("WeatherStory = %#v, want nil for missing source", bundle.WeatherStory) } source := sourceByName(t, bundle.Sources, "weather_story") if !source.Missing || len(source.Warnings) != 1 { t.Fatalf("weather_story source = %#v, want missing source warning", source) } } func TestMalformedWeatherStoryUsesPolicy(t *testing.T) { server := fixtureServer(t, map[string]handlerOverride{ "/weatherstories/latest": {status: http.StatusOK, body: `{"data": {"startTime": 123}}`}, }, nil) client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{ "weather_story": config.MissingSourceWarn, }) bundle, err := client.FetchBundle(context.Background()) if err != nil { t.Fatalf("FetchBundle() error = %v", err) } source := sourceByName(t, bundle.Sources, "weather_story") if !source.Missing || len(source.Warnings) != 1 || source.Warnings[0].Code != "malformed_source" { t.Fatalf("weather_story source = %#v, want malformed source warning", source) } } func TestEmptyWeatherStoryUsesPolicy(t *testing.T) { for _, tt := range []struct { name string policy config.MissingSourcePolicy wantErr bool }{ {name: "warn", policy: config.MissingSourceWarn}, {name: "error", policy: config.MissingSourceError, wantErr: true}, } { t.Run(tt.name, func(t *testing.T) { server := fixtureServer(t, map[string]handlerOverride{ "/weatherstories/latest": {status: http.StatusOK, body: `{"data": {}}`}, }, nil) client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{ "weather_story": tt.policy, }) bundle, err := client.FetchBundle(context.Background()) if tt.wantErr { if err == nil || !strings.Contains(err.Error(), "weather story has no usable content") { t.Fatalf("FetchBundle() error = %v, want unusable weather story error", err) } return } if err != nil { t.Fatalf("FetchBundle() error = %v", err) } if bundle.WeatherStory != nil { t.Fatalf("WeatherStory = %#v, want nil for empty source", bundle.WeatherStory) } source := sourceByName(t, bundle.Sources, "weather_story") if !source.Missing || len(source.Warnings) != 1 || source.Warnings[0].Code != "malformed_source" { t.Fatalf("weather_story source = %#v, want malformed source warning", source) } }) } } func TestContextCancellation(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() })) defer server.Close() client := newTestClient(t, server.URL+"/", nil) ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := client.FetchBundle(ctx) if err == nil { t.Fatal("FetchBundle() error = nil, want cancellation error") } } func TestRetryDelayRespectsContextCancellation(t *testing.T) { var cancel context.CancelFunc var hourlyCalls int server := fixtureServer(t, map[string]handlerOverride{ "/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) { hourlyCalls++ if cancel != nil { cancel() } w.WriteHeader(http.StatusBadGateway) _, _ = w.Write([]byte("temporary upstream failure")) }}, }, nil) client := newTestClient(t, server.URL+"/", nil) client.fetchRetryDelay = time.Hour ctx, cancelFunc := context.WithCancel(context.Background()) cancel = cancelFunc defer cancelFunc() start := time.Now() _, err := client.FetchBundle(ctx) elapsed := time.Since(start) if err == nil { t.Fatal("FetchBundle() error = nil, want cancellation during retry delay") } if !strings.Contains(err.Error(), context.Canceled.Error()) { t.Fatalf("error = %q, want context cancellation", err.Error()) } if elapsed > time.Second { t.Fatalf("FetchBundle() elapsed = %s, want prompt cancellation", elapsed) } if hourlyCalls != 1 { t.Fatalf("hourly calls = %d, want retry delay cancellation before second attempt", hourlyCalls) } } func TestHTTPTimeout(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(50 * time.Millisecond) })) defer server.Close() cfg := testConfig(server.URL + "/") cfg.WeatherAPI.Timeout = time.Nanosecond client, err := New(cfg, WithClock(fixedNow)) if err != nil { t.Fatalf("New() error = %v", err) } client.warmupDelay = 0 client.fetchRetryDelay = 0 _, err = client.FetchBundle(context.Background()) if err == nil { t.Fatal("FetchBundle() error = nil, want timeout error") } if !strings.Contains(err.Error(), defaultWarmupEndpoint) { t.Fatalf("error = %q, want endpoint context", err.Error()) } } type handlerOverride struct { status int body string handler http.HandlerFunc } var weatherFixtureFiles = map[string]string{ "/observations": "observations.json", "/conditions/current": "current.json", "/forecast/hourly": "hourly.json", "/forecast/narrative": "narrative.json", "/alerts/active": "alerts.json", "/discussion": "discussion.json", "/weatherstories/latest": "weather_story.json", convectiveOutlooksEndpoint: "convective_outlooks.json", } func serveWeatherFixture(w http.ResponseWriter, r *http.Request) bool { name, ok := weatherFixtureFiles[r.URL.Path] if !ok { return false } http.ServeFile(w, r, filepath.Join("testdata", name)) return true } func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server { t.Helper() var requestedMu sync.Mutex server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if requested != nil { requestedMu.Lock() *requested = append(*requested, r.URL.String()) requestedMu.Unlock() } if override, ok := overrides[r.URL.Path]; ok { if override.handler != nil { override.handler(w, r) return } w.WriteHeader(override.status) _, _ = w.Write([]byte(override.body)) return } if !serveWeatherFixture(w, r) { http.NotFound(w, r) } })) t.Cleanup(server.Close) return server } func newTestClient(t *testing.T, baseURL string, sourcePolicies map[string]config.MissingSourcePolicy) *Client { t.Helper() cfg := testConfig(baseURL) for source, policy := range sourcePolicies { cfg.MissingSource.Sources[source] = policy } client, err := New(cfg, WithClock(fixedNow)) if err != nil { t.Fatalf("New() error = %v", err) } client.warmupDelay = 0 client.fetchRetryDelay = 0 return client } func testConfig(baseURL string) config.Config { cfg := config.Defaults() cfg.WeatherAPI.BaseURL = baseURL return cfg } func fixedNow() time.Time { return time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC) } func paddedJSON(t *testing.T, value string, size int) string { t.Helper() if len(value) > size { t.Fatalf("JSON value length = %d, exceeds requested size %d", len(value), size) } return value + strings.Repeat(" ", size-len(value)) } func containsPath(requested []string, path string) bool { for _, rawURL := range requested { if strings.HasPrefix(rawURL, path+"?") || rawURL == path { return true } } return false } func countPath(requested []string, path string) int { var count int for _, rawURL := range requested { if strings.HasPrefix(rawURL, path+"?") || rawURL == path { count++ } } return count } func sourceByName(t *testing.T, sources []weatherdata.Source, name string) weatherdata.Source { t.Helper() for _, source := range sources { if source.Name == name { return source } } t.Fatalf("source %q not found in %#v", name, sources) return weatherdata.Source{} } func hashFixtureData(t *testing.T, fixture string) string { t.Helper() data, err := os.ReadFile(filepath.Join("testdata", fixture)) if err != nil { t.Fatalf("read fixture: %v", err) } var env envelope if err := json.Unmarshal(data, &env); err != nil { t.Fatalf("decode fixture envelope: %v", err) } hash, err := sourceHash(env.Data) if err != nil { t.Fatalf("hash fixture data: %v", err) } return hash }