Implemented weather stories support
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful

This commit is contained in:
2026-05-30 07:06:29 -05:00
parent bde515d146
commit ecea856e8e
18 changed files with 760 additions and 4 deletions

View File

@@ -27,6 +27,8 @@ type fakeService struct {
forecast *model.WeatherForecastRun
narrativeForecast *model.WeatherForecastRun
discussion *model.WeatherForecastDiscussion
weatherStoryRun *model.WeatherStoryRun
weatherStory *model.WeatherStory
alerts *model.WeatherAlertRun
conditions *app.CurrentConditions
err error
@@ -48,6 +50,14 @@ func (s *fakeService) LatestForecastDiscussion(context.Context) (*model.WeatherF
return s.discussion, s.err
}
func (s *fakeService) LatestWeatherStoryRun(context.Context) (*model.WeatherStoryRun, error) {
return s.weatherStoryRun, s.err
}
func (s *fakeService) LatestWeatherStory(context.Context) (*model.WeatherStory, error) {
return s.weatherStory, s.err
}
func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
return s.alerts, s.err
}
@@ -1445,6 +1455,177 @@ func TestDefinitionsIncludeDiscussion(t *testing.T) {
_ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/long-term")
}
func TestDefinitionsIncludeWeatherStories(t *testing.T) {
_ = definitionForPath(t, Definitions(&fakeService{}), "/weatherstories")
_ = definitionForPath(t, Definitions(&fakeService{}), "/weatherstories/latest")
}
func TestWeatherStoriesNoDataReturnsNullEnvelopeData(t *testing.T) {
for _, path := range []string{"/weatherstories", "/weatherstories/latest"} {
t.Run(path, func(t *testing.T) {
h := newHandler(t, &fakeService{}, path)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, 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 TestWeatherStoriesJSONEnvelope(t *testing.T) {
h := newHandler(t, &fakeService{
weatherStoryRun: sampleWeatherStoryRun(),
}, "/weatherstories")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/weatherstories?tz=CDT", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload struct {
Data struct {
OfficeID string `json:"officeId"`
AsOf string `json:"asOf"`
Stories []struct {
Title string `json:"title"`
UpdatedAt string `json:"updatedAt"`
} `json:"stories"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if payload.Data.OfficeID != "LSX" {
t.Fatalf("expected officeId LSX, got %q", payload.Data.OfficeID)
}
if len(payload.Data.Stories) != 2 {
t.Fatalf("expected 2 stories, got %d", len(payload.Data.Stories))
}
if payload.Data.Stories[0].Title != "Rain Chances" {
t.Fatalf("unexpected first story title: %q", payload.Data.Stories[0].Title)
}
if !strings.Contains(payload.Data.AsOf, "-05:00") || !strings.Contains(payload.Data.Stories[0].UpdatedAt, "-05:00") {
t.Fatalf("expected CDT offset in weather story times, got asOf=%q updatedAt=%q", payload.Data.AsOf, payload.Data.Stories[0].UpdatedAt)
}
}
func TestWeatherStoriesLatestJSONEnvelope(t *testing.T) {
run := sampleWeatherStoryRun()
h := newHandler(t, &fakeService{
weatherStory: &run.Stories[1],
}, "/weatherstories/latest")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/weatherstories/latest?tz=Chicago", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload struct {
Data struct {
Title string `json:"title"`
UpdatedAt string `json:"updatedAt"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if payload.Data.Title != "More Rain" {
t.Fatalf("expected latest story title More Rain, got %q", payload.Data.Title)
}
if !strings.Contains(payload.Data.UpdatedAt, "-05:00") {
t.Fatalf("expected Chicago offset in updatedAt, got %q", payload.Data.UpdatedAt)
}
}
func TestWeatherStoriesFormatNegotiation(t *testing.T) {
hText := newHandler(t, &fakeService{
weatherStoryRun: sampleWeatherStoryRun(),
}, "/weatherstories")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/weatherstories?format=TEXT&units=US", nil)
hText.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for text request, got %d", w.Code)
}
if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
}
if !strings.Contains(w.Body.String(), "Weather Stories") {
t.Fatalf("expected rendered weather stories template, got %q", w.Body.String())
}
run := sampleWeatherStoryRun()
hXML := newHandler(t, &fakeService{
weatherStory: &run.Stories[0],
}, "/weatherstories/latest")
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/weatherstories/latest?format=XML", nil)
hXML.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for xml request, got %d", w.Code)
}
if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
}
}
func TestWeatherStoriesRejectInvalidQuery(t *testing.T) {
tests := []struct {
path string
query string
}{
{path: "/weatherstories", query: "/weatherstories?bogus=1"},
{path: "/weatherstories", query: "/weatherstories?precision=1"},
{path: "/weatherstories", query: "/weatherstories?tz=not-a-timezone"},
{path: "/weatherstories", query: "/weatherstories?tz=CDT&TZ=EST"},
{path: "/weatherstories/latest", query: "/weatherstories/latest?bogus=1"},
{path: "/weatherstories/latest", query: "/weatherstories/latest?precision=1"},
{path: "/weatherstories/latest", query: "/weatherstories/latest?tz=not-a-timezone"},
{path: "/weatherstories/latest", query: "/weatherstories/latest?tz=CDT&TZ=EST"},
}
for _, tt := range tests {
t.Run(tt.query, func(t *testing.T) {
run := sampleWeatherStoryRun()
h := newHandler(t, &fakeService{
weatherStoryRun: run,
weatherStory: &run.Stories[0],
}, tt.path)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, tt.query, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
})
}
}
func TestDiscussionSubresourcesNoDataReturnsNullEnvelopeData(t *testing.T) {
for _, path := range []string{
"/discussion/key-messages",
@@ -1784,6 +1965,8 @@ func testRenderers(t *testing.T) *render.Registry {
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
"forecast_hourly.txt.tmpl": "Forecast text",
"forecast_narrative.txt.tmpl": "Narrative Forecast",
"weatherstories.txt.tmpl": "Weather Stories",
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
"alerts_active.txt.tmpl": "Alerts text",
"conditions_current.txt.tmpl": "Conditions text",
} {
@@ -1811,6 +1994,39 @@ func wmoCodePtr(v model.WMOCode) *model.WMOCode {
return &out
}
func sampleWeatherStoryRun() *model.WeatherStoryRun {
return &model.WeatherStoryRun{
OfficeID: "LSX",
AsOf: time.Date(2026, 5, 30, 16, 0, 34, 0, time.UTC),
Stories: []model.WeatherStory{
{
OfficeID: "LSX",
StartTime: time.Date(2026, 5, 30, 13, 46, 0, 0, time.UTC),
EndTime: time.Date(2026, 5, 31, 16, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 5, 30, 14, 0, 34, 0, time.UTC),
Title: "Rain Chances",
Description: "Several chances for rain through Monday.",
AltText: "Forecast graphic.",
Priority: false,
Order: 1,
DownloadURL: "https://api.weather.gov/offices/LSX/weatherstories/download/story-1",
},
{
OfficeID: "LSX",
StartTime: time.Date(2026, 5, 30, 15, 46, 0, 0, time.UTC),
EndTime: time.Date(2026, 5, 31, 18, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 5, 30, 16, 0, 34, 0, time.UTC),
Title: "More Rain",
Description: "Showers remain possible.",
AltText: "Another forecast graphic.",
Priority: true,
Order: 2,
DownloadURL: "https://api.weather.gov/offices/LSX/weatherstories/download/story-2",
},
},
}
}
type forecastTimePayload struct {
Data struct {
IssuedAt time.Time `json:"issuedAt"`