package weatherapi import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" ) 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 len(bundle.Sources) != 8 { t.Fatalf("Sources length = %d, want 8", len(bundle.Sources)) } if len(bundle.Warnings) != 2 { t.Fatalf("Warnings length = %d, want daily and weather story warnings", len(bundle.Warnings)) } 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) } } 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") || !strings.Contains(rawURL, "units=us") { t.Fatalf("request %q missing format=json or units=us", rawURL) } if strings.HasPrefix(rawURL, "/forecast/") { if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=Chicago") { t.Fatalf("forecast request %q missing precision or tz", 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) } } func TestHTTPErrorIsActionable(t *testing.T) { server := fixtureServer(t, map[string]handlerOverride{ "/conditions/current": {status: http.StatusBadGateway, body: `upstream failed`}, }, 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(), "/conditions/current") || !strings.Contains(err.Error(), "502") { t.Fatalf("error = %q, want endpoint and status", err.Error()) } } func TestRequiredHourlyForecast(t *testing.T) { server := fixtureServer(t, map[string]handlerOverride{ "/forecast/hourly": {status: http.StatusOK, body: `{"data": null}`}, }, nil) 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()) } } func TestMissingSourcePolicyWarnNoneError(t *testing.T) { tests := []struct { name string policy config.MissingSourcePolicy wantErr bool wantWarns int wantSource bool }{ {name: "warn", policy: config.MissingSourceWarn, wantWarns: 3, 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 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 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) } _, err = client.FetchBundle(context.Background()) if err == nil { t.Fatal("FetchBundle() error = nil, want timeout error") } if !strings.Contains(err.Error(), "/observations") { t.Fatalf("error = %q, want endpoint context", err.Error()) } } func TestSaveBundle(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) } path := filepath.Join(t.TempDir(), "nested", "bundle.json") if err := SaveBundle(path, bundle); err != nil { t.Fatalf("SaveBundle() error = %v", err) } data, err := os.ReadFile(path) if err != nil { t.Fatalf("read saved bundle: %v", err) } if !strings.Contains(string(data), `"hourly"`) { t.Fatalf("saved bundle missing hourly source:\n%s", string(data)) } } type handlerOverride struct { status int body string } func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server { t.Helper() fixtures := 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", } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if requested != nil { *requested = append(*requested, r.URL.String()) } if override, ok := overrides[r.URL.Path]; ok { w.WriteHeader(override.status) _, _ = w.Write([]byte(override.body)) return } name, ok := fixtures[r.URL.Path] if !ok { http.NotFound(w, r) return } http.ServeFile(w, r, filepath.Join("testdata", name)) })) 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) } 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 containsPath(requested []string, path string) bool { for _, rawURL := range requested { if strings.HasPrefix(rawURL, path+"?") || rawURL == path { return true } } return false } func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast.Source { t.Helper() for _, source := range sources { if source.Name == name { return source } } t.Fatalf("source %q not found in %#v", name, sources) return forecast.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 }