Files
weatherreporter/internal/adapters/weatherapi/client_test.go

1189 lines
39 KiB
Go

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
}