diff --git a/docs/integrations/weatherapi.md b/docs/integrations/weatherapi.md index 4f27ab3..04ec200 100644 --- a/docs/integrations/weatherapi.md +++ b/docs/integrations/weatherapi.md @@ -28,9 +28,14 @@ Every response used by the adapter must be JSON with a top-level `data` field: } ``` -`data: null` is treated as a missing source. Missing optional sources follow the -configured missing-source policy. Missing hourly forecast data fails bundle -fetching because hourly periods are required for report generation. +For most sources, `data: null` is treated as a missing source. Missing optional +sources follow the configured missing-source policy. Missing hourly forecast +data fails bundle fetching because hourly periods are required for report +generation. + +`/alerts/active` is the exception: a successful response with `data: null` +means the endpoint was checked and there are no current active alerts. The +adapter records a non-missing alerts source and an empty alert run. Malformed JSON envelopes, non-2xx statuses, and response read failures include endpoint context in returned errors. Decode errors include source context when @@ -92,9 +97,14 @@ Policy behavior: - `warn`: omit the source data, add a warning, and continue - `none`: omit the source data and continue without a warning +For `/alerts/active`, an HTTP error or missing `data` field still fails or +follows the relevant error path, but explicit `data: null` is not a +missing-source condition. + ## Source Identity -For non-null source payloads, the adapter records: +For source payloads accepted into the bundle, including the explicit `null` +alerts payload, the adapter records: - source name - endpoint path diff --git a/docs/internal/weather-data.md b/docs/internal/weather-data.md index ada9546..b851e80 100644 --- a/docs/internal/weather-data.md +++ b/docs/internal/weather-data.md @@ -53,8 +53,9 @@ contract used by this project. The adapter records source name, endpoint, query, fetch time, source timestamps when available, SHA-256 hash over compact raw `data` JSON, missing status, and -source warnings. `app.FetchAndSaveBundle` can write bundle JSON atomically for -inspection. +source warnings. Successful `data: null` responses from `/alerts/active` +represent a checked empty active-alert list, not a missing source. +`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection. ## Skip And Resume Behavior @@ -69,6 +70,8 @@ data is required and cannot be skipped. endpoint context. - Missing hourly data or hourly forecasts with no periods fail bundle fetch. - Optional and stub sources follow missing-source policy. +- Explicit `data: null` from `/alerts/active` produces an empty, non-missing + alert run. ## Tests diff --git a/internal/adapters/weatherapi/client.go b/internal/adapters/weatherapi/client.go index eabae26..6b33368 100644 --- a/internal/adapters/weatherapi/client.go +++ b/internal/adapters/weatherapi/client.go @@ -202,13 +202,18 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error { } func (b *bundleBuilder) fetchAlerts(ctx context.Context) error { - raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{}) + raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true}) if err != nil { return err } if raw == nil { return b.handleMissing(&source, "active alerts data is missing", false) } + if isJSONNull(raw) { + b.bundle.Alerts = &forecast.AlertRun{Raw: append(json.RawMessage(nil), raw...)} + b.addSource(source) + return nil + } var alerts forecast.AlertRun if err := decodeSource(raw, &alerts); err != nil { return b.handleMalformed(&source, err, false) @@ -301,6 +306,7 @@ func (c *Client) policyFor(source string) config.MissingSourcePolicy { type queryOptions struct { precision bool timezone bool + allowNull bool } type envelope struct { @@ -339,7 +345,7 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, Query: queryMap(reqURL.Query()), FetchedAt: c.now(), } - if len(env.Data) == 0 || bytes.Equal(bytes.TrimSpace(env.Data), []byte("null")) { + if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) { source.Missing = true return nil, source, nil } @@ -351,6 +357,10 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, return env.Data, source, nil } +func isJSONNull(raw json.RawMessage) bool { + return bytes.Equal(bytes.TrimSpace(raw), []byte("null")) +} + func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL { reqURL := *c.baseURL reqURL.Path = path.Join(c.baseURL.Path, endpoint) diff --git a/internal/adapters/weatherapi/client_test.go b/internal/adapters/weatherapi/client_test.go index a1b17d1..fc3151d 100644 --- a/internal/adapters/weatherapi/client_test.go +++ b/internal/adapters/weatherapi/client_test.go @@ -125,6 +125,36 @@ func TestRequiredHourlyForecast(t *testing.T) { } } +func TestNullAlertsMeansNoActiveAlerts(t *testing.T) { + server := fixtureServer(t, map[string]handlerOverride{ + "/alerts/active": {status: http.StatusOK, body: `{"data": null}`}, + }, nil) + client := newTestClient(t, server.URL+"/", nil) + + bundle, err := client.FetchBundle(context.Background()) + if err != nil { + t.Fatalf("FetchBundle() error = %v", err) + } + if bundle.Alerts == nil { + t.Fatal("Alerts = nil, want checked empty alert run") + } + if len(bundle.Alerts.Alerts) != 0 { + t.Fatalf("Alerts length = %d, want no active alerts", len(bundle.Alerts.Alerts)) + } + source := sourceByName(t, bundle.Sources, "alerts") + if source.Missing { + t.Fatalf("alerts source Missing = true, want false") + } + if source.DataSHA256 == "" { + t.Fatal("alerts DataSHA256 is empty, want hash for explicit null payload") + } + for _, warning := range bundle.Warnings { + if warning.Source == "alerts" { + t.Fatalf("warnings = %#v, want no alerts warning", bundle.Warnings) + } + } +} + func TestMissingSourcePolicyWarnNoneError(t *testing.T) { tests := []struct { name string diff --git a/internal/briefing/daily.go b/internal/briefing/daily.go index 784f5ac..8e5816b 100644 --- a/internal/briefing/daily.go +++ b/internal/briefing/daily.go @@ -81,6 +81,7 @@ func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, erro ForecastSummaryDate: summary.Date, }, } + setRelevantAlertCount(&pkg.Metadata, len(summary.AlertOverlaps)) if ctx.Resolved.Definition.ID == report.DailyTomorrow { pkg.Daily.Planning = buildTomorrowPlanning(summary) } diff --git a/internal/briefing/daily_test.go b/internal/briefing/daily_test.go index 93998a3..8d89ad0 100644 --- a/internal/briefing/daily_test.go +++ b/internal/briefing/daily_test.go @@ -85,7 +85,11 @@ func TestDailyBriefingQuietWeather(t *testing.T) { Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{ quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72), }}, - Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}}, + Alerts: &forecast.AlertRun{}, + Sources: []forecast.Source{ + {Name: "hourly", FetchedAt: time.Now()}, + {Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()}, + }, } summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) if err != nil { @@ -101,6 +105,19 @@ func TestDailyBriefingQuietWeather(t *testing.T) { if len(pkg.Daily.RelevantAlerts) != 0 { t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts)) } + if pkg.Metadata.Alerts == nil { + t.Fatal("Metadata.Alerts = nil, want checked no-active-alerts status") + } + if !pkg.Metadata.Alerts.Checked || pkg.Metadata.Alerts.ActiveCount != 0 || pkg.Metadata.Alerts.RelevantCount != 0 || pkg.Metadata.Alerts.Missing { + t.Fatalf("Metadata.Alerts = %#v, want checked no-active-alerts status", pkg.Metadata.Alerts) + } + data, err := json.Marshal(pkg.Metadata) + if err != nil { + t.Fatalf("marshal metadata: %v", err) + } + if strings.Contains(string(data), `"missing"`) { + t.Fatalf("metadata includes missing for checked empty alerts:\n%s", string(data)) + } } func TestDailyBriefingAlertExclusion(t *testing.T) { diff --git a/internal/briefing/package.go b/internal/briefing/package.go index ca64160..1f85cda 100644 --- a/internal/briefing/package.go +++ b/internal/briefing/package.go @@ -35,6 +35,7 @@ type Metadata struct { SourceLocation string `json:"sourceLocation,omitempty"` Sources []SourceMetadata `json:"sources,omitempty"` SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"` + Alerts *AlertStatus `json:"alerts,omitempty"` } type SourceMetadata struct { @@ -48,6 +49,13 @@ type SourceMetadata struct { Warnings []forecast.SourceWarning `json:"warnings,omitempty"` } +type AlertStatus struct { + Checked bool `json:"checked"` + ActiveCount int `json:"activeCount"` + RelevantCount int `json:"relevantCount"` + Missing bool `json:"missing,omitempty"` +} + type BuildContext struct { Resolved report.Resolved Bundle *forecast.Bundle @@ -72,6 +80,7 @@ func BuildMetadata(ctx BuildContext) Metadata { SourceLocation: sourceLocation, Sources: sourceMetadata(ctx.Bundle), SourceWarnings: sourceWarnings(ctx.Bundle), + Alerts: alertStatus(ctx.Bundle), } } @@ -124,6 +133,37 @@ func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning { return bundle.Warnings } +func alertStatus(bundle *forecast.Bundle) *AlertStatus { + if bundle == nil { + return nil + } + status := &AlertStatus{} + if bundle.Alerts != nil { + status.Checked = true + status.ActiveCount = len(bundle.Alerts.Alerts) + } + for _, source := range bundle.Sources { + if source.Name == "alerts" && source.Missing { + status.Missing = true + break + } + } + if !status.Checked && !status.Missing { + return nil + } + return status +} + +func setRelevantAlertCount(metadata *Metadata, count int) { + if metadata.Alerts == nil { + if count == 0 { + return + } + metadata.Alerts = &AlertStatus{} + } + metadata.Alerts.RelevantCount = count +} + func variantForReport(id report.ID) string { switch id { case report.DailyToday: diff --git a/internal/briefing/storm.go b/internal/briefing/storm.go index 7485ea7..4827acf 100644 --- a/internal/briefing/storm.go +++ b/internal/briefing/storm.go @@ -56,10 +56,12 @@ func BuildStorm(ctx BuildContext) (Package, error) { Discussion: buildDiscussion(ctx.Bundle.Discussion), WeatherStory: buildWeatherStory(ctx.Bundle), } - return Package{ + pkg := Package{ Metadata: BuildMetadata(ctx), Storm: storm, - }, nil + } + setRelevantAlertCount(&pkg.Metadata, len(alerts)) + return pkg, nil } func stormHeadlines(alerts []forecast.AlertOverlap) []string { diff --git a/internal/briefing/three_day.go b/internal/briefing/three_day.go index da054f5..3abf130 100644 --- a/internal/briefing/three_day.go +++ b/internal/briefing/three_day.go @@ -49,6 +49,7 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day) } pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days) + setRelevantAlertCount(&pkg.Metadata, len(pkg.ThreeDay.RelevantAlerts)) return pkg, nil } diff --git a/internal/briefing/weekend.go b/internal/briefing/weekend.go index 8a41463..86e7563 100644 --- a/internal/briefing/weekend.go +++ b/internal/briefing/weekend.go @@ -42,6 +42,7 @@ func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package, pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary)) } pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days) + setRelevantAlertCount(&pkg.Metadata, len(pkg.Weekend.RelevantAlerts)) pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle) return pkg, nil }