Add weather API bundle adapter
This commit is contained in:
426
internal/adapters/weatherapi/client.go
Normal file
426
internal/adapters/weatherapi/client.go
Normal file
@@ -0,0 +1,426 @@
|
||||
// Package weatherapi adapts the internal weather API to forecast bundles.
|
||||
package weatherapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL *url.URL
|
||||
httpClient *http.Client
|
||||
units string
|
||||
format string
|
||||
timezone string
|
||||
precision int
|
||||
missingSource config.MissingSourceConfig
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type Option func(*Client)
|
||||
|
||||
func WithHTTPClient(httpClient *http.Client) Option {
|
||||
return func(c *Client) {
|
||||
if httpClient != nil {
|
||||
c.httpClient = httpClient
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(c *Client) {
|
||||
if now != nil {
|
||||
c.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func New(cfg config.Config, opts ...Option) (*Client, error) {
|
||||
if strings.TrimSpace(cfg.WeatherAPI.BaseURL) == "" {
|
||||
return nil, fmt.Errorf("weather_api.base_url is required")
|
||||
}
|
||||
baseURL, err := url.Parse(cfg.WeatherAPI.BaseURL)
|
||||
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
|
||||
return nil, fmt.Errorf("weather_api.base_url must be an absolute URL")
|
||||
}
|
||||
|
||||
timeout := cfg.WeatherAPI.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{Timeout: timeout},
|
||||
units: cfg.WeatherAPI.Units,
|
||||
format: cfg.WeatherAPI.Format,
|
||||
timezone: cfg.WeatherAPI.Timezone,
|
||||
precision: cfg.WeatherAPI.Precision,
|
||||
missingSource: config.MissingSourceConfig{
|
||||
Default: cfg.MissingSource.Default,
|
||||
Sources: cfg.MissingSource.Sources,
|
||||
},
|
||||
now: time.Now,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(client)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
|
||||
fetchedAt := c.now()
|
||||
builder := bundleBuilder{
|
||||
client: c,
|
||||
bundle: &forecast.Bundle{FetchedAt: fetchedAt},
|
||||
fetchedAt: fetchedAt,
|
||||
}
|
||||
|
||||
if err := builder.fetchObservation(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.fetchCurrent(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.fetchHourly(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.fetchNarrative(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.fetchAlerts(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.fetchDiscussion(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.addStub("weather_story", "NWS weather story is not available from the weather API yet"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return builder.bundle, nil
|
||||
}
|
||||
|
||||
type bundleBuilder struct {
|
||||
client *Client
|
||||
bundle *forecast.Bundle
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "observation data is missing", false)
|
||||
}
|
||||
var observation forecast.Observation
|
||||
if err := decodeSource(raw, &observation); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
source.IssuedAt = &observation.Timestamp
|
||||
b.bundle.Observation = &observation
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "current conditions data is missing", false)
|
||||
}
|
||||
var current forecast.Current
|
||||
if err := decodeSource(raw, ¤t); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
b.bundle.Current = ¤t
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "hourly", "/forecast/hourly", queryOptions{precision: true, timezone: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "hourly forecast data is missing", true)
|
||||
}
|
||||
var hourly forecast.ForecastRun
|
||||
if err := decodeSource(raw, &hourly); err != nil {
|
||||
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
|
||||
}
|
||||
if len(hourly.Periods) == 0 {
|
||||
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
|
||||
}
|
||||
source.IssuedAt = &hourly.IssuedAt
|
||||
source.UpdatedAt = hourly.UpdatedAt
|
||||
b.bundle.Hourly = &hourly
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "narrative", "/forecast/narrative", queryOptions{precision: true, timezone: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "narrative forecast data is missing", false)
|
||||
}
|
||||
var narrative forecast.ForecastRun
|
||||
if err := decodeSource(raw, &narrative); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
source.IssuedAt = &narrative.IssuedAt
|
||||
source.UpdatedAt = narrative.UpdatedAt
|
||||
b.bundle.Narrative = &narrative
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "active alerts data is missing", false)
|
||||
}
|
||||
var alerts forecast.AlertRun
|
||||
if err := decodeSource(raw, &alerts); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
alerts.Raw = append(json.RawMessage(nil), raw...)
|
||||
if alerts.AsOf != nil {
|
||||
source.IssuedAt = alerts.AsOf
|
||||
}
|
||||
b.bundle.Alerts = &alerts
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "forecast discussion data is missing", false)
|
||||
}
|
||||
var discussion forecast.Discussion
|
||||
if err := decodeSource(raw, &discussion); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
source.IssuedAt = &discussion.IssuedAt
|
||||
source.UpdatedAt = discussion.UpdatedAt
|
||||
b.bundle.Discussion = &discussion
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) addStub(sourceName string, message string) error {
|
||||
source := forecast.Source{
|
||||
Name: sourceName,
|
||||
FetchedAt: b.fetchedAt,
|
||||
Missing: true,
|
||||
}
|
||||
return b.applyMissingPolicy(&source, "missing_source", message)
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, required bool) error {
|
||||
source.Missing = true
|
||||
if required {
|
||||
return fmt.Errorf("%s from %s is required", message, source.Endpoint)
|
||||
}
|
||||
return b.applyMissingPolicy(source, "missing_source", message)
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) handleMalformed(source *forecast.Source, err error, required bool) error {
|
||||
if required {
|
||||
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
|
||||
}
|
||||
source.Missing = true
|
||||
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string, message string) error {
|
||||
policy := b.client.policyFor(source.Name)
|
||||
if policy == config.MissingSourceError {
|
||||
return fmt.Errorf("%s: %s", source.Name, message)
|
||||
}
|
||||
if policy == config.MissingSourceWarn {
|
||||
warning := forecast.SourceWarning{
|
||||
Source: source.Name,
|
||||
Code: code,
|
||||
Severity: "warning",
|
||||
Message: message,
|
||||
Endpoint: source.Endpoint,
|
||||
CompletenessImpact: "source omitted from bundle",
|
||||
}
|
||||
source.Warnings = append(source.Warnings, warning)
|
||||
b.bundle.Warnings = append(b.bundle.Warnings, warning)
|
||||
}
|
||||
b.addSource(*source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) addSource(source forecast.Source) {
|
||||
b.bundle.Sources = append(b.bundle.Sources, source)
|
||||
}
|
||||
|
||||
func (c *Client) policyFor(source string) config.MissingSourcePolicy {
|
||||
if policy, ok := c.missingSource.Sources[source]; ok {
|
||||
return policy
|
||||
}
|
||||
return c.missingSource.Default
|
||||
}
|
||||
|
||||
type queryOptions struct {
|
||||
precision bool
|
||||
timezone bool
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, forecast.Source, error) {
|
||||
reqURL := c.endpointURL(endpoint, opts)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, forecast.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, forecast.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
return nil, forecast.Source{}, fmt.Errorf("read %s response: %w", endpoint, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, forecast.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return nil, forecast.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
|
||||
}
|
||||
|
||||
source := forecast.Source{
|
||||
Name: sourceName,
|
||||
Endpoint: endpoint,
|
||||
Query: queryMap(reqURL.Query()),
|
||||
FetchedAt: c.now(),
|
||||
}
|
||||
if len(env.Data) == 0 || bytes.Equal(bytes.TrimSpace(env.Data), []byte("null")) {
|
||||
source.Missing = true
|
||||
return nil, source, nil
|
||||
}
|
||||
hash, err := sourceHash(env.Data)
|
||||
if err != nil {
|
||||
return env.Data, source, nil
|
||||
}
|
||||
source.DataSHA256 = hash
|
||||
return env.Data, source, nil
|
||||
}
|
||||
|
||||
func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
|
||||
reqURL := *c.baseURL
|
||||
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
|
||||
query := reqURL.Query()
|
||||
query.Set("format", c.format)
|
||||
query.Set("units", c.units)
|
||||
if opts.precision {
|
||||
query.Set("precision", strconv.Itoa(c.precision))
|
||||
}
|
||||
if opts.timezone {
|
||||
query.Set("tz", c.timezone)
|
||||
}
|
||||
reqURL.RawQuery = query.Encode()
|
||||
return &reqURL
|
||||
}
|
||||
|
||||
func queryMap(values url.Values) map[string]string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
if len(value) > 0 {
|
||||
out[key] = value[0]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeSource(raw json.RawMessage, target any) error {
|
||||
if err := json.Unmarshal(raw, target); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sourceHash(raw json.RawMessage) (string, error) {
|
||||
var compact bytes.Buffer
|
||||
if err := json.Compact(&compact, raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(compact.Bytes())
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func SaveBundle(path string, bundle *forecast.Bundle) error {
|
||||
data, err := json.MarshalIndent(bundle, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal forecast bundle: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create bundle directory %q: %w", filepath.Dir(path), err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary bundle file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary bundle file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary bundle file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("save bundle %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
348
internal/adapters/weatherapi/client_test.go
Normal file
348
internal/adapters/weatherapi/client_test.go
Normal file
@@ -0,0 +1,348 @@
|
||||
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
|
||||
}
|
||||
6
internal/adapters/weatherapi/testdata/alerts.json
vendored
Normal file
6
internal/adapters/weatherapi/testdata/alerts.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"data": {
|
||||
"asOf": "2026-05-29T14:00:00Z",
|
||||
"alerts": []
|
||||
}
|
||||
}
|
||||
10
internal/adapters/weatherapi/testdata/current.json
vendored
Normal file
10
internal/adapters/weatherapi/testdata/current.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"data": {
|
||||
"conditionText": "Partly cloudy",
|
||||
"isDay": true,
|
||||
"temperatureF": 75.9,
|
||||
"apparentTemperatureF": 76.1,
|
||||
"windSpeedMph": 10.7,
|
||||
"relativeHumidityPercent": 56
|
||||
}
|
||||
}
|
||||
16
internal/adapters/weatherapi/testdata/discussion.json
vendored
Normal file
16
internal/adapters/weatherapi/testdata/discussion.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"data": {
|
||||
"officeId": "LSX",
|
||||
"officeName": "St. Louis",
|
||||
"product": "discussion",
|
||||
"issuedAt": "2026-05-29T09:25:00-05:00",
|
||||
"keyMessages": [
|
||||
"Scattered showers possible this evening.",
|
||||
"Warmer temperatures this weekend."
|
||||
],
|
||||
"shortTerm": {
|
||||
"title": "Short Term",
|
||||
"narrative": "A weak boundary may trigger isolated showers."
|
||||
}
|
||||
}
|
||||
}
|
||||
25
internal/adapters/weatherapi/testdata/hourly.json
vendored
Normal file
25
internal/adapters/weatherapi/testdata/hourly.json
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"data": {
|
||||
"locationId": "nws-lsx-grid-90-74",
|
||||
"locationName": "St. Louis, MO",
|
||||
"issuedAt": "2026-05-29T10:30:00-05:00",
|
||||
"updatedAt": "2026-05-29T10:45:00-05:00",
|
||||
"product": "hourly",
|
||||
"latitude": 38.63,
|
||||
"longitude": -90.2,
|
||||
"elevationFeet": 466,
|
||||
"periods": [
|
||||
{
|
||||
"startTime": "2026-05-29T13:00:00-05:00",
|
||||
"endTime": "2026-05-29T14:00:00-05:00",
|
||||
"isDay": true,
|
||||
"conditionCode": 3,
|
||||
"textDescription": "Partly sunny",
|
||||
"temperatureF": 81,
|
||||
"windSpeedMph": 12,
|
||||
"windGustMph": 20,
|
||||
"probabilityOfPrecipitationPercent": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
20
internal/adapters/weatherapi/testdata/narrative.json
vendored
Normal file
20
internal/adapters/weatherapi/testdata/narrative.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"data": {
|
||||
"locationId": "nws-lsx-grid-90-74",
|
||||
"locationName": "St. Louis, MO",
|
||||
"issuedAt": "2026-05-29T10:30:00-05:00",
|
||||
"product": "narrative",
|
||||
"periods": [
|
||||
{
|
||||
"startTime": "2026-05-29T13:00:00-05:00",
|
||||
"endTime": "2026-05-29T19:00:00-05:00",
|
||||
"name": "Today",
|
||||
"isDay": true,
|
||||
"textDescription": "Partly sunny, with a high near 81.",
|
||||
"temperatureF": 81,
|
||||
"windSpeedMph": 12,
|
||||
"probabilityOfPrecipitationPercent": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
13
internal/adapters/weatherapi/testdata/observations.json
vendored
Normal file
13
internal/adapters/weatherapi/testdata/observations.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"data": {
|
||||
"stationId": "KSTL",
|
||||
"stationName": "St. Louis",
|
||||
"timestamp": "2026-05-29T14:00:00Z",
|
||||
"conditionCode": 3,
|
||||
"isDay": true,
|
||||
"textDescription": "Partly cloudy",
|
||||
"temperatureF": 75.9,
|
||||
"windSpeedMph": 10.7,
|
||||
"relativeHumidityPercent": 56
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user