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 `data: null` is also missing. The active-alert exception is listed above: its
explicit `null` payload represents an empty alert result. explicit `null` payload represents an empty alert result.
Hourly forecast data must be present and contain at least one `period`; a Hourly forecast data must be present and contain at least one `period`. Every
missing, malformed, or empty hourly product fails collection. The remaining hourly period needs nonzero `startTime` and `endTime` values, with `endTime`
sources follow the configured missing-source policy. Under `error`, collection after `startTime`; a missing, malformed, empty, or invalidly bounded hourly
fails; under `warn`, the source is omitted and an inspectable warning is product fails collection. The remaining sources follow the configured
recorded; under `none`, the source is omitted without a warning. A per-source missing-source policy. Under `error`, collection fails; under `warn`, the
policy overrides the default. See [Configuration](../config.md) for policy source is omitted and an inspectable warning is recorded; under `none`, the
settings and [Weather data internals](../internal/weather-data.md) for recorded source is omitted without a warning. A per-source policy overrides the default.
source metadata. 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 top-level JSON envelopes and HTTP failures are direct request errors.
Malformed `data` for an optional source follows its missing-source policy. 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 response bodies into application-facing errors. Oversized response bodies fail
collection before source decoding. 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 ## Application Composition
`internal/app` owns the narrow `Collector` interface used by workflow tests; `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 response or parsing failure as unavailable. The policy itself belongs to the
[configuration reference](../config.md). [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 ## Warning semantics
`SourceWarning` has a source name, stable code, severity, explanatory message, `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 { if len(hourly.Periods) == 0 {
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint) 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.IssuedAt = &hourly.IssuedAt
source.UpdatedAt = hourly.UpdatedAt source.UpdatedAt = hourly.UpdatedAt
b.bundle.Hourly = &hourly 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) { func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{ server := fixtureServer(t, map[string]handlerOverride{
"/alerts/active": {status: http.StatusOK, body: `{"data": null}`}, "/alerts/active": {status: http.StatusOK, body: `{"data": null}`},

View File

@@ -132,6 +132,11 @@ type ForecastPeriod struct {
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"` 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 { type AlertRun struct {
AsOf *time.Time `json:"asOf,omitempty"` AsOf *time.Time `json:"asOf,omitempty"`
Alerts []json.RawMessage `json:"alerts,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 { func mustParseBundleTime(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value) parsed, err := time.Parse(time.RFC3339, value)
if err != nil { if err != nil {