Added new, focused forecast discussion endpoints for key messages, short term, and long term discussions
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
This commit is contained in:
@@ -1440,6 +1440,307 @@ func TestDiscussionRejectsInvalidQueryParameters(t *testing.T) {
|
||||
|
||||
func TestDefinitionsIncludeDiscussion(t *testing.T) {
|
||||
_ = definitionForPath(t, Definitions(&fakeService{}), "/discussion")
|
||||
_ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/key-messages")
|
||||
_ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/short-term")
|
||||
_ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/long-term")
|
||||
}
|
||||
|
||||
func TestDiscussionSubresourcesNoDataReturnsNullEnvelopeData(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/discussion/key-messages",
|
||||
"/discussion/short-term",
|
||||
"/discussion/long-term",
|
||||
} {
|
||||
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 TestDiscussionSubresourcesJSONEnvelopeFocusedFields(t *testing.T) {
|
||||
issuedAt := time.Date(2026, 3, 29, 0, 24, 0, 0, time.UTC)
|
||||
shortIssuedAt := issuedAt.Add(-5 * time.Minute)
|
||||
longIssuedAt := issuedAt.Add(10 * time.Minute)
|
||||
run := &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{Qualifier: "(Tomorrow)", IssuedAt: &longIssuedAt, Text: "Long term text"},
|
||||
}
|
||||
|
||||
t.Run("key messages", func(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{discussion: run}, "/discussion/key-messages")
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/discussion/key-messages", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
OfficeID string `json:"officeId"`
|
||||
KeyMessages []string `json:"keyMessages"`
|
||||
ShortTerm any `json:"shortTerm"`
|
||||
LongTerm any `json:"longTerm"`
|
||||
} `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.KeyMessages) != 2 {
|
||||
t.Fatalf("expected 2 key messages, got %d", len(payload.Data.KeyMessages))
|
||||
}
|
||||
if payload.Data.ShortTerm != nil || payload.Data.LongTerm != nil {
|
||||
t.Fatalf("unexpected extra fields in key messages payload")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("short term", func(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{discussion: run}, "/discussion/short-term")
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/discussion/short-term", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
ShortTerm *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"shortTerm"`
|
||||
KeyMessages any `json:"keyMessages"`
|
||||
LongTerm any `json:"longTerm"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode envelope: %v", err)
|
||||
}
|
||||
if payload.Data.ShortTerm == nil || payload.Data.ShortTerm.Text != "Short term text" {
|
||||
t.Fatalf("unexpected shortTerm payload: %+v", payload.Data.ShortTerm)
|
||||
}
|
||||
if payload.Data.KeyMessages != nil || payload.Data.LongTerm != nil {
|
||||
t.Fatalf("unexpected extra fields in short term payload")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("long term", func(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{discussion: run}, "/discussion/long-term")
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/discussion/long-term", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
LongTerm *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"longTerm"`
|
||||
KeyMessages any `json:"keyMessages"`
|
||||
ShortTerm any `json:"shortTerm"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode envelope: %v", err)
|
||||
}
|
||||
if payload.Data.LongTerm == nil || payload.Data.LongTerm.Text != "Long term text" {
|
||||
t.Fatalf("unexpected longTerm payload: %+v", payload.Data.LongTerm)
|
||||
}
|
||||
if payload.Data.KeyMessages != nil || payload.Data.ShortTerm != nil {
|
||||
t.Fatalf("unexpected extra fields in long term payload")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiscussionSubresourcesSupportTextAndXMLFormats(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
textContains string
|
||||
}{
|
||||
{path: "/discussion/key-messages", textContains: "Forecast Discussion Key Messages"},
|
||||
{path: "/discussion/short-term", textContains: "Forecast Discussion Short Term"},
|
||||
{path: "/discussion/long-term", textContains: "Forecast Discussion Long Term"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
hText := newHandler(t, &fakeService{
|
||||
discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()},
|
||||
}, tt.path)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, tt.path+"?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(), tt.textContains) {
|
||||
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()},
|
||||
}, tt.path)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, tt.path+"?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 TestDiscussionSubresourcesTimezoneQuery(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)
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
query string
|
||||
want int
|
||||
check func(*testing.T, discussionFocusedTimePayload, int)
|
||||
}{
|
||||
{
|
||||
path: "/discussion/key-messages",
|
||||
query: "/discussion/key-messages?tz=CDT",
|
||||
want: -5 * 60 * 60,
|
||||
check: func(t *testing.T, payload discussionFocusedTimePayload, want int) {
|
||||
assertOffsetSeconds(t, payload.Data.IssuedAt, want)
|
||||
assertOffsetSeconds(t, *payload.Data.UpdatedAt, want)
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/discussion/short-term",
|
||||
query: "/discussion/short-term?tz=Chicago",
|
||||
want: -5 * 60 * 60,
|
||||
check: func(t *testing.T, payload discussionFocusedTimePayload, want int) {
|
||||
assertOffsetSeconds(t, payload.Data.IssuedAt, want)
|
||||
assertOffsetSeconds(t, *payload.Data.UpdatedAt, want)
|
||||
assertOffsetSeconds(t, *payload.Data.ShortTerm.IssuedAt, want)
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/discussion/long-term",
|
||||
query: "/discussion/long-term?TZ=-5",
|
||||
want: -5 * 60 * 60,
|
||||
check: func(t *testing.T, payload discussionFocusedTimePayload, want int) {
|
||||
assertOffsetSeconds(t, payload.Data.IssuedAt, want)
|
||||
assertOffsetSeconds(t, *payload.Data.UpdatedAt, want)
|
||||
assertOffsetSeconds(t, *payload.Data.LongTerm.IssuedAt, want)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, 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},
|
||||
},
|
||||
}, tt.path)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, tt.query, nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var payload discussionFocusedTimePayload
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode discussion payload: %v", err)
|
||||
}
|
||||
tt.check(t, payload, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscussionSubresourcesRejectInvalidQueryParameters(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
urls []string
|
||||
}{
|
||||
{
|
||||
path: "/discussion/key-messages",
|
||||
urls: []string{
|
||||
"/discussion/key-messages?bogus=1",
|
||||
"/discussion/key-messages?precision=1",
|
||||
"/discussion/key-messages?tz=not-a-timezone",
|
||||
"/discussion/key-messages?tz=CDT&TZ=EST",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/discussion/short-term",
|
||||
urls: []string{
|
||||
"/discussion/short-term?bogus=1",
|
||||
"/discussion/short-term?precision=1",
|
||||
"/discussion/short-term?tz=not-a-timezone",
|
||||
"/discussion/short-term?tz=CDT&TZ=EST",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/discussion/long-term",
|
||||
urls: []string{
|
||||
"/discussion/long-term?bogus=1",
|
||||
"/discussion/long-term?precision=1",
|
||||
"/discussion/long-term?tz=not-a-timezone",
|
||||
"/discussion/long-term?tz=CDT&TZ=EST",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
for _, rawURL := range tt.urls {
|
||||
h := newHandler(t, &fakeService{
|
||||
discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()},
|
||||
}, tt.path)
|
||||
|
||||
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 newHandler(t *testing.T, svc Service, path string) http.Handler {
|
||||
@@ -1476,12 +1777,15 @@ 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",
|
||||
"conditions_current.txt.tmpl": "Conditions text",
|
||||
"observations.txt.tmpl": "Observation text",
|
||||
"discussion.txt.tmpl": "Forecast Discussion",
|
||||
"discussion_key_messages.txt.tmpl": "Forecast Discussion Key Messages",
|
||||
"discussion_short_term.txt.tmpl": "Forecast Discussion Short Term",
|
||||
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
|
||||
"forecast_hourly.txt.tmpl": "Forecast text",
|
||||
"forecast_narrative.txt.tmpl": "Narrative Forecast",
|
||||
"alerts_active.txt.tmpl": "Alerts text",
|
||||
"conditions_current.txt.tmpl": "Conditions text",
|
||||
} {
|
||||
tmpl, err := template.New(name).Parse(body)
|
||||
if err != nil {
|
||||
@@ -1526,6 +1830,19 @@ type discussionTimePayload struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type discussionFocusedTimePayload 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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user