Validate hourly forecast time bounds

This commit is contained in:
2026-08-13 00:37:43 +00:00
parent c515529b3a
commit 8bb7307f22
7 changed files with 110 additions and 8 deletions

View File

@@ -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.

View File

@@ -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;

View File

@@ -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,

View File

@@ -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

View File

@@ -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}`},

View File

@@ -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"`

View File

@@ -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 {