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:
@@ -7,6 +7,7 @@ A small HTTP API that serves a variety of weather-related endpoints.
|
|||||||
- `GET /observations`
|
- `GET /observations`
|
||||||
- `GET /conditions/current`
|
- `GET /conditions/current`
|
||||||
- `GET /alerts/active`
|
- `GET /alerts/active`
|
||||||
|
- `GET /discussion`
|
||||||
- `GET /forecast/hourly`
|
- `GET /forecast/hourly`
|
||||||
- `GET /forecast/hourly/today`
|
- `GET /forecast/hourly/today`
|
||||||
- `GET /forecast/hourly/tomorrow`
|
- `GET /forecast/hourly/tomorrow`
|
||||||
@@ -24,4 +25,7 @@ Shared weather query parameters:
|
|||||||
Forecast endpoint query parameters:
|
Forecast endpoint query parameters:
|
||||||
|
|
||||||
- `precision` (`0`-`2`)
|
- `precision` (`0`-`2`)
|
||||||
|
|
||||||
|
Forecast and discussion endpoint query parameters:
|
||||||
|
|
||||||
- `tz` / `TZ` (IANA timezone, US abbreviation, or UTC offset)
|
- `tz` / `TZ` (IANA timezone, US abbreviation, or UTC offset)
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -4,7 +4,7 @@ go 1.25.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0
|
gitea.maximumdirect.net/ejr/feedapi v0.1.0
|
||||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.7.2
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.8.3
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,7 +1,7 @@
|
|||||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0 h1:ZB5QWKD5DPFV3P7vyeJqXPMcSWN9qHkDUHw1LgN9hwY=
|
gitea.maximumdirect.net/ejr/feedapi v0.1.0 h1:ZB5QWKD5DPFV3P7vyeJqXPMcSWN9qHkDUHw1LgN9hwY=
|
||||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0/go.mod h1:3fIaFFx4ywt0TWbN8DIIBAHJn7ZQUm6PNcceqRgy3bw=
|
gitea.maximumdirect.net/ejr/feedapi v0.1.0/go.mod h1:3fIaFFx4ywt0TWbN8DIIBAHJn7ZQUm6PNcceqRgy3bw=
|
||||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.7.2 h1:GJdZ9x54HLTPidPGhD/dmq7efvGjBltK1ywCIRU1w6c=
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.8.3 h1:vH5p8zKiJ6D7JnbpA43iWKNEdgQg/VwKKaWlwF3AXXs=
|
||||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.7.2/go.mod h1:P7rP7XftJjBFzNEIkAiX+z2YqXOtnZjy3BaF6kX+sO4=
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.8.3/go.mod h1:YeHGpmJihwutT+2t8La8e2pPCusdxqiUNyZZOPqE33I=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
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{
|
defs := []endpoint.Definition{
|
||||||
observationDefinition(svc),
|
observationDefinition(svc),
|
||||||
alertsDefinition(svc),
|
alertsDefinition(svc),
|
||||||
|
discussionDefinition(svc),
|
||||||
conditionsDefinition(svc),
|
conditionsDefinition(svc),
|
||||||
}
|
}
|
||||||
defs = append(defs, forecastDefinitions(svc)...)
|
defs = append(defs, forecastDefinitions(svc)...)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type fakeService struct {
|
|||||||
observation *model.WeatherObservation
|
observation *model.WeatherObservation
|
||||||
forecast *model.WeatherForecastRun
|
forecast *model.WeatherForecastRun
|
||||||
narrativeForecast *model.WeatherForecastRun
|
narrativeForecast *model.WeatherForecastRun
|
||||||
|
discussion *model.WeatherForecastDiscussion
|
||||||
alerts *model.WeatherAlertRun
|
alerts *model.WeatherAlertRun
|
||||||
conditions *app.CurrentConditions
|
conditions *app.CurrentConditions
|
||||||
err error
|
err error
|
||||||
@@ -43,6 +44,10 @@ func (s *fakeService) LatestNarrativeForecast(context.Context) (*model.WeatherFo
|
|||||||
return s.narrativeForecast, s.err
|
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) {
|
func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
|
||||||
return s.alerts, s.err
|
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 {
|
func newHandler(t *testing.T, svc Service, path string) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -1292,6 +1477,7 @@ func testRenderers(t *testing.T) *render.Registry {
|
|||||||
tmplReg := templates.NewRegistry()
|
tmplReg := templates.NewRegistry()
|
||||||
for name, body := range map[string]string{
|
for name, body := range map[string]string{
|
||||||
"observations.txt.tmpl": "Observation text",
|
"observations.txt.tmpl": "Observation text",
|
||||||
|
"discussion.txt.tmpl": "Forecast Discussion",
|
||||||
"forecast_hourly.txt.tmpl": "Forecast text",
|
"forecast_hourly.txt.tmpl": "Forecast text",
|
||||||
"forecast_narrative.txt.tmpl": "Narrative Forecast",
|
"forecast_narrative.txt.tmpl": "Narrative Forecast",
|
||||||
"alerts_active.txt.tmpl": "Alerts text",
|
"alerts_active.txt.tmpl": "Alerts text",
|
||||||
@@ -1327,6 +1513,19 @@ type forecastTimePayload struct {
|
|||||||
} `json:"data"`
|
} `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 {
|
func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload {
|
||||||
t.Helper()
|
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)
|
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) {
|
func TestMetricCopyAndNilHandling(t *testing.T) {
|
||||||
obs := &model.WeatherObservation{
|
obs := &model.WeatherObservation{
|
||||||
TemperatureC: float64Ptr(20.6),
|
TemperatureC: float64Ptr(20.6),
|
||||||
@@ -202,6 +236,9 @@ func TestMetricCopyAndNilHandling(t *testing.T) {
|
|||||||
if AlertsPayload(nil, UnitsUS) != nil {
|
if AlertsPayload(nil, UnitsUS) != nil {
|
||||||
t.Fatalf("expected nil alerts input to return nil payload")
|
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 {
|
if CurrentConditionsPayload(nil, UnitsUS, 0) != nil {
|
||||||
t.Fatalf("expected nil current conditions input to return nil payload")
|
t.Fatalf("expected nil current conditions input to return nil payload")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ type precisionQueryRequest struct {
|
|||||||
Timezone *time.Location
|
Timezone *time.Location
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type timezoneQueryRequest struct {
|
||||||
|
Units presenter.Units
|
||||||
|
Timezone *time.Location
|
||||||
|
}
|
||||||
|
|
||||||
func bindQuery(r *http.Request) (queryRequest, error) {
|
func bindQuery(r *http.Request) (queryRequest, error) {
|
||||||
normalizeCommonQueryValue(r, "units")
|
normalizeCommonQueryValue(r, "units")
|
||||||
normalizeCommonQueryValue(r, "format")
|
normalizeCommonQueryValue(r, "format")
|
||||||
@@ -50,6 +55,36 @@ func bindForecastPrecisionQuery(r *http.Request) (precisionQueryRequest, error)
|
|||||||
return bindPrecisionQueryInternal(r, true)
|
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) {
|
func bindPrecisionQueryInternal(r *http.Request, allowTimezone bool) (precisionQueryRequest, error) {
|
||||||
normalizeCommonQueryValue(r, "units")
|
normalizeCommonQueryValue(r, "units")
|
||||||
normalizeCommonQueryValue(r, "format")
|
normalizeCommonQueryValue(r, "format")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Service interface {
|
|||||||
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
||||||
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
|
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
|
||||||
LatestNarrativeForecast(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)
|
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
|
||||||
CurrentConditions(ctx context.Context) (*app.CurrentConditions, error)
|
CurrentConditions(ctx context.Context) (*app.CurrentConditions, error)
|
||||||
}
|
}
|
||||||
|
|||||||
45
internal/adapters/outbound/postgres/discussion_mapper.go
Normal file
45
internal/adapters/outbound/postgres/discussion_mapper.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// discussion_mapper.go maps forecast discussion rows into weather model payloads.
|
||||||
|
// Layer: adapters/outbound/postgres discussion feature.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mapDiscussionParentRow(row discussionParentRow) model.WeatherForecastDiscussion {
|
||||||
|
return model.WeatherForecastDiscussion{
|
||||||
|
OfficeID: stringValue(row.OfficeID),
|
||||||
|
OfficeName: stringValue(row.OfficeName),
|
||||||
|
Product: model.ForecastDiscussionProduct(strings.TrimSpace(row.Product)),
|
||||||
|
IssuedAt: row.IssuedAt.UTC(),
|
||||||
|
UpdatedAt: timePtr(row.UpdatedAt),
|
||||||
|
ShortTerm: discussionSectionPtr(row.ShortTermQualifier, row.ShortTermIssuedAt, row.ShortTermText),
|
||||||
|
LongTerm: discussionSectionPtr(row.LongTermQualifier, row.LongTermIssuedAt, row.LongTermText),
|
||||||
|
KeyMessages: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapDiscussionKeyMessageRow(row discussionKeyMessageRow) string {
|
||||||
|
return stringValue(row.MessageText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func discussionSectionPtr(
|
||||||
|
qualifier sql.NullString,
|
||||||
|
issuedAt sql.NullTime,
|
||||||
|
text sql.NullString,
|
||||||
|
) *model.WeatherForecastDiscussionSection {
|
||||||
|
q := stringValue(qualifier)
|
||||||
|
t := stringValue(text)
|
||||||
|
i := timePtr(issuedAt)
|
||||||
|
if q == "" && t == "" && i == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &model.WeatherForecastDiscussionSection{
|
||||||
|
Qualifier: q,
|
||||||
|
IssuedAt: i,
|
||||||
|
Text: t,
|
||||||
|
}
|
||||||
|
}
|
||||||
31
internal/adapters/outbound/postgres/discussion_queries.go
Normal file
31
internal/adapters/outbound/postgres/discussion_queries.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// discussion_queries.go contains SQL text for forecast discussion reads.
|
||||||
|
// Layer: adapters/outbound/postgres discussion feature.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
const (
|
||||||
|
queryLatestForecastDiscussion = `
|
||||||
|
SELECT
|
||||||
|
event_id,
|
||||||
|
office_id,
|
||||||
|
office_name,
|
||||||
|
issued_at,
|
||||||
|
updated_at,
|
||||||
|
product,
|
||||||
|
short_term_qualifier,
|
||||||
|
short_term_issued_at,
|
||||||
|
short_term_text,
|
||||||
|
long_term_qualifier,
|
||||||
|
long_term_issued_at,
|
||||||
|
long_term_text
|
||||||
|
FROM forecast_discussions
|
||||||
|
ORDER BY issued_at DESC, event_emitted_at DESC
|
||||||
|
LIMIT 1`
|
||||||
|
|
||||||
|
queryForecastDiscussionKeyMessages = `
|
||||||
|
SELECT
|
||||||
|
message_index,
|
||||||
|
message_text
|
||||||
|
FROM forecast_discussion_key_messages
|
||||||
|
WHERE run_event_id = $1
|
||||||
|
ORDER BY message_index ASC`
|
||||||
|
)
|
||||||
74
internal/adapters/outbound/postgres/discussion_read.go
Normal file
74
internal/adapters/outbound/postgres/discussion_read.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// discussion_read.go executes forecast discussion queries.
|
||||||
|
// Layer: adapters/outbound/postgres discussion feature.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Repository) LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, fmt.Errorf("postgres repository is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
var row discussionParentRow
|
||||||
|
err := r.db.QueryRowContext(ctx, queryLatestForecastDiscussion).Scan(
|
||||||
|
&row.EventID,
|
||||||
|
&row.OfficeID,
|
||||||
|
&row.OfficeName,
|
||||||
|
&row.IssuedAt,
|
||||||
|
&row.UpdatedAt,
|
||||||
|
&row.Product,
|
||||||
|
&row.ShortTermQualifier,
|
||||||
|
&row.ShortTermIssuedAt,
|
||||||
|
&row.ShortTermText,
|
||||||
|
&row.LongTermQualifier,
|
||||||
|
&row.LongTermIssuedAt,
|
||||||
|
&row.LongTermText,
|
||||||
|
)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query latest forecast discussion: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
run := mapDiscussionParentRow(row)
|
||||||
|
|
||||||
|
keyMessages, err := r.loadForecastDiscussionKeyMessages(ctx, row.EventID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
run.KeyMessages = keyMessages
|
||||||
|
|
||||||
|
return &run, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) loadForecastDiscussionKeyMessages(ctx context.Context, eventID string) ([]string, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, queryForecastDiscussionKeyMessages, eventID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query forecast discussion key messages: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var row discussionKeyMessageRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&row.MessageIndex,
|
||||||
|
&row.MessageText,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan forecast discussion key message row: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, mapDiscussionKeyMessageRow(row))
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate forecast discussion key message rows: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
28
internal/adapters/outbound/postgres/discussion_rows.go
Normal file
28
internal/adapters/outbound/postgres/discussion_rows.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// discussion_rows.go defines row DTOs for forecast discussion reads.
|
||||||
|
// Layer: adapters/outbound/postgres discussion feature.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type discussionParentRow struct {
|
||||||
|
EventID string
|
||||||
|
OfficeID sql.NullString
|
||||||
|
OfficeName sql.NullString
|
||||||
|
IssuedAt time.Time
|
||||||
|
UpdatedAt sql.NullTime
|
||||||
|
Product string
|
||||||
|
ShortTermQualifier sql.NullString
|
||||||
|
ShortTermIssuedAt sql.NullTime
|
||||||
|
ShortTermText sql.NullString
|
||||||
|
LongTermQualifier sql.NullString
|
||||||
|
LongTermIssuedAt sql.NullTime
|
||||||
|
LongTermText sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
type discussionKeyMessageRow struct {
|
||||||
|
MessageIndex int
|
||||||
|
MessageText sql.NullString
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMapObservationParentRowNullables(t *testing.T) {
|
func TestMapObservationParentRowNullables(t *testing.T) {
|
||||||
@@ -84,6 +86,65 @@ func TestMapForecastPeriodRowNullables(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMapDiscussionParentRowNullables(t *testing.T) {
|
||||||
|
issuedAt := time.Date(2026, 3, 28, 19, 24, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||||
|
updatedAt := issuedAt.Add(time.Hour)
|
||||||
|
shortIssuedAt := issuedAt.Add(-5 * time.Minute)
|
||||||
|
|
||||||
|
discussion := mapDiscussionParentRow(discussionParentRow{
|
||||||
|
OfficeID: sql.NullString{String: "LSX", Valid: true},
|
||||||
|
OfficeName: sql.NullString{String: "National Weather Service Saint Louis MO", Valid: true},
|
||||||
|
IssuedAt: issuedAt,
|
||||||
|
UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true},
|
||||||
|
Product: "afd",
|
||||||
|
ShortTermQualifier: sql.NullString{String: "(Tonight)", Valid: true},
|
||||||
|
ShortTermIssuedAt: sql.NullTime{Time: shortIssuedAt, Valid: true},
|
||||||
|
ShortTermText: sql.NullString{String: "Short term text", Valid: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
if discussion.OfficeID != "LSX" {
|
||||||
|
t.Fatalf("expected office id LSX, got %q", discussion.OfficeID)
|
||||||
|
}
|
||||||
|
if discussion.Product != model.ForecastDiscussionProductAFD {
|
||||||
|
t.Fatalf("expected product afd, got %q", discussion.Product)
|
||||||
|
}
|
||||||
|
if discussion.UpdatedAt == nil || discussion.UpdatedAt.Location().String() != "UTC" {
|
||||||
|
t.Fatalf("expected updatedAt to be UTC-normalized, got %v", discussion.UpdatedAt)
|
||||||
|
}
|
||||||
|
if discussion.ShortTerm == nil {
|
||||||
|
t.Fatalf("expected shortTerm section to be populated")
|
||||||
|
}
|
||||||
|
if discussion.ShortTerm.Qualifier != "(Tonight)" || discussion.ShortTerm.Text != "Short term text" {
|
||||||
|
t.Fatalf("unexpected shortTerm section: %+v", discussion.ShortTerm)
|
||||||
|
}
|
||||||
|
if discussion.LongTerm != nil {
|
||||||
|
t.Fatalf("expected longTerm nil, got %+v", discussion.LongTerm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapDiscussionSectionNilWhenAllFieldsMissing(t *testing.T) {
|
||||||
|
got := discussionSectionPtr(sql.NullString{}, sql.NullTime{}, sql.NullString{})
|
||||||
|
if got != nil {
|
||||||
|
t.Fatalf("expected nil discussion section, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapDiscussionKeyMessagesPreserveOrder(t *testing.T) {
|
||||||
|
rows := []discussionKeyMessageRow{
|
||||||
|
{MessageIndex: 0, MessageText: sql.NullString{String: "first", Valid: true}},
|
||||||
|
{MessageIndex: 1, MessageText: sql.NullString{String: "second", Valid: true}},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]string, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
got = append(got, mapDiscussionKeyMessageRow(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got) != 2 || got[0] != "first" || got[1] != "second" {
|
||||||
|
t.Fatalf("unexpected key message order: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAttachAlertReferencesPreservesOrder(t *testing.T) {
|
func TestAttachAlertReferencesPreservesOrder(t *testing.T) {
|
||||||
sent1 := time.Date(2026, 3, 20, 1, 0, 0, 0, time.UTC)
|
sent1 := time.Date(2026, 3, 20, 1, 0, 0, 0, time.UTC)
|
||||||
sent2 := sent1.Add(10 * time.Minute)
|
sent2 := sent1.Add(10 * time.Minute)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Repository interface {
|
|||||||
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
||||||
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
|
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
|
||||||
LatestNarrativeForecast(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)
|
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
|
||||||
CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, error)
|
CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, error)
|
||||||
}
|
}
|
||||||
@@ -38,6 +39,10 @@ func (s *Service) LatestNarrativeForecast(ctx context.Context) (*model.WeatherFo
|
|||||||
return s.repo.LatestNarrativeForecast(ctx)
|
return s.repo.LatestNarrativeForecast(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error) {
|
||||||
|
return s.repo.LatestForecastDiscussion(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) {
|
func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) {
|
||||||
return s.repo.LatestAlertRun(ctx)
|
return s.repo.LatestAlertRun(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type fakeRepository struct {
|
|||||||
observation *model.WeatherObservation
|
observation *model.WeatherObservation
|
||||||
forecast *model.WeatherForecastRun
|
forecast *model.WeatherForecastRun
|
||||||
narrative *model.WeatherForecastRun
|
narrative *model.WeatherForecastRun
|
||||||
|
discussion *model.WeatherForecastDiscussion
|
||||||
alerts *model.WeatherAlertRun
|
alerts *model.WeatherAlertRun
|
||||||
conditions *CurrentConditions
|
conditions *CurrentConditions
|
||||||
err error
|
err error
|
||||||
@@ -33,6 +34,10 @@ func (r *fakeRepository) LatestNarrativeForecast(context.Context) (*model.Weathe
|
|||||||
return r.narrative, r.err
|
return r.narrative, r.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepository) LatestForecastDiscussion(context.Context) (*model.WeatherForecastDiscussion, error) {
|
||||||
|
return r.discussion, r.err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
|
func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
|
||||||
return r.alerts, r.err
|
return r.alerts, r.err
|
||||||
}
|
}
|
||||||
@@ -94,6 +99,19 @@ func TestServiceDelegatesAlerts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceDelegatesForecastDiscussion(t *testing.T) {
|
||||||
|
repo := &fakeRepository{discussion: &model.WeatherForecastDiscussion{OfficeID: "LSX"}}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestForecastDiscussion(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if run == nil || run.OfficeID != "LSX" {
|
||||||
|
t.Fatalf("unexpected forecast discussion: %+v", run)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceUsesDefaultCurrentConditionsWindow(t *testing.T) {
|
func TestServiceUsesDefaultCurrentConditionsWindow(t *testing.T) {
|
||||||
repo := &fakeRepository{conditions: &CurrentConditions{ConditionCode: model.WMOUnknown}}
|
repo := &fakeRepository{conditions: &CurrentConditions{ConditionCode: model.WMOUnknown}}
|
||||||
svc := NewService(repo)
|
svc := NewService(repo)
|
||||||
|
|||||||
43
templates/discussion.txt.tmpl
Normal file
43
templates/discussion.txt.tmpl
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{{- if .Data -}}
|
||||||
|
Forecast Discussion
|
||||||
|
Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}}
|
||||||
|
Office Name: {{if .Data.OfficeName}}{{.Data.OfficeName}}{{else}}n/a{{end}}
|
||||||
|
Product: {{.Data.Product}}
|
||||||
|
Issued At: {{.Data.IssuedAt}}
|
||||||
|
{{- if .Data.UpdatedAt}}
|
||||||
|
Updated At: {{.Data.UpdatedAt}}
|
||||||
|
{{- end}}
|
||||||
|
Key Messages: {{len .Data.KeyMessages}}
|
||||||
|
{{- range $i, $message := .Data.KeyMessages}}
|
||||||
|
|
||||||
|
[{{$i}}] {{$message}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.ShortTerm}}
|
||||||
|
|
||||||
|
Short Term
|
||||||
|
{{- if .Data.ShortTerm.Qualifier}}
|
||||||
|
Qualifier: {{.Data.ShortTerm.Qualifier}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.ShortTerm.IssuedAt}}
|
||||||
|
Issued At: {{.Data.ShortTerm.IssuedAt}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.ShortTerm.Text}}
|
||||||
|
Text: {{.Data.ShortTerm.Text}}
|
||||||
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.LongTerm}}
|
||||||
|
|
||||||
|
Long Term
|
||||||
|
{{- if .Data.LongTerm.Qualifier}}
|
||||||
|
Qualifier: {{.Data.LongTerm.Qualifier}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.LongTerm.IssuedAt}}
|
||||||
|
Issued At: {{.Data.LongTerm.IssuedAt}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.LongTerm.Text}}
|
||||||
|
Text: {{.Data.LongTerm.Text}}
|
||||||
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
{{- else -}}
|
||||||
|
No forecast discussion data available.
|
||||||
|
{{- end}}
|
||||||
Reference in New Issue
Block a user