Implemented the weather forecast discussion product from weatherfeeder v0.8.3
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:
28
internal/adapters/inbound/httpapi/discussion_endpoint.go
Normal file
28
internal/adapters/inbound/httpapi/discussion_endpoint.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// discussion_endpoint.go defines the /discussion endpoint behavior.
|
||||
// Layer: adapters/inbound/httpapi discussion route.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
|
||||
"gitea.maximumdirect.net/ejr/feedapi/render"
|
||||
"gitea.maximumdirect.net/ejr/feedapi/response"
|
||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
|
||||
)
|
||||
|
||||
func discussionDefinition(svc Service) endpoint.Definition {
|
||||
return endpoint.GET(
|
||||
"/discussion",
|
||||
bindTimezoneQuery,
|
||||
func(ctx context.Context, req timezoneQueryRequest) (any, error) {
|
||||
run, err := svc.LatestForecastDiscussion(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Envelope{Data: presenter.DiscussionPayload(run, req.Units, req.Timezone)}, nil
|
||||
},
|
||||
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
|
||||
endpoint.WithTemplate("discussion.txt.tmpl"),
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ func Definitions(svc Service) []endpoint.Definition {
|
||||
defs := []endpoint.Definition{
|
||||
observationDefinition(svc),
|
||||
alertsDefinition(svc),
|
||||
discussionDefinition(svc),
|
||||
conditionsDefinition(svc),
|
||||
}
|
||||
defs = append(defs, forecastDefinitions(svc)...)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
38
internal/adapters/inbound/httpapi/presenter/discussion.go
Normal file
38
internal/adapters/inbound/httpapi/presenter/discussion.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// discussion.go presents forecast discussion payloads.
|
||||
// Layer: adapters/inbound/httpapi/presenter discussion payload mapping.
|
||||
package presenter
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
)
|
||||
|
||||
func DiscussionPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.Location) any {
|
||||
if run == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := model.WeatherForecastDiscussion{
|
||||
OfficeID: run.OfficeID,
|
||||
OfficeName: run.OfficeName,
|
||||
Product: run.Product,
|
||||
IssuedAt: inLocationTime(run.IssuedAt, tz),
|
||||
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
|
||||
KeyMessages: append([]string(nil), run.KeyMessages...),
|
||||
ShortTerm: copyDiscussionSection(run.ShortTerm, tz),
|
||||
LongTerm: copyDiscussionSection(run.LongTerm, tz),
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
func copyDiscussionSection(in *model.WeatherForecastDiscussionSection, tz *time.Location) *model.WeatherForecastDiscussionSection {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.WeatherForecastDiscussionSection{
|
||||
Qualifier: in.Qualifier,
|
||||
IssuedAt: inLocationTimePtr(in.IssuedAt, tz),
|
||||
Text: in.Text,
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,40 @@ func TestForecastPayloadNoTimezonePreservesUTCAndCopySemantics(t *testing.T) {
|
||||
assertOffsetSeconds(t, metric.Periods[0].StartTime, 0)
|
||||
}
|
||||
|
||||
func TestDiscussionPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
loc := time.FixedZone("UTC-05:00", -5*60*60)
|
||||
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
|
||||
updatedAt := issuedAt.Add(30 * time.Minute)
|
||||
shortIssuedAt := issuedAt.Add(-15 * time.Minute)
|
||||
run := &model.WeatherForecastDiscussion{
|
||||
OfficeID: "LSX",
|
||||
Product: model.ForecastDiscussionProductAFD,
|
||||
IssuedAt: issuedAt,
|
||||
UpdatedAt: &updatedAt,
|
||||
KeyMessages: []string{"msg one"},
|
||||
ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"},
|
||||
}
|
||||
|
||||
payload := DiscussionPayload(run, UnitsMetric, loc)
|
||||
discussion, ok := payload.(*model.WeatherForecastDiscussion)
|
||||
if !ok {
|
||||
t.Fatalf("expected *model.WeatherForecastDiscussion payload, got %T", payload)
|
||||
}
|
||||
if discussion == run {
|
||||
t.Fatalf("expected discussion payload to be copied")
|
||||
}
|
||||
if discussion.ShortTerm == run.ShortTerm {
|
||||
t.Fatalf("expected shortTerm pointer to be copied")
|
||||
}
|
||||
assertOffsetSeconds(t, discussion.IssuedAt, -5*60*60)
|
||||
assertOffsetSeconds(t, *discussion.UpdatedAt, -5*60*60)
|
||||
assertOffsetSeconds(t, *discussion.ShortTerm.IssuedAt, -5*60*60)
|
||||
if discussion.KeyMessages[0] != "msg one" {
|
||||
t.Fatalf("expected key message preserved, got %#v", discussion.KeyMessages)
|
||||
}
|
||||
assertOffsetSeconds(t, run.IssuedAt, 0)
|
||||
}
|
||||
|
||||
func TestMetricCopyAndNilHandling(t *testing.T) {
|
||||
obs := &model.WeatherObservation{
|
||||
TemperatureC: float64Ptr(20.6),
|
||||
@@ -202,6 +236,9 @@ func TestMetricCopyAndNilHandling(t *testing.T) {
|
||||
if AlertsPayload(nil, UnitsUS) != nil {
|
||||
t.Fatalf("expected nil alerts input to return nil payload")
|
||||
}
|
||||
if DiscussionPayload(nil, UnitsUS, nil) != nil {
|
||||
t.Fatalf("expected nil discussion input to return nil payload")
|
||||
}
|
||||
if CurrentConditionsPayload(nil, UnitsUS, 0) != nil {
|
||||
t.Fatalf("expected nil current conditions input to return nil payload")
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ type precisionQueryRequest struct {
|
||||
Timezone *time.Location
|
||||
}
|
||||
|
||||
type timezoneQueryRequest struct {
|
||||
Units presenter.Units
|
||||
Timezone *time.Location
|
||||
}
|
||||
|
||||
func bindQuery(r *http.Request) (queryRequest, error) {
|
||||
normalizeCommonQueryValue(r, "units")
|
||||
normalizeCommonQueryValue(r, "format")
|
||||
@@ -50,6 +55,36 @@ func bindForecastPrecisionQuery(r *http.Request) (precisionQueryRequest, error)
|
||||
return bindPrecisionQueryInternal(r, true)
|
||||
}
|
||||
|
||||
func bindTimezoneQuery(r *http.Request) (timezoneQueryRequest, error) {
|
||||
normalizeCommonQueryValue(r, "units")
|
||||
normalizeCommonQueryValue(r, "format")
|
||||
|
||||
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
|
||||
AllowUnits: true,
|
||||
AllowFormat: true,
|
||||
DefaultUnits: string(presenter.UnitsMetric),
|
||||
RejectUnknown: true,
|
||||
}, "tz", "TZ")
|
||||
if err != nil {
|
||||
return timezoneQueryRequest{}, err
|
||||
}
|
||||
|
||||
units := presenter.Units(strings.ToLower(strings.TrimSpace(common.Units)))
|
||||
if units == "" {
|
||||
units = presenter.UnitsMetric
|
||||
}
|
||||
|
||||
tz, err := parseTimezoneQuery(r)
|
||||
if err != nil {
|
||||
return timezoneQueryRequest{}, err
|
||||
}
|
||||
|
||||
return timezoneQueryRequest{
|
||||
Units: units,
|
||||
Timezone: tz,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func bindPrecisionQueryInternal(r *http.Request, allowTimezone bool) (precisionQueryRequest, error) {
|
||||
normalizeCommonQueryValue(r, "units")
|
||||
normalizeCommonQueryValue(r, "format")
|
||||
|
||||
@@ -14,6 +14,7 @@ type Service interface {
|
||||
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
||||
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
|
||||
LatestNarrativeForecast(ctx context.Context) (*model.WeatherForecastRun, error)
|
||||
LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error)
|
||||
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
|
||||
CurrentConditions(ctx context.Context) (*app.CurrentConditions, error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user