Implemented the weather forecast discussion product from weatherfeeder v0.8.3
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful

This commit is contained in:
2026-03-28 17:36:35 -05:00
parent 291a9178c8
commit 0bccfc50d9
18 changed files with 651 additions and 3 deletions

View File

@@ -26,6 +26,7 @@ type fakeService struct {
observation *model.WeatherObservation
forecast *model.WeatherForecastRun
narrativeForecast *model.WeatherForecastRun
discussion *model.WeatherForecastDiscussion
alerts *model.WeatherAlertRun
conditions *app.CurrentConditions
err error
@@ -43,6 +44,10 @@ func (s *fakeService) LatestNarrativeForecast(context.Context) (*model.WeatherFo
return s.narrativeForecast, s.err
}
func (s *fakeService) LatestForecastDiscussion(context.Context) (*model.WeatherForecastDiscussion, error) {
return s.discussion, s.err
}
func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
return s.alerts, s.err
}
@@ -1257,6 +1262,186 @@ func TestAlertsRejectPrecisionQueryParameter(t *testing.T) {
}
}
func TestDiscussionNoDataReturnsNullEnvelopeData(t *testing.T) {
h := newHandler(t, &fakeService{}, "/discussion")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/discussion", 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 TestDiscussionJSONEnvelope(t *testing.T) {
issuedAt := time.Date(2026, 3, 29, 0, 24, 0, 0, time.UTC)
shortIssuedAt := issuedAt.Add(-5 * time.Minute)
h := newHandler(t, &fakeService{
discussion: &model.WeatherForecastDiscussion{
OfficeID: "LSX",
OfficeName: "National Weather Service Saint Louis MO",
Product: model.ForecastDiscussionProductAFD,
IssuedAt: issuedAt,
KeyMessages: []string{"msg one", "msg two"},
ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"},
LongTerm: &model.WeatherForecastDiscussionSection{Text: "Long term text"},
},
}, "/discussion")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/discussion", 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"`
Product string `json:"product"`
KeyMessages []string `json:"keyMessages"`
ShortTerm *struct {
Text string `json:"text"`
} `json:"shortTerm"`
} `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 payload.Data.Product != "afd" {
t.Fatalf("expected product afd, got %q", payload.Data.Product)
}
if len(payload.Data.KeyMessages) != 2 {
t.Fatalf("expected 2 key messages, got %d", len(payload.Data.KeyMessages))
}
if payload.Data.ShortTerm == nil || payload.Data.ShortTerm.Text != "Short term text" {
t.Fatalf("unexpected shortTerm payload: %+v", payload.Data.ShortTerm)
}
}
func TestDiscussionSupportsTextAndXMLFormats(t *testing.T) {
hText := newHandler(t, &fakeService{
discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()},
}, "/discussion")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/discussion?format=TEXT", 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(), "Forecast Discussion") {
t.Fatalf("expected rendered text template body, got %q", w.Body.String())
}
hXML := newHandler(t, &fakeService{
discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()},
}, "/discussion")
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/discussion?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 TestDiscussionTimezoneQuery(t *testing.T) {
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(30 * time.Minute)
shortIssuedAt := issuedAt.Add(-15 * time.Minute)
longIssuedAt := issuedAt.Add(15 * time.Minute)
for _, tc := range []struct {
name string
query string
want int
}{
{name: "abbreviation", query: "/discussion?tz=CDT", want: -5 * 60 * 60},
{name: "alias", query: "/discussion?tz=Chicago", want: -5 * 60 * 60},
{name: "offset", query: "/discussion?TZ=-5", want: -5 * 60 * 60},
} {
t.Run(tc.name, func(t *testing.T) {
h := newHandler(t, &fakeService{
discussion: &model.WeatherForecastDiscussion{
Product: model.ForecastDiscussionProductAFD,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
ShortTerm: &model.WeatherForecastDiscussionSection{IssuedAt: &shortIssuedAt},
LongTerm: &model.WeatherForecastDiscussionSection{IssuedAt: &longIssuedAt},
},
}, "/discussion")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, tc.query, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload discussionTimePayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode discussion payload: %v", err)
}
assertOffsetSeconds(t, payload.Data.IssuedAt, tc.want)
assertOffsetSeconds(t, *payload.Data.UpdatedAt, tc.want)
assertOffsetSeconds(t, *payload.Data.ShortTerm.IssuedAt, tc.want)
assertOffsetSeconds(t, *payload.Data.LongTerm.IssuedAt, tc.want)
})
}
}
func TestDiscussionRejectsInvalidQueryParameters(t *testing.T) {
tests := []string{
"/discussion?bogus=1",
"/discussion?precision=1",
"/discussion?tz=not-a-timezone",
"/discussion?tz=CDT&TZ=EST",
}
for _, rawURL := range tests {
h := newHandler(t, &fakeService{
discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()},
}, "/discussion")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, rawURL, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s: expected 400, got %d", rawURL, w.Code)
}
}
}
func TestDefinitionsIncludeDiscussion(t *testing.T) {
_ = definitionForPath(t, Definitions(&fakeService{}), "/discussion")
}
func newHandler(t *testing.T, svc Service, path string) http.Handler {
t.Helper()
@@ -1292,6 +1477,7 @@ func testRenderers(t *testing.T) *render.Registry {
tmplReg := templates.NewRegistry()
for name, body := range map[string]string{
"observations.txt.tmpl": "Observation text",
"discussion.txt.tmpl": "Forecast Discussion",
"forecast_hourly.txt.tmpl": "Forecast text",
"forecast_narrative.txt.tmpl": "Narrative Forecast",
"alerts_active.txt.tmpl": "Alerts text",
@@ -1327,6 +1513,19 @@ type forecastTimePayload struct {
} `json:"data"`
}
type discussionTimePayload struct {
Data struct {
IssuedAt time.Time `json:"issuedAt"`
UpdatedAt *time.Time `json:"updatedAt"`
ShortTerm *struct {
IssuedAt *time.Time `json:"issuedAt"`
} `json:"shortTerm"`
LongTerm *struct {
IssuedAt *time.Time `json:"issuedAt"`
} `json:"longTerm"`
} `json:"data"`
}
func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload {
t.Helper()