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

379 lines
11 KiB
Go

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=America%2FChicago") {
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 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 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
}