Validate hourly forecast time bounds
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`},
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user