Compare commits
5 Commits
90abc536dd
...
v0.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 6316783c3a | |||
| cdfe8881d9 | |||
| 8d0bc90f9d | |||
| 12a7447494 | |||
| dd2f24316c |
10
docs/api.md
10
docs/api.md
@@ -194,10 +194,14 @@ GET /conditions/current?format=json&precision=0
|
|||||||
GET /alerts/active
|
GET /alerts/active
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns the latest active-alert snapshot.
|
Returns the latest stored alert run filtered to alerts active at request time.
|
||||||
|
|
||||||
Query parameters: `format`, `units`.
|
Query parameters: `format`, `units`.
|
||||||
|
|
||||||
|
When no latest alert run exists, `data` is null. When a latest run exists but
|
||||||
|
no alerts are currently active, `data` remains an object and `alerts` is an
|
||||||
|
empty array.
|
||||||
|
|
||||||
Run `data` fields:
|
Run `data` fields:
|
||||||
|
|
||||||
| Field | Type | Notes |
|
| Field | Type | Notes |
|
||||||
@@ -207,6 +211,10 @@ Run `data` fields:
|
|||||||
| `latitude`, `longitude` | number | optional |
|
| `latitude`, `longitude` | number | optional |
|
||||||
| `alerts` | array | active alerts, possibly empty |
|
| `alerts` | array | active alerts, possibly empty |
|
||||||
|
|
||||||
|
Alerts are active when `messageType` is not `Cancel`, `effective` is absent or
|
||||||
|
at or before request time, and `expires` is absent or after request time.
|
||||||
|
`onset` is presented when available but is not used as the active boundary.
|
||||||
|
|
||||||
Alert fields include `id`, `event`, `headline`, `severity`, `urgency`,
|
Alert fields include `id`, `event`, `headline`, `severity`, `urgency`,
|
||||||
`certainty`, `status`, `messageType`, `category`, `response`, `description`,
|
`certainty`, `status`, `messageType`, `category`, `response`, `description`,
|
||||||
`instruction`, `sent`, `effective`, `onset`, `expires`, `areaDescription`,
|
`instruction`, `sent`, `effective`, `onset`, `expires`, `areaDescription`,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ This document describes the internal HTTP adapter under
|
|||||||
The HTTP adapter turns feedapi route definitions into calls on the application
|
The HTTP adapter turns feedapi route definitions into calls on the application
|
||||||
service boundary. It owns route registration, query binding, request validation,
|
service boundary. It owns route registration, query binding, request validation,
|
||||||
forecast day-slice filtering, outlook active/location filter construction,
|
forecast day-slice filtering, outlook active/location filter construction,
|
||||||
response envelopes, and template names.
|
alert active-time selection, response envelopes, and template names.
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs and Outputs
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ The adapter may:
|
|||||||
- call the `Service` interface;
|
- call the `Service` interface;
|
||||||
- choose the presenter function for an endpoint;
|
- choose the presenter function for an endpoint;
|
||||||
- filter forecast copies for `/today` and `/tomorrow`;
|
- filter forecast copies for `/today` and `/tomorrow`;
|
||||||
|
- pass the current UTC instant to active-alert application filtering;
|
||||||
- construct outlook active/location filters.
|
- construct outlook active/location filters.
|
||||||
|
|
||||||
The adapter must not:
|
The adapter must not:
|
||||||
@@ -61,9 +62,9 @@ declared in endpoint definitions, but `templates.base_dir` is loaded by feedapi.
|
|||||||
|
|
||||||
## State
|
## State
|
||||||
|
|
||||||
The adapter has no durable state. `forecastNow` and `outlookNow` are
|
The adapter has no durable state. `forecastNow`, `alertNow`, and `outlookNow`
|
||||||
package-level state only to make time-dependent endpoint tests deterministic.
|
are package-level state only to make time-dependent endpoint tests
|
||||||
Do not add request caches or cross-request mutable state here.
|
deterministic. Do not add request caches or cross-request mutable state here.
|
||||||
|
|
||||||
## Route Registry
|
## Route Registry
|
||||||
|
|
||||||
@@ -131,6 +132,12 @@ Filtering behavior:
|
|||||||
The package variable `forecastNow` exists so endpoint tests can make day-slice
|
The package variable `forecastNow` exists so endpoint tests can make day-slice
|
||||||
behavior deterministic.
|
behavior deterministic.
|
||||||
|
|
||||||
|
## Alert Active Time
|
||||||
|
|
||||||
|
`/alerts/active` uses the shared `format` and `units` binder. The handler calls
|
||||||
|
the application service with `alertNow().UTC()` so active alert filtering uses
|
||||||
|
the request-time instant while remaining deterministic in endpoint tests.
|
||||||
|
|
||||||
## Outlook Filters
|
## Outlook Filters
|
||||||
|
|
||||||
Outlook route filters are built at the HTTP boundary and passed to the
|
Outlook route filters are built at the HTTP boundary and passed to the
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ successful responses with `data: null`.
|
|||||||
- `CurrentConditions`: aggregates recent rows from `observations` using the
|
- `CurrentConditions`: aggregates recent rows from `observations` using the
|
||||||
application-provided observation window.
|
application-provided observation window.
|
||||||
- `LatestAlertRun`: latest row from `alert_runs`, then child `alerts` and
|
- `LatestAlertRun`: latest row from `alert_runs`, then child `alerts` and
|
||||||
`alert_references`.
|
`alert_references`. This is the latest stored alert snapshot; active-time
|
||||||
|
filtering is performed by the application service.
|
||||||
- `LatestHourlyForecast`: latest `forecasts` row where `product = 'hourly'`,
|
- `LatestHourlyForecast`: latest `forecasts` row where `product = 'hourly'`,
|
||||||
then child `forecast_periods`.
|
then child `forecast_periods`.
|
||||||
- `LatestNarrativeForecast`: latest `forecasts` row where
|
- `LatestNarrativeForecast`: latest `forecasts` row where
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ Outputs are HTTP responses in JSON, XML, or text format. All public endpoint han
|
|||||||
|
|
||||||
`weatherapi` owns no durable weather state. Its runtime state is limited to process memory, loaded configuration, HTTP server state, template registry, renderer registry, and database connection pools. Durable weather data and schema creation are external concerns owned by `weatherfeeder` and Postgres.
|
`weatherapi` owns no durable weather state. Its runtime state is limited to process memory, loaded configuration, HTTP server state, template registry, renderer registry, and database connection pools. Durable weather data and schema creation are external concerns owned by `weatherfeeder` and Postgres.
|
||||||
|
|
||||||
The API currently serves latest-resource views: latest observation, current conditions, latest active alerts run, latest hourly forecast, latest narrative forecast, latest forecast discussion, latest weather story run, latest individual weather story, and latest convective outlook run. Forecast `today` and `tomorrow` routes derive filtered copies from the latest run. Convective outlook active and location routes derive filtered copies from the latest run.
|
The API currently serves latest-resource views: latest observation, current conditions, latest hourly forecast, latest narrative forecast, latest forecast discussion, latest weather story run, latest individual weather story, and latest convective outlook run. Active alert, forecast `today` and `tomorrow`, and convective outlook active/location routes derive filtered copies from the latest stored run.
|
||||||
|
|
||||||
## Configuration and CLI Boundaries
|
## Configuration and CLI Boundaries
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
# Active Alert Filtering Cleanup
|
# Active Alert Filtering Cleanup
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Implementation, current-behavior documentation, and automated verification are
|
||||||
|
complete.
|
||||||
|
|
||||||
|
## Verification Record
|
||||||
|
|
||||||
|
Completed on 2026-06-12:
|
||||||
|
|
||||||
|
- `go test ./internal/app`
|
||||||
|
- `go test ./internal/adapters/inbound/httpapi`
|
||||||
|
- `go test ./internal/adapters/inbound/httpapi/presenter`
|
||||||
|
- `go test ./...`
|
||||||
|
|
||||||
|
Deployment smoke checks were not run from this workspace because the local
|
||||||
|
repository state is not a deployed `weatherapi` instance.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Fix `/alerts/active` so it returns alerts that are active at request time, not merely every alert from the latest stored alert snapshot.
|
Fix `/alerts/active` so it returns alerts that are active at request time, not merely every alert from the latest stored alert snapshot.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
|
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
|
||||||
"gitea.maximumdirect.net/ejr/feedapi/render"
|
"gitea.maximumdirect.net/ejr/feedapi/render"
|
||||||
@@ -11,12 +12,14 @@ import (
|
|||||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
|
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var alertNow = time.Now
|
||||||
|
|
||||||
func alertsDefinition(svc Service) endpoint.Definition {
|
func alertsDefinition(svc Service) endpoint.Definition {
|
||||||
return endpoint.GET(
|
return endpoint.GET(
|
||||||
"/alerts/active",
|
"/alerts/active",
|
||||||
bindQuery,
|
bindQuery,
|
||||||
func(ctx context.Context, req queryRequest) (any, error) {
|
func(ctx context.Context, req queryRequest) (any, error) {
|
||||||
run, err := svc.LatestAlertRun(ctx)
|
run, err := svc.LatestActiveAlertRun(ctx, alertNow().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type fakeService struct {
|
|||||||
weatherStoryRun *model.WeatherStoryRun
|
weatherStoryRun *model.WeatherStoryRun
|
||||||
weatherStory *model.WeatherStory
|
weatherStory *model.WeatherStory
|
||||||
alerts *model.WeatherAlertRun
|
alerts *model.WeatherAlertRun
|
||||||
|
activeAlertAt []time.Time
|
||||||
outlookRun *model.WeatherOutlookRun
|
outlookRun *model.WeatherOutlookRun
|
||||||
outlookFilters []app.OutlookFilter
|
outlookFilters []app.OutlookFilter
|
||||||
conditions *app.CurrentConditions
|
conditions *app.CurrentConditions
|
||||||
@@ -64,6 +65,11 @@ func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, e
|
|||||||
return s.alerts, s.err
|
return s.alerts, s.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *fakeService) LatestActiveAlertRun(_ context.Context, activeAt time.Time) (*model.WeatherAlertRun, error) {
|
||||||
|
s.activeAlertAt = append(s.activeAlertAt, activeAt)
|
||||||
|
return s.alerts, s.err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *fakeService) LatestConvectiveOutlook(_ context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error) {
|
func (s *fakeService) LatestConvectiveOutlook(_ context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error) {
|
||||||
s.outlookFilters = append(s.outlookFilters, filter)
|
s.outlookFilters = append(s.outlookFilters, filter)
|
||||||
return s.outlookRun, s.err
|
return s.outlookRun, s.err
|
||||||
@@ -73,6 +79,46 @@ func (s *fakeService) CurrentConditions(context.Context) (*app.CurrentConditions
|
|||||||
return s.conditions, s.err
|
return s.conditions, s.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type alertRepository struct {
|
||||||
|
alerts *model.WeatherAlertRun
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) LatestObservation(context.Context) (*model.WeatherObservation, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) LatestHourlyForecast(context.Context) (*model.WeatherForecastRun, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) LatestNarrativeForecast(context.Context) (*model.WeatherForecastRun, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) LatestForecastDiscussion(context.Context) (*model.WeatherForecastDiscussion, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) LatestWeatherStoryRun(context.Context) (*model.WeatherStoryRun, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) LatestWeatherStory(context.Context) (*model.WeatherStory, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
|
||||||
|
return r.alerts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) LatestConvectiveOutlookRun(context.Context) (*model.WeatherOutlookRun, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertRepository) CurrentConditions(context.Context, int) (*app.CurrentConditions, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestObservationsRejectUnknownQueryParameter(t *testing.T) {
|
func TestObservationsRejectUnknownQueryParameter(t *testing.T) {
|
||||||
h := newHandler(t, &fakeService{}, "/observations")
|
h := newHandler(t, &fakeService{}, "/observations")
|
||||||
|
|
||||||
@@ -315,6 +361,153 @@ func TestAlertsUSUnitsKeepSchema(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertsRouteRegistered(t *testing.T) {
|
||||||
|
def := definitionForPath(t, Definitions(&fakeService{}), "/alerts/active")
|
||||||
|
if len(def.Methods) != 1 || def.Methods[0] != http.MethodGet {
|
||||||
|
t.Fatalf("expected GET definition, got %+v", def.Methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertsHandlerPassesCurrentUTCInstant(t *testing.T) {
|
||||||
|
now := time.Date(2026, 6, 11, 7, 30, 0, 0, time.FixedZone("LOCAL", -5*60*60))
|
||||||
|
setAlertNowForTest(t, now)
|
||||||
|
svc := &fakeService{
|
||||||
|
alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()},
|
||||||
|
}
|
||||||
|
h := newHandler(t, svc, "/alerts/active")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/alerts/active", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if len(svc.activeAlertAt) != 1 {
|
||||||
|
t.Fatalf("expected one active-alert service call, got %d", len(svc.activeAlertAt))
|
||||||
|
}
|
||||||
|
want := now.UTC()
|
||||||
|
if !svc.activeAlertAt[0].Equal(want) {
|
||||||
|
t.Fatalf("expected activeAt %v, got %v", want, svc.activeAlertAt[0])
|
||||||
|
}
|
||||||
|
if svc.activeAlertAt[0].Location() != time.UTC {
|
||||||
|
t.Fatalf("expected UTC location, got %v", svc.activeAlertAt[0].Location())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertsNoDataReturnsNullEnvelopeData(t *testing.T) {
|
||||||
|
h := newHandler(t, &fakeService{}, "/alerts/active")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/alerts/active", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Data *json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode envelope: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Data != nil {
|
||||||
|
t.Fatalf("expected data null, got %s", string(*payload.Data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertsTextRendersNoData(t *testing.T) {
|
||||||
|
h := newHandler(t, &fakeService{}, "/alerts/active")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/alerts/active?format=text", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "No active alerts data available.") {
|
||||||
|
t.Fatalf("expected no-data alert text, got %q", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertsTextRendersEmptyActiveSet(t *testing.T) {
|
||||||
|
h := newHandler(t, &fakeService{
|
||||||
|
alerts: &model.WeatherAlertRun{
|
||||||
|
AsOf: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
|
||||||
|
Alerts: []model.WeatherAlert{},
|
||||||
|
},
|
||||||
|
}, "/alerts/active")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/alerts/active?format=text", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "Alerts: 0") {
|
||||||
|
t.Fatalf("expected empty alert count in text body, got %q", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertsTextOmitsInactiveAlertsAfterServiceFiltering(t *testing.T) {
|
||||||
|
activeAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||||
|
setAlertNowForTest(t, activeAt)
|
||||||
|
effective := activeAt.Add(-1 * time.Hour)
|
||||||
|
activeExpires := activeAt.Add(1 * time.Hour)
|
||||||
|
expiredAtBoundary := activeAt
|
||||||
|
repo := &alertRepository{
|
||||||
|
alerts: &model.WeatherAlertRun{
|
||||||
|
AsOf: activeAt,
|
||||||
|
Alerts: []model.WeatherAlert{
|
||||||
|
{
|
||||||
|
ID: "active-alert",
|
||||||
|
Headline: "Active warning",
|
||||||
|
MessageType: "Alert",
|
||||||
|
Effective: &effective,
|
||||||
|
Expires: &activeExpires,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "expired-alert",
|
||||||
|
Headline: "Expired warning",
|
||||||
|
MessageType: "Alert",
|
||||||
|
Effective: &effective,
|
||||||
|
Expires: &expiredAtBoundary,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "canceled-alert",
|
||||||
|
Headline: "Canceled warning",
|
||||||
|
MessageType: " cancel ",
|
||||||
|
Effective: &effective,
|
||||||
|
Expires: &activeExpires,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := newHandler(t, app.NewService(repo), "/alerts/active")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/alerts/active?format=text", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{"Alerts: 1", "active-alert", "Active warning"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("expected %q in text body, got %q", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, notWant := range []string{"expired-alert", "Expired warning", "canceled-alert", "Canceled warning"} {
|
||||||
|
if strings.Contains(body, notWant) {
|
||||||
|
t.Fatalf("did not expect %q in text body, got %q", notWant, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestObservationUSUnitsWithXMLFormat(t *testing.T) {
|
func TestObservationUSUnitsWithXMLFormat(t *testing.T) {
|
||||||
h := newHandler(t, &fakeService{
|
h := newHandler(t, &fakeService{
|
||||||
observation: &model.WeatherObservation{
|
observation: &model.WeatherObservation{
|
||||||
@@ -1265,17 +1458,24 @@ func TestPrecisionValidationRange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertsRejectPrecisionQueryParameter(t *testing.T) {
|
func TestAlertsRejectUnsupportedQueryParameters(t *testing.T) {
|
||||||
h := newHandler(t, &fakeService{
|
h := newHandler(t, &fakeService{
|
||||||
alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()},
|
alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()},
|
||||||
}, "/alerts/active")
|
}, "/alerts/active")
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
for _, rawURL := range []string{
|
||||||
req := httptest.NewRequest(http.MethodGet, "/alerts/active?precision=1", nil)
|
"/alerts/active?precision=1",
|
||||||
h.ServeHTTP(w, req)
|
"/alerts/active?tz=CDT",
|
||||||
|
"/alerts/active?TZ=CDT",
|
||||||
|
"/alerts/active?bogus=1",
|
||||||
|
} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, rawURL, nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
if w.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("expected 400, got %d", w.Code)
|
t.Fatalf("%s: expected 400, got %d", rawURL, w.Code)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2263,7 +2463,7 @@ func testRenderers(t *testing.T) *render.Registry {
|
|||||||
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}\nDiscussions: {{len .Data.Discussions}}{{range .Data.Discussions}}\nDiscussion: {{.Discussion}}{{end}}{{else}}No convective outlook data available.{{end}}",
|
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}\nDiscussions: {{len .Data.Discussions}}{{range .Data.Discussions}}\nDiscussion: {{.Discussion}}{{end}}{{else}}No convective outlook data available.{{end}}",
|
||||||
"weatherstories.txt.tmpl": "Weather Stories",
|
"weatherstories.txt.tmpl": "Weather Stories",
|
||||||
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
|
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
|
||||||
"alerts_active.txt.tmpl": "Alerts text",
|
"alerts_active.txt.tmpl": "{{if .Data}}Active Alerts\nAlerts: {{len .Data.Alerts}}{{range .Data.Alerts}}\n{{.ID}}{{if .Headline}}\nHeadline: {{.Headline}}{{end}}{{end}}{{else}}No active alerts data available.{{end}}",
|
||||||
"conditions_current.txt.tmpl": "Conditions text",
|
"conditions_current.txt.tmpl": "Conditions text",
|
||||||
} {
|
} {
|
||||||
tmpl, err := template.New(name).Parse(body)
|
tmpl, err := template.New(name).Parse(body)
|
||||||
@@ -2297,6 +2497,13 @@ func setOutlookNowForTest(t *testing.T, now time.Time) {
|
|||||||
t.Cleanup(func() { outlookNow = original })
|
t.Cleanup(func() { outlookNow = original })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setAlertNowForTest(t *testing.T, now time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
original := alertNow
|
||||||
|
alertNow = func() time.Time { return now }
|
||||||
|
t.Cleanup(func() { alertNow = original })
|
||||||
|
}
|
||||||
|
|
||||||
func testOutlookRun() *model.WeatherOutlookRun {
|
func testOutlookRun() *model.WeatherOutlookRun {
|
||||||
issuedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
issuedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||||
discussionUpdatedAt := issuedAt.Add(30 * time.Minute)
|
discussionUpdatedAt := issuedAt.Add(30 * time.Minute)
|
||||||
|
|||||||
@@ -337,6 +337,22 @@ func TestMetricCopyAndNilHandling(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertsPayloadPassThrough(t *testing.T) {
|
||||||
|
run := &model.WeatherAlertRun{
|
||||||
|
LocationID: "stl",
|
||||||
|
AsOf: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
|
||||||
|
Alerts: []model.WeatherAlert{{
|
||||||
|
ID: "alert-1",
|
||||||
|
Headline: "Storm warning",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := AlertsPayload(run, UnitsUS)
|
||||||
|
if payload != run {
|
||||||
|
t.Fatalf("expected alerts payload to pass through input run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
|
func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
|
||||||
conditions := &app.CurrentConditions{
|
conditions := &app.CurrentConditions{
|
||||||
TemperatureC: float64Ptr(20),
|
TemperatureC: float64Ptr(20),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
@@ -18,6 +19,7 @@ type Service interface {
|
|||||||
LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error)
|
LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error)
|
||||||
LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error)
|
LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error)
|
||||||
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
|
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
|
||||||
|
LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error)
|
||||||
LatestConvectiveOutlook(ctx context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error)
|
LatestConvectiveOutlook(ctx context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error)
|
||||||
CurrentConditions(ctx context.Context) (*app.CurrentConditions, error)
|
CurrentConditions(ctx context.Context) (*app.CurrentConditions, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,26 @@ func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, e
|
|||||||
return s.repo.LatestAlertRun(ctx)
|
return s.repo.LatestAlertRun(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error) {
|
||||||
|
run, err := s.repo.LatestAlertRun(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if run == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := cloneAlertRun(run)
|
||||||
|
alerts := out.Alerts[:0]
|
||||||
|
for _, alert := range out.Alerts {
|
||||||
|
if isActiveAlert(alert, activeAt) {
|
||||||
|
alerts = append(alerts, alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Alerts = alerts
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFilter) (*model.WeatherOutlookRun, error) {
|
func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFilter) (*model.WeatherOutlookRun, error) {
|
||||||
run, err := s.repo.LatestConvectiveOutlookRun(ctx)
|
run, err := s.repo.LatestConvectiveOutlookRun(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -113,6 +133,53 @@ func normalizeOutlookType(value string) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(value))
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneAlertRun(run *model.WeatherAlertRun) *model.WeatherAlertRun {
|
||||||
|
out := *run
|
||||||
|
out.Latitude = copyFloat64(run.Latitude)
|
||||||
|
out.Longitude = copyFloat64(run.Longitude)
|
||||||
|
if run.Alerts != nil {
|
||||||
|
out.Alerts = make([]model.WeatherAlert, len(run.Alerts))
|
||||||
|
for i := range run.Alerts {
|
||||||
|
out.Alerts[i] = cloneAlert(run.Alerts[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneAlert(alert model.WeatherAlert) model.WeatherAlert {
|
||||||
|
out := alert
|
||||||
|
out.Sent = copyTime(alert.Sent)
|
||||||
|
out.Effective = copyTime(alert.Effective)
|
||||||
|
out.Onset = copyTime(alert.Onset)
|
||||||
|
out.Expires = copyTime(alert.Expires)
|
||||||
|
if alert.References != nil {
|
||||||
|
out.References = make([]model.AlertReference, len(alert.References))
|
||||||
|
for i := range alert.References {
|
||||||
|
out.References[i] = cloneAlertReference(alert.References[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneAlertReference(ref model.AlertReference) model.AlertReference {
|
||||||
|
out := ref
|
||||||
|
out.Sent = copyTime(ref.Sent)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isActiveAlert(alert model.WeatherAlert, activeAt time.Time) bool {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(alert.MessageType), "Cancel") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if alert.Effective != nil && activeAt.Before(*alert.Effective) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if alert.Expires != nil && !activeAt.Before(*alert.Expires) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
|
func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
|
||||||
out := *run
|
out := *run
|
||||||
out.Latitude = copyFloat64(run.Latitude)
|
out.Latitude = copyFloat64(run.Latitude)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type fakeRepository struct {
|
|||||||
err error
|
err error
|
||||||
|
|
||||||
currentConditionsWindow int
|
currentConditionsWindow int
|
||||||
|
alertRunCalls int
|
||||||
outlookRunCalls int
|
outlookRunCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ func (r *fakeRepository) LatestWeatherStory(context.Context) (*model.WeatherStor
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
|
func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
|
||||||
|
r.alertRunCalls++
|
||||||
return r.alerts, r.err
|
return r.alerts, r.err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +120,137 @@ func TestServiceDelegatesAlerts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunDelegatesAndFilters(t *testing.T) {
|
||||||
|
activeAt := testTime(12)
|
||||||
|
repo := &fakeRepository{alerts: testAlertRun()}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if repo.alertRunCalls != 1 {
|
||||||
|
t.Fatalf("expected one repository call, got %d", repo.alertRunCalls)
|
||||||
|
}
|
||||||
|
assertAlertIDs(t, run, []string{"current", "effective-at-boundary", "missing-effective", "missing-expires", "later-onset"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunNoData(t *testing.T) {
|
||||||
|
repo := &fakeRepository{}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if run != nil {
|
||||||
|
t.Fatalf("expected nil alert run, got %+v", run)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunPropagatesErrors(t *testing.T) {
|
||||||
|
want := errors.New("alert read failed")
|
||||||
|
repo := &fakeRepository{err: want}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
|
||||||
|
if !errors.Is(err, want) {
|
||||||
|
t.Fatalf("expected error %v, got %v", want, err)
|
||||||
|
}
|
||||||
|
if run != nil {
|
||||||
|
t.Fatalf("expected nil alert run on error, got %+v", run)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunKeepsMetadataWithEmptyAlerts(t *testing.T) {
|
||||||
|
activeAt := testTime(12)
|
||||||
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{
|
||||||
|
testAlert("expired", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12)),
|
||||||
|
testAlert("cancel", "Cancel", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
|
||||||
|
testAlert("future", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(14)),
|
||||||
|
})}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if run == nil {
|
||||||
|
t.Fatal("expected alert run")
|
||||||
|
}
|
||||||
|
if run.LocationID != "stl" || run.LocationName != "St. Louis" || !run.AsOf.Equal(testTime(10)) {
|
||||||
|
t.Fatalf("unexpected run metadata: %+v", run)
|
||||||
|
}
|
||||||
|
if run.Latitude == nil || *run.Latitude != 38.62 {
|
||||||
|
t.Fatalf("unexpected latitude: %v", run.Latitude)
|
||||||
|
}
|
||||||
|
if run.Longitude == nil || *run.Longitude != -90.2 {
|
||||||
|
t.Fatalf("unexpected longitude: %v", run.Longitude)
|
||||||
|
}
|
||||||
|
if run.Alerts == nil {
|
||||||
|
t.Fatal("expected empty alert slice, got nil")
|
||||||
|
}
|
||||||
|
if len(run.Alerts) != 0 {
|
||||||
|
t.Fatalf("expected no alerts, got %+v", run.Alerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunDoesNotMutateRepositoryRun(t *testing.T) {
|
||||||
|
original := testAlertRun()
|
||||||
|
repo := &fakeRepository{alerts: original}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(run.Alerts) == 0 {
|
||||||
|
t.Fatal("expected active alerts")
|
||||||
|
}
|
||||||
|
|
||||||
|
*run.Latitude = 99
|
||||||
|
*run.Longitude = -99
|
||||||
|
*run.Alerts[0].Sent = testTime(1)
|
||||||
|
*run.Alerts[0].Effective = testTime(2)
|
||||||
|
*run.Alerts[0].Onset = testTime(3)
|
||||||
|
*run.Alerts[0].Expires = testTime(4)
|
||||||
|
*run.Alerts[0].References[0].Sent = testTime(5)
|
||||||
|
run.Alerts[0].ID = "changed"
|
||||||
|
run.Alerts[0].References[0].ID = "changed"
|
||||||
|
run.Alerts = run.Alerts[:1]
|
||||||
|
|
||||||
|
if *original.Latitude != 38.62 {
|
||||||
|
t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude)
|
||||||
|
}
|
||||||
|
if *original.Longitude != -90.2 {
|
||||||
|
t.Fatalf("expected original longitude unchanged, got %v", *original.Longitude)
|
||||||
|
}
|
||||||
|
if original.Alerts[0].ID != "current" {
|
||||||
|
t.Fatalf("expected original alert ID unchanged, got %q", original.Alerts[0].ID)
|
||||||
|
}
|
||||||
|
if original.Alerts[0].Sent == nil || !original.Alerts[0].Sent.Equal(testTime(9)) {
|
||||||
|
t.Fatalf("expected original sent unchanged, got %v", original.Alerts[0].Sent)
|
||||||
|
}
|
||||||
|
if original.Alerts[0].Effective == nil || !original.Alerts[0].Effective.Equal(testTime(10)) {
|
||||||
|
t.Fatalf("expected original effective unchanged, got %v", original.Alerts[0].Effective)
|
||||||
|
}
|
||||||
|
if original.Alerts[0].Onset == nil || !original.Alerts[0].Onset.Equal(testTime(11)) {
|
||||||
|
t.Fatalf("expected original onset unchanged, got %v", original.Alerts[0].Onset)
|
||||||
|
}
|
||||||
|
if original.Alerts[0].Expires == nil || !original.Alerts[0].Expires.Equal(testTime(13)) {
|
||||||
|
t.Fatalf("expected original expires unchanged, got %v", original.Alerts[0].Expires)
|
||||||
|
}
|
||||||
|
if original.Alerts[0].References[0].ID != "ref-current" {
|
||||||
|
t.Fatalf("expected original reference ID unchanged, got %q", original.Alerts[0].References[0].ID)
|
||||||
|
}
|
||||||
|
if original.Alerts[0].References[0].Sent == nil || !original.Alerts[0].References[0].Sent.Equal(testTime(8)) {
|
||||||
|
t.Fatalf("expected original reference sent unchanged, got %v", original.Alerts[0].References[0].Sent)
|
||||||
|
}
|
||||||
|
if len(original.Alerts) != 8 {
|
||||||
|
t.Fatalf("expected original alert slice unchanged, got %d entries", len(original.Alerts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
||||||
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
||||||
svc := NewService(repo)
|
svc := NewService(repo)
|
||||||
@@ -402,6 +535,67 @@ func TestServicePropagatesErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAlertRun() *model.WeatherAlertRun {
|
||||||
|
return testAlertRunWithAlerts([]model.WeatherAlert{
|
||||||
|
testAlert("current", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
|
||||||
|
testAlert("expired", "Update", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12)),
|
||||||
|
testAlert("future-effective", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(15)),
|
||||||
|
testAlert("canceled", " cancel ", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
|
||||||
|
testAlert("effective-at-boundary", "Alert", testTimePtr(9), testTimePtr(12), testTimePtr(12), testTimePtr(14)),
|
||||||
|
testAlert("missing-effective", "Alert", testTimePtr(9), nil, nil, testTimePtr(14)),
|
||||||
|
testAlert("missing-expires", "Alert", testTimePtr(9), testTimePtr(10), nil, nil),
|
||||||
|
testAlert("later-onset", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(13), testTimePtr(14)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAlertRunWithAlerts(alerts []model.WeatherAlert) *model.WeatherAlertRun {
|
||||||
|
latitude := 38.62
|
||||||
|
longitude := -90.2
|
||||||
|
return &model.WeatherAlertRun{
|
||||||
|
LocationID: "stl",
|
||||||
|
LocationName: "St. Louis",
|
||||||
|
AsOf: testTime(10),
|
||||||
|
Latitude: &latitude,
|
||||||
|
Longitude: &longitude,
|
||||||
|
Alerts: alerts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAlert(id string, messageType string, sent *time.Time, effective *time.Time, onset *time.Time, expires *time.Time) model.WeatherAlert {
|
||||||
|
refSent := testTime(8)
|
||||||
|
return model.WeatherAlert{
|
||||||
|
ID: id,
|
||||||
|
Event: "Thunderstorm Warning",
|
||||||
|
Headline: "Storm headline",
|
||||||
|
Severity: "Severe",
|
||||||
|
Urgency: "Immediate",
|
||||||
|
Certainty: "Likely",
|
||||||
|
Status: "Actual",
|
||||||
|
MessageType: messageType,
|
||||||
|
Category: "Met",
|
||||||
|
Response: "Shelter",
|
||||||
|
Description: "Storm description",
|
||||||
|
Instruction: "Take shelter",
|
||||||
|
Sent: sent,
|
||||||
|
Effective: effective,
|
||||||
|
Onset: onset,
|
||||||
|
Expires: expires,
|
||||||
|
AreaDescription: "St. Louis City",
|
||||||
|
SenderName: "NWS St. Louis",
|
||||||
|
References: []model.AlertReference{{
|
||||||
|
ID: "ref-" + id,
|
||||||
|
Identifier: "identifier-" + id,
|
||||||
|
Sender: "sender-" + id,
|
||||||
|
Sent: &refSent,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTimePtr(hour int) *time.Time {
|
||||||
|
value := testTime(hour)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
func testOutlookRun() *model.WeatherOutlookRun {
|
func testOutlookRun() *model.WeatherOutlookRun {
|
||||||
latitude := 38.62
|
latitude := 38.62
|
||||||
longitude := -90.2
|
longitude := -90.2
|
||||||
@@ -493,3 +687,18 @@ func assertDiscussionDays(t *testing.T, run *model.WeatherOutlookRun, want []int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertAlertIDs(t *testing.T, run *model.WeatherAlertRun, want []string) {
|
||||||
|
t.Helper()
|
||||||
|
if run == nil {
|
||||||
|
t.Fatal("expected alert run")
|
||||||
|
}
|
||||||
|
if len(run.Alerts) != len(want) {
|
||||||
|
t.Fatalf("expected alert IDs %v, got %+v", want, run.Alerts)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if run.Alerts[i].ID != want[i] {
|
||||||
|
t.Fatalf("expected alert IDs %v, got %+v", want, run.Alerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user