Implemented weather stories support
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:
@@ -11,6 +11,8 @@ A small HTTP API that serves a variety of weather-related endpoints.
|
|||||||
- `GET /discussion/key-messages`
|
- `GET /discussion/key-messages`
|
||||||
- `GET /discussion/short-term`
|
- `GET /discussion/short-term`
|
||||||
- `GET /discussion/long-term`
|
- `GET /discussion/long-term`
|
||||||
|
- `GET /weatherstories`
|
||||||
|
- `GET /weatherstories/latest`
|
||||||
- `GET /forecast/hourly`
|
- `GET /forecast/hourly`
|
||||||
- `GET /forecast/hourly/today`
|
- `GET /forecast/hourly/today`
|
||||||
- `GET /forecast/hourly/tomorrow`
|
- `GET /forecast/hourly/tomorrow`
|
||||||
@@ -29,6 +31,6 @@ Forecast endpoint query parameters:
|
|||||||
|
|
||||||
- `precision` (`0`-`2`)
|
- `precision` (`0`-`2`)
|
||||||
|
|
||||||
Forecast and discussion endpoint query parameters:
|
Forecast, discussion, and weather stories endpoint query parameters:
|
||||||
|
|
||||||
- `tz` / `TZ` (IANA timezone, US abbreviation, or UTC offset)
|
- `tz` / `TZ` (IANA timezone, US abbreviation, or UTC offset)
|
||||||
|
|||||||
87
docs/api.md
87
docs/api.md
@@ -63,6 +63,7 @@ Supported where documented:
|
|||||||
- if both are provided, they must match exactly or the request fails
|
- if both are provided, they must match exactly or the request fails
|
||||||
|
|
||||||
Timezone affects datetime rendering and day-slice filtering for `/today` and `/tomorrow` forecast routes.
|
Timezone affects datetime rendering and day-slice filtering for `/today` and `/tomorrow` forecast routes.
|
||||||
|
It also affects weather story and discussion datetime rendering.
|
||||||
|
|
||||||
### Query validation
|
### Query validation
|
||||||
|
|
||||||
@@ -223,6 +224,42 @@ Discussion section object fields:
|
|||||||
- `narrative` (string, optional)
|
- `narrative` (string, optional)
|
||||||
- `issuedAt` (RFC3339 datetime, optional)
|
- `issuedAt` (RFC3339 datetime, optional)
|
||||||
|
|
||||||
|
## Weather Stories endpoints
|
||||||
|
|
||||||
|
- `GET /weatherstories`
|
||||||
|
- `GET /weatherstories/latest`
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
- `units`: `metric` | `us` (accepted; does not materially alter weather story payloads)
|
||||||
|
- `format`: `json` | `xml` | `text`
|
||||||
|
- `tz` or `TZ`: timezone selector
|
||||||
|
|
||||||
|
Response behavior:
|
||||||
|
|
||||||
|
- `/weatherstories` returns the latest stored weather story run and its ordered stories.
|
||||||
|
- `/weatherstories/latest` returns the single story with the greatest `updatedAt`.
|
||||||
|
- When no weather story data exists, `data` is `null`.
|
||||||
|
|
||||||
|
`/weatherstories` response `data` fields:
|
||||||
|
|
||||||
|
- `officeId` (string, optional)
|
||||||
|
- `asOf` (RFC3339 datetime, required)
|
||||||
|
- `stories` (array, required)
|
||||||
|
|
||||||
|
Weather story fields:
|
||||||
|
|
||||||
|
- `officeId` (string, optional)
|
||||||
|
- `startTime` (RFC3339 datetime, required)
|
||||||
|
- `endTime` (RFC3339 datetime, required)
|
||||||
|
- `updatedAt` (RFC3339 datetime, required)
|
||||||
|
- `title` (string, optional)
|
||||||
|
- `description` (string, optional)
|
||||||
|
- `altText` (string, optional)
|
||||||
|
- `priority` (boolean, required)
|
||||||
|
- `order` (integer, required)
|
||||||
|
- `downloadUrl` (string, optional)
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
### Observation (JSON, metric)
|
### Observation (JSON, metric)
|
||||||
@@ -338,6 +375,56 @@ GET /discussion/key-messages?format=json&tz=Chicago
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Weather stories (JSON)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /weatherstories?format=json&tz=America/Chicago
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"officeId": "LSX",
|
||||||
|
"asOf": "2026-05-30T04:00:34-05:00",
|
||||||
|
"stories": [
|
||||||
|
{
|
||||||
|
"officeId": "LSX",
|
||||||
|
"startTime": "2026-05-30T03:46:00-05:00",
|
||||||
|
"endTime": "2026-05-31T06:00:00-05:00",
|
||||||
|
"updatedAt": "2026-05-30T04:00:34-05:00",
|
||||||
|
"title": "Several Chances for Rain Through Monday",
|
||||||
|
"description": "Scattered showers and thunderstorms remain possible.",
|
||||||
|
"altText": "This slide shows the forecast for today through Tuesday.",
|
||||||
|
"priority": false,
|
||||||
|
"order": 1,
|
||||||
|
"downloadUrl": "https://api.weather.gov/offices/LSX/weatherstories/download/3228e499-2aae-45a8-9ff9-1c060311026f"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Latest weather story (JSON)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /weatherstories/latest?format=json&tz=CDT
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"officeId": "LSX",
|
||||||
|
"startTime": "2026-05-30T03:46:00-05:00",
|
||||||
|
"endTime": "2026-05-31T06:00:00-05:00",
|
||||||
|
"updatedAt": "2026-05-30T04:00:34-05:00",
|
||||||
|
"title": "Several Chances for Rain Through Monday",
|
||||||
|
"description": "Scattered showers and thunderstorms remain possible.",
|
||||||
|
"priority": false,
|
||||||
|
"order": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Invalid timezone error example
|
### Invalid timezone error example
|
||||||
|
|
||||||
```http
|
```http
|
||||||
|
|||||||
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.8.3
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.10.0
|
||||||
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.8.3 h1:vH5p8zKiJ6D7JnbpA43iWKNEdgQg/VwKKaWlwF3AXXs=
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.10.0 h1:C2LQehq1A74Ufem8szL0kKxLRR7+h4kHcbyPefwRXWI=
|
||||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.8.3/go.mod h1:YeHGpmJihwutT+2t8La8e2pPCusdxqiUNyZZOPqE33I=
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.10.0/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw=
|
||||||
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=
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ func Definitions(svc Service) []endpoint.Definition {
|
|||||||
alertsDefinition(svc),
|
alertsDefinition(svc),
|
||||||
conditionsDefinition(svc),
|
conditionsDefinition(svc),
|
||||||
}
|
}
|
||||||
|
defs = append(defs, weatherStoriesDefinitions(svc)...)
|
||||||
defs = append(defs, discussionDefinitions(svc)...)
|
defs = append(defs, discussionDefinitions(svc)...)
|
||||||
defs = append(defs, forecastDefinitions(svc)...)
|
defs = append(defs, forecastDefinitions(svc)...)
|
||||||
return defs
|
return defs
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type fakeService struct {
|
|||||||
forecast *model.WeatherForecastRun
|
forecast *model.WeatherForecastRun
|
||||||
narrativeForecast *model.WeatherForecastRun
|
narrativeForecast *model.WeatherForecastRun
|
||||||
discussion *model.WeatherForecastDiscussion
|
discussion *model.WeatherForecastDiscussion
|
||||||
|
weatherStoryRun *model.WeatherStoryRun
|
||||||
|
weatherStory *model.WeatherStory
|
||||||
alerts *model.WeatherAlertRun
|
alerts *model.WeatherAlertRun
|
||||||
conditions *app.CurrentConditions
|
conditions *app.CurrentConditions
|
||||||
err error
|
err error
|
||||||
@@ -48,6 +50,14 @@ func (s *fakeService) LatestForecastDiscussion(context.Context) (*model.WeatherF
|
|||||||
return s.discussion, s.err
|
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) {
|
func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
|
||||||
return s.alerts, s.err
|
return s.alerts, s.err
|
||||||
}
|
}
|
||||||
@@ -1445,6 +1455,177 @@ func TestDefinitionsIncludeDiscussion(t *testing.T) {
|
|||||||
_ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/long-term")
|
_ = 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) {
|
func TestDiscussionSubresourcesNoDataReturnsNullEnvelopeData(t *testing.T) {
|
||||||
for _, path := range []string{
|
for _, path := range []string{
|
||||||
"/discussion/key-messages",
|
"/discussion/key-messages",
|
||||||
@@ -1784,6 +1965,8 @@ func testRenderers(t *testing.T) *render.Registry {
|
|||||||
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
|
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
|
||||||
"forecast_hourly.txt.tmpl": "Forecast text",
|
"forecast_hourly.txt.tmpl": "Forecast text",
|
||||||
"forecast_narrative.txt.tmpl": "Narrative Forecast",
|
"forecast_narrative.txt.tmpl": "Narrative Forecast",
|
||||||
|
"weatherstories.txt.tmpl": "Weather Stories",
|
||||||
|
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
|
||||||
"alerts_active.txt.tmpl": "Alerts text",
|
"alerts_active.txt.tmpl": "Alerts text",
|
||||||
"conditions_current.txt.tmpl": "Conditions text",
|
"conditions_current.txt.tmpl": "Conditions text",
|
||||||
} {
|
} {
|
||||||
@@ -1811,6 +1994,39 @@ func wmoCodePtr(v model.WMOCode) *model.WMOCode {
|
|||||||
return &out
|
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 {
|
type forecastTimePayload struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
IssuedAt time.Time `json:"issuedAt"`
|
IssuedAt time.Time `json:"issuedAt"`
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// weatherstories.go presents weather story payloads.
|
||||||
|
// Layer: adapters/inbound/httpapi/presenter weather stories payload mapping.
|
||||||
|
package presenter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func WeatherStoryRunPayload(run *model.WeatherStoryRun, _ Units, tz *time.Location) any {
|
||||||
|
if run == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := model.WeatherStoryRun{
|
||||||
|
OfficeID: run.OfficeID,
|
||||||
|
AsOf: inLocationTime(run.AsOf, tz),
|
||||||
|
Stories: make([]model.WeatherStory, 0, len(run.Stories)),
|
||||||
|
}
|
||||||
|
for _, story := range run.Stories {
|
||||||
|
out.Stories = append(out.Stories, copyWeatherStory(story, tz))
|
||||||
|
}
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
func WeatherStoryPayload(story *model.WeatherStory, _ Units, tz *time.Location) any {
|
||||||
|
if story == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := copyWeatherStory(*story, tz)
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyWeatherStory(story model.WeatherStory, tz *time.Location) model.WeatherStory {
|
||||||
|
return model.WeatherStory{
|
||||||
|
OfficeID: story.OfficeID,
|
||||||
|
StartTime: inLocationTime(story.StartTime, tz),
|
||||||
|
EndTime: inLocationTime(story.EndTime, tz),
|
||||||
|
UpdatedAt: inLocationTime(story.UpdatedAt, tz),
|
||||||
|
Title: story.Title,
|
||||||
|
Description: story.Description,
|
||||||
|
AltText: story.AltText,
|
||||||
|
Priority: story.Priority,
|
||||||
|
Order: story.Order,
|
||||||
|
DownloadURL: story.DownloadURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ type Service interface {
|
|||||||
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)
|
LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error)
|
||||||
|
LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error)
|
||||||
|
LatestWeatherStory(ctx context.Context) (*model.WeatherStory, 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)
|
||||||
}
|
}
|
||||||
|
|||||||
43
internal/adapters/inbound/httpapi/weatherstories_endpoint.go
Normal file
43
internal/adapters/inbound/httpapi/weatherstories_endpoint.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// weatherstories_endpoint.go defines the /weatherstories endpoint behavior.
|
||||||
|
// Layer: adapters/inbound/httpapi weather stories 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 weatherStoriesDefinitions(svc Service) []endpoint.Definition {
|
||||||
|
return []endpoint.Definition{
|
||||||
|
endpoint.GET(
|
||||||
|
"/weatherstories",
|
||||||
|
bindTimezoneQuery,
|
||||||
|
func(ctx context.Context, req timezoneQueryRequest) (any, error) {
|
||||||
|
run, err := svc.LatestWeatherStoryRun(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return response.Envelope{Data: presenter.WeatherStoryRunPayload(run, req.Units, req.Timezone)}, nil
|
||||||
|
},
|
||||||
|
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
|
||||||
|
endpoint.WithTemplate("weatherstories.txt.tmpl"),
|
||||||
|
),
|
||||||
|
endpoint.GET(
|
||||||
|
"/weatherstories/latest",
|
||||||
|
bindTimezoneQuery,
|
||||||
|
func(ctx context.Context, req timezoneQueryRequest) (any, error) {
|
||||||
|
story, err := svc.LatestWeatherStory(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return response.Envelope{Data: presenter.WeatherStoryPayload(story, req.Units, req.Timezone)}, nil
|
||||||
|
},
|
||||||
|
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
|
||||||
|
endpoint.WithTemplate("weatherstories_latest.txt.tmpl"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,6 +160,63 @@ func TestMapDiscussionKeyMessagesPreserveOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMapWeatherStoryRunParentRowNullables(t *testing.T) {
|
||||||
|
asOf := time.Date(2026, 5, 30, 9, 0, 34, 0, time.FixedZone("CDT", -5*3600))
|
||||||
|
run := mapWeatherStoryRunParentRow(weatherStoryRunParentRow{
|
||||||
|
EventID: "evt-story-run",
|
||||||
|
OfficeID: sql.NullString{String: "LSX", Valid: true},
|
||||||
|
AsOf: asOf,
|
||||||
|
})
|
||||||
|
|
||||||
|
if run.OfficeID != "LSX" {
|
||||||
|
t.Fatalf("expected office id LSX, got %q", run.OfficeID)
|
||||||
|
}
|
||||||
|
if run.AsOf.Location().String() != "UTC" {
|
||||||
|
t.Fatalf("expected asOf to be UTC-normalized, got %v", run.AsOf)
|
||||||
|
}
|
||||||
|
if run.Stories != nil {
|
||||||
|
t.Fatalf("expected nil stories before child load, got %+v", run.Stories)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapWeatherStoryRowMapsFields(t *testing.T) {
|
||||||
|
start := time.Date(2026, 5, 30, 8, 46, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||||
|
end := time.Date(2026, 5, 31, 11, 0, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||||
|
updated := time.Date(2026, 5, 30, 9, 0, 34, 0, time.FixedZone("CDT", -5*3600))
|
||||||
|
story := mapWeatherStoryRow(weatherStoryRow{
|
||||||
|
StoryIndex: 2,
|
||||||
|
OfficeID: sql.NullString{String: "LSX", Valid: true},
|
||||||
|
StartTime: start,
|
||||||
|
EndTime: end,
|
||||||
|
UpdatedAt: updated,
|
||||||
|
Title: sql.NullString{String: "Several Chances for Rain Through Monday", Valid: true},
|
||||||
|
Description: sql.NullString{String: "Scattered showers and thunderstorms.", Valid: true},
|
||||||
|
AltText: sql.NullString{String: "Forecast slide.", Valid: true},
|
||||||
|
Priority: true,
|
||||||
|
StoryOrder: 1,
|
||||||
|
DownloadURL: sql.NullString{String: "https://api.weather.gov/offices/LSX/weatherstories/download/story-1", Valid: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
if story.OfficeID != "LSX" {
|
||||||
|
t.Fatalf("expected office id LSX, got %q", story.OfficeID)
|
||||||
|
}
|
||||||
|
if story.Title != "Several Chances for Rain Through Monday" {
|
||||||
|
t.Fatalf("unexpected title: %q", story.Title)
|
||||||
|
}
|
||||||
|
if !story.Priority {
|
||||||
|
t.Fatalf("expected priority true")
|
||||||
|
}
|
||||||
|
if story.Order != 1 {
|
||||||
|
t.Fatalf("expected order 1, got %d", story.Order)
|
||||||
|
}
|
||||||
|
if story.DownloadURL == "" {
|
||||||
|
t.Fatalf("expected download URL")
|
||||||
|
}
|
||||||
|
if story.StartTime.Location().String() != "UTC" || story.EndTime.Location().String() != "UTC" || story.UpdatedAt.Location().String() != "UTC" {
|
||||||
|
t.Fatalf("expected story timestamps to be UTC-normalized, got %s %s %s", story.StartTime, story.EndTime, story.UpdatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
28
internal/adapters/outbound/postgres/weatherstories_mapper.go
Normal file
28
internal/adapters/outbound/postgres/weatherstories_mapper.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// weatherstories_mapper.go maps weather story rows into weather model payloads.
|
||||||
|
// Layer: adapters/outbound/postgres weather stories feature.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
|
|
||||||
|
func mapWeatherStoryRunParentRow(row weatherStoryRunParentRow) model.WeatherStoryRun {
|
||||||
|
return model.WeatherStoryRun{
|
||||||
|
OfficeID: stringValue(row.OfficeID),
|
||||||
|
AsOf: row.AsOf.UTC(),
|
||||||
|
Stories: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapWeatherStoryRow(row weatherStoryRow) model.WeatherStory {
|
||||||
|
return model.WeatherStory{
|
||||||
|
OfficeID: stringValue(row.OfficeID),
|
||||||
|
StartTime: row.StartTime.UTC(),
|
||||||
|
EndTime: row.EndTime.UTC(),
|
||||||
|
UpdatedAt: row.UpdatedAt.UTC(),
|
||||||
|
Title: stringValue(row.Title),
|
||||||
|
Description: stringValue(row.Description),
|
||||||
|
AltText: stringValue(row.AltText),
|
||||||
|
Priority: row.Priority,
|
||||||
|
Order: row.StoryOrder,
|
||||||
|
DownloadURL: stringValue(row.DownloadURL),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// weatherstories_queries.go contains SQL text for weather story reads.
|
||||||
|
// Layer: adapters/outbound/postgres weather stories feature.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
const (
|
||||||
|
queryLatestWeatherStoryRun = `
|
||||||
|
SELECT
|
||||||
|
event_id,
|
||||||
|
office_id,
|
||||||
|
as_of
|
||||||
|
FROM weather_story_runs
|
||||||
|
ORDER BY as_of DESC, event_emitted_at DESC
|
||||||
|
LIMIT 1`
|
||||||
|
|
||||||
|
queryWeatherStoriesForRun = `
|
||||||
|
SELECT
|
||||||
|
story_index,
|
||||||
|
office_id,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
updated_at,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
alt_text,
|
||||||
|
priority,
|
||||||
|
story_order,
|
||||||
|
download_url
|
||||||
|
FROM weather_stories
|
||||||
|
WHERE run_event_id = $1
|
||||||
|
ORDER BY story_index ASC`
|
||||||
|
|
||||||
|
queryLatestWeatherStory = `
|
||||||
|
SELECT
|
||||||
|
story_index,
|
||||||
|
office_id,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
updated_at,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
alt_text,
|
||||||
|
priority,
|
||||||
|
story_order,
|
||||||
|
download_url
|
||||||
|
FROM weather_stories
|
||||||
|
ORDER BY updated_at DESC, as_of DESC, story_order ASC, story_index ASC
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
103
internal/adapters/outbound/postgres/weatherstories_read.go
Normal file
103
internal/adapters/outbound/postgres/weatherstories_read.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// weatherstories_read.go executes weather story queries.
|
||||||
|
// Layer: adapters/outbound/postgres weather stories feature.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Repository) LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, fmt.Errorf("postgres repository is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
var row weatherStoryRunParentRow
|
||||||
|
err := r.db.QueryRowContext(ctx, queryLatestWeatherStoryRun).Scan(
|
||||||
|
&row.EventID,
|
||||||
|
&row.OfficeID,
|
||||||
|
&row.AsOf,
|
||||||
|
)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query latest weather story run: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
run := mapWeatherStoryRunParentRow(row)
|
||||||
|
stories, err := r.loadWeatherStories(ctx, row.EventID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
run.Stories = stories
|
||||||
|
|
||||||
|
return &run, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, fmt.Errorf("postgres repository is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
var row weatherStoryRow
|
||||||
|
err := r.db.QueryRowContext(ctx, queryLatestWeatherStory).Scan(
|
||||||
|
&row.StoryIndex,
|
||||||
|
&row.OfficeID,
|
||||||
|
&row.StartTime,
|
||||||
|
&row.EndTime,
|
||||||
|
&row.UpdatedAt,
|
||||||
|
&row.Title,
|
||||||
|
&row.Description,
|
||||||
|
&row.AltText,
|
||||||
|
&row.Priority,
|
||||||
|
&row.StoryOrder,
|
||||||
|
&row.DownloadURL,
|
||||||
|
)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query latest weather story: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
story := mapWeatherStoryRow(row)
|
||||||
|
return &story, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) loadWeatherStories(ctx context.Context, eventID string) ([]model.WeatherStory, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, queryWeatherStoriesForRun, eventID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query weather stories: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]model.WeatherStory, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var row weatherStoryRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&row.StoryIndex,
|
||||||
|
&row.OfficeID,
|
||||||
|
&row.StartTime,
|
||||||
|
&row.EndTime,
|
||||||
|
&row.UpdatedAt,
|
||||||
|
&row.Title,
|
||||||
|
&row.Description,
|
||||||
|
&row.AltText,
|
||||||
|
&row.Priority,
|
||||||
|
&row.StoryOrder,
|
||||||
|
&row.DownloadURL,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan weather story row: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, mapWeatherStoryRow(row))
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate weather story rows: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
28
internal/adapters/outbound/postgres/weatherstories_rows.go
Normal file
28
internal/adapters/outbound/postgres/weatherstories_rows.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// weatherstories_rows.go defines row DTOs for weather story reads.
|
||||||
|
// Layer: adapters/outbound/postgres weather stories feature.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type weatherStoryRunParentRow struct {
|
||||||
|
EventID string
|
||||||
|
OfficeID sql.NullString
|
||||||
|
AsOf time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type weatherStoryRow struct {
|
||||||
|
StoryIndex int
|
||||||
|
OfficeID sql.NullString
|
||||||
|
StartTime time.Time
|
||||||
|
EndTime time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
Title sql.NullString
|
||||||
|
Description sql.NullString
|
||||||
|
AltText sql.NullString
|
||||||
|
Priority bool
|
||||||
|
StoryOrder int
|
||||||
|
DownloadURL sql.NullString
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ type Repository interface {
|
|||||||
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)
|
LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error)
|
||||||
|
LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error)
|
||||||
|
LatestWeatherStory(ctx context.Context) (*model.WeatherStory, 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)
|
||||||
}
|
}
|
||||||
@@ -43,6 +45,14 @@ func (s *Service) LatestForecastDiscussion(ctx context.Context) (*model.WeatherF
|
|||||||
return s.repo.LatestForecastDiscussion(ctx)
|
return s.repo.LatestForecastDiscussion(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error) {
|
||||||
|
return s.repo.LatestWeatherStoryRun(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error) {
|
||||||
|
return s.repo.LatestWeatherStory(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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ type fakeRepository struct {
|
|||||||
forecast *model.WeatherForecastRun
|
forecast *model.WeatherForecastRun
|
||||||
narrative *model.WeatherForecastRun
|
narrative *model.WeatherForecastRun
|
||||||
discussion *model.WeatherForecastDiscussion
|
discussion *model.WeatherForecastDiscussion
|
||||||
|
storyRun *model.WeatherStoryRun
|
||||||
|
story *model.WeatherStory
|
||||||
alerts *model.WeatherAlertRun
|
alerts *model.WeatherAlertRun
|
||||||
conditions *CurrentConditions
|
conditions *CurrentConditions
|
||||||
err error
|
err error
|
||||||
@@ -38,6 +40,14 @@ func (r *fakeRepository) LatestForecastDiscussion(context.Context) (*model.Weath
|
|||||||
return r.discussion, r.err
|
return r.discussion, r.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepository) LatestWeatherStoryRun(context.Context) (*model.WeatherStoryRun, error) {
|
||||||
|
return r.storyRun, r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepository) LatestWeatherStory(context.Context) (*model.WeatherStory, error) {
|
||||||
|
return r.story, 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
|
||||||
}
|
}
|
||||||
@@ -112,6 +122,32 @@ func TestServiceDelegatesForecastDiscussion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceDelegatesWeatherStoryRun(t *testing.T) {
|
||||||
|
repo := &fakeRepository{storyRun: &model.WeatherStoryRun{OfficeID: "LSX"}}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestWeatherStoryRun(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if run == nil || run.OfficeID != "LSX" {
|
||||||
|
t.Fatalf("unexpected weather story run: %+v", run)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceDelegatesWeatherStory(t *testing.T) {
|
||||||
|
repo := &fakeRepository{story: &model.WeatherStory{OfficeID: "LSX", Title: "Rain chances"}}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
story, err := svc.LatestWeatherStory(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if story == nil || story.Title != "Rain chances" {
|
||||||
|
t.Fatalf("unexpected weather story: %+v", story)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
26
templates/weatherstories.txt.tmpl
Normal file
26
templates/weatherstories.txt.tmpl
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{{- if .Data -}}
|
||||||
|
Weather Stories
|
||||||
|
Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}}
|
||||||
|
As Of: {{.Data.AsOf}}
|
||||||
|
Stories: {{len .Data.Stories}}
|
||||||
|
{{- range $i, $story := .Data.Stories}}
|
||||||
|
|
||||||
|
[{{$i}}] {{if $story.Title}}{{$story.Title}}{{else}}Untitled{{end}}
|
||||||
|
Start: {{$story.StartTime}}
|
||||||
|
End: {{$story.EndTime}}
|
||||||
|
Updated: {{$story.UpdatedAt}}
|
||||||
|
Priority: {{$story.Priority}}
|
||||||
|
Order: {{$story.Order}}
|
||||||
|
{{- if $story.Description}}
|
||||||
|
Description: {{$story.Description}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if $story.AltText}}
|
||||||
|
Alt Text: {{$story.AltText}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if $story.DownloadURL}}
|
||||||
|
Download URL: {{$story.DownloadURL}}
|
||||||
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
{{- else -}}
|
||||||
|
No weather stories data available.
|
||||||
|
{{- end}}
|
||||||
21
templates/weatherstories_latest.txt.tmpl
Normal file
21
templates/weatherstories_latest.txt.tmpl
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{{- if .Data -}}
|
||||||
|
Latest Weather Story
|
||||||
|
Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}}
|
||||||
|
Title: {{if .Data.Title}}{{.Data.Title}}{{else}}Untitled{{end}}
|
||||||
|
Start: {{.Data.StartTime}}
|
||||||
|
End: {{.Data.EndTime}}
|
||||||
|
Updated: {{.Data.UpdatedAt}}
|
||||||
|
Priority: {{.Data.Priority}}
|
||||||
|
Order: {{.Data.Order}}
|
||||||
|
{{- if .Data.Description}}
|
||||||
|
Description: {{.Data.Description}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.AltText}}
|
||||||
|
Alt Text: {{.Data.AltText}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Data.DownloadURL}}
|
||||||
|
Download URL: {{.Data.DownloadURL}}
|
||||||
|
{{- end}}
|
||||||
|
{{- else -}}
|
||||||
|
No weather stories data available.
|
||||||
|
{{- end}}
|
||||||
Reference in New Issue
Block a user