diff --git a/docs/integrations/weatherapi.md b/docs/integrations/weatherapi.md index ef12b3c5..dc94896 100644 --- a/docs/integrations/weatherapi.md +++ b/docs/integrations/weatherapi.md @@ -57,14 +57,15 @@ An absent `data` member is treated as a missing source. For ordinary sources, `data: null` is also missing. The active-alert exception is listed above: its explicit `null` payload represents an empty alert result. -Hourly forecast data must be present and contain at least one `period`; a -missing, malformed, or empty hourly product fails collection. The remaining -sources follow the configured missing-source policy. Under `error`, collection -fails; under `warn`, the source is omitted and an inspectable warning is -recorded; under `none`, the source is omitted without a warning. A per-source -policy overrides the default. See [Configuration](../config.md) for policy -settings and [Weather data internals](../internal/weather-data.md) for recorded -source metadata. +Hourly forecast data must be present and contain at least one `period`. Every +hourly period needs nonzero `startTime` and `endTime` values, with `endTime` +after `startTime`; a missing, malformed, empty, or invalidly bounded hourly +product fails collection. The remaining sources follow the configured +missing-source policy. Under `error`, collection fails; under `warn`, the +source is omitted and an inspectable warning is recorded; under `none`, the +source is omitted without a warning. A per-source policy overrides the default. +See [Configuration](../config.md) for policy settings and [Weather data +internals](../internal/weather-data.md) for recorded source metadata. Malformed top-level JSON envelopes and HTTP failures are direct request errors. Malformed `data` for an optional source follows its missing-source policy. diff --git a/docs/internal/collect.md b/docs/internal/collect.md index 5fbd7d8..dd50e5e 100644 --- a/docs/internal/collect.md +++ b/docs/internal/collect.md @@ -20,6 +20,9 @@ Fetch failures retain Weather API endpoint context but do not project upstream response bodies into application-facing errors. Oversized response bodies fail collection before source decoding. +The required hourly product must contain one or more periods with usable time +bounds. An invalid hourly product fails collection before derivation begins. + ## Application Composition `internal/app` owns the narrow `Collector` interface used by workflow tests; diff --git a/docs/internal/weather-data.md b/docs/internal/weather-data.md index 3c6e19d..485f6f3 100644 --- a/docs/internal/weather-data.md +++ b/docs/internal/weather-data.md @@ -45,6 +45,10 @@ marked missing only when the adapter's missing-source policy treats the response or parsing failure as unavailable. The policy itself belongs to the [configuration reference](../config.md). +Accepted hourly forecast periods always have nonzero start and end times, with +the end after the start. Collection rejects a required hourly product that does +not meet those bounds before it enters downstream derivation. + ## Warning semantics `SourceWarning` has a source name, stable code, severity, explanatory message, diff --git a/internal/adapters/weatherapi/client.go b/internal/adapters/weatherapi/client.go index 148ab27..28461bc 100644 --- a/internal/adapters/weatherapi/client.go +++ b/internal/adapters/weatherapi/client.go @@ -224,6 +224,11 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error { if len(hourly.Periods) == 0 { return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint) } + for i, period := range hourly.Periods { + if !period.HasUsableTimeBounds() { + return fmt.Errorf("hourly forecast from %s has unusable time bounds for period %d", source.Endpoint, i+1) + } + } source.IssuedAt = &hourly.IssuedAt source.UpdatedAt = hourly.UpdatedAt b.bundle.Hourly = &hourly diff --git a/internal/adapters/weatherapi/client_test.go b/internal/adapters/weatherapi/client_test.go index b66ddea..fda6401 100644 --- a/internal/adapters/weatherapi/client_test.go +++ b/internal/adapters/weatherapi/client_test.go @@ -501,6 +501,66 @@ func TestRequiredHourlyForecast(t *testing.T) { } } +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}`}, diff --git a/internal/weatherdata/bundle.go b/internal/weatherdata/bundle.go index f00c6ab..3b8d1b3 100644 --- a/internal/weatherdata/bundle.go +++ b/internal/weatherdata/bundle.go @@ -132,6 +132,11 @@ type ForecastPeriod struct { RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"` } +// HasUsableTimeBounds reports whether the period has a non-empty time range. +func (p ForecastPeriod) HasUsableTimeBounds() bool { + return !p.StartTime.IsZero() && !p.EndTime.IsZero() && p.EndTime.After(p.StartTime) +} + type AlertRun struct { AsOf *time.Time `json:"asOf,omitempty"` Alerts []json.RawMessage `json:"alerts,omitempty"` diff --git a/internal/weatherdata/bundle_test.go b/internal/weatherdata/bundle_test.go index f18440d..e589984 100644 --- a/internal/weatherdata/bundle_test.go +++ b/internal/weatherdata/bundle_test.go @@ -69,6 +69,30 @@ func TestConvectiveOutlookRunJSONPreservesGeometry(t *testing.T) { } } +func TestForecastPeriodHasUsableTimeBounds(t *testing.T) { + start := mustParseBundleTime("2026-05-29T13:00:00Z") + end := mustParseBundleTime("2026-05-29T14:00:00Z") + tests := []struct { + name string + period ForecastPeriod + want bool + }{ + {name: "valid", period: ForecastPeriod{StartTime: start, EndTime: end}, want: true}, + {name: "missing start", period: ForecastPeriod{EndTime: end}}, + {name: "missing end", period: ForecastPeriod{StartTime: start}}, + {name: "empty range", period: ForecastPeriod{StartTime: start, EndTime: start}}, + {name: "reversed range", period: ForecastPeriod{StartTime: end, EndTime: start}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.period.HasUsableTimeBounds(); got != tt.want { + t.Fatalf("HasUsableTimeBounds() = %t, want %t", got, tt.want) + } + }) + } +} + func mustParseBundleTime(value string) time.Time { parsed, err := time.Parse(time.RFC3339, value) if err != nil {