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
|
`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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`},
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user