diff --git a/internal/adapters/inbound/httpapi/endpoints_test.go b/internal/adapters/inbound/httpapi/endpoints_test.go index 12b0bd0..55608bb 100644 --- a/internal/adapters/inbound/httpapi/endpoints_test.go +++ b/internal/adapters/inbound/httpapi/endpoints_test.go @@ -1355,6 +1355,43 @@ func TestOutlookNoDataReturnsNullEnvelopeData(t *testing.T) { } } +func TestOutlookTextResponseUsesTemplate(t *testing.T) { + h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, "/outlooks/convective") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?format=text", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, 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")) + } + body := w.Body.String() + if !strings.Contains(body, "Convective Outlook") || !strings.Contains(body, "Outlooks: 1") { + t.Fatalf("expected outlook text template body, got %q", body) + } +} + +func TestOutlookXMLResponseRenders(t *testing.T) { + h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, "/outlooks/convective") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?format=xml", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { + t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) + } + if !strings.Contains(w.Body.String(), "stl") { + t.Fatalf("expected outlook XML payload, got %q", w.Body.String()) + } +} + func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) { run := testOutlookRun() run.Outlooks = []model.WeatherOutlook{} @@ -2174,7 +2211,7 @@ func testRenderers(t *testing.T) *render.Registry { "discussion_long_term.txt.tmpl": "Forecast Discussion Long Term", "forecast_hourly.txt.tmpl": "Forecast text", "forecast_narrative.txt.tmpl": "Narrative Forecast", - "outlooks_convective.txt.tmpl": "Convective Outlook", + "outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}{{else}}No convective outlook data available.{{end}}", "weatherstories.txt.tmpl": "Weather Stories", "weatherstories_latest.txt.tmpl": "Latest Weather Story", "alerts_active.txt.tmpl": "Alerts text", diff --git a/internal/adapters/inbound/httpapi/presenter/outlook.go b/internal/adapters/inbound/httpapi/presenter/outlook.go index c41e297..d0bd1b9 100644 --- a/internal/adapters/inbound/httpapi/presenter/outlook.go +++ b/internal/adapters/inbound/httpapi/presenter/outlook.go @@ -3,14 +3,64 @@ package presenter import ( + "encoding/json" "time" "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) -func OutlookRunPayload(run *model.WeatherOutlookRun, _ Units, _ *time.Location) any { +func OutlookRunPayload(run *model.WeatherOutlookRun, _ Units, tz *time.Location) any { if run == nil { return nil } - return run + + out := model.WeatherOutlookRun{ + LocationID: run.LocationID, + LocationName: run.LocationName, + Latitude: copyFloat64Ptr(run.Latitude), + Longitude: copyFloat64Ptr(run.Longitude), + AsOf: inLocationTime(run.AsOf, tz), + IssuedAt: inLocationTimePtr(run.IssuedAt, tz), + Outlooks: make([]model.WeatherOutlook, 0, len(run.Outlooks)), + } + for _, outlook := range run.Outlooks { + out.Outlooks = append(out.Outlooks, copyOutlook(outlook, tz)) + } + return &out +} + +func copyOutlook(outlook model.WeatherOutlook, tz *time.Location) model.WeatherOutlook { + out := model.WeatherOutlook{ + ID: outlook.ID, + Provider: outlook.Provider, + Product: outlook.Product, + Day: outlook.Day, + OutlookType: outlook.OutlookType, + Label: outlook.Label, + LabelText: outlook.LabelText, + SeverityRank: copyIntPtr(outlook.SeverityRank), + ValidFrom: inLocationTime(outlook.ValidFrom, tz), + ValidTo: inLocationTime(outlook.ValidTo, tz), + IssuedAt: inLocationTime(outlook.IssuedAt, tz), + ExpiresAt: inLocationTime(outlook.ExpiresAt, tz), + Forecaster: outlook.Forecaster, + Headline: outlook.Headline, + Summary: outlook.Summary, + Discussion: outlook.Discussion, + SourceURL: outlook.SourceURL, + ImageURL: outlook.ImageURL, + ContainsLocation: outlook.ContainsLocation, + } + if outlook.Geometry != nil { + out.Geometry = json.RawMessage(append([]byte(nil), outlook.Geometry...)) + } + return out +} + +func copyIntPtr(v *int) *int { + if v == nil { + return nil + } + out := *v + return &out } diff --git a/internal/adapters/inbound/httpapi/presenter/payload_test.go b/internal/adapters/inbound/httpapi/presenter/payload_test.go index 5558afb..92c7aac 100644 --- a/internal/adapters/inbound/httpapi/presenter/payload_test.go +++ b/internal/adapters/inbound/httpapi/presenter/payload_test.go @@ -430,6 +430,110 @@ func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) { assertApprox(t, us.Longitude, -90.199456, 0.000001) } +func TestOutlookRunPayloadNilInputReturnsNil(t *testing.T) { + if OutlookRunPayload(nil, UnitsUS, time.UTC) != nil { + t.Fatalf("expected nil outlook run input to return nil payload") + } +} + +func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) { + loc := time.FixedZone("UTC-05:00", -5*60*60) + asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC) + issuedAt := asOf.Add(-1 * time.Hour) + severityRank := 5 + latitude := 38.627123 + longitude := -90.199456 + geometry := json.RawMessage(`{"type":"Point","coordinates":[-90.2,38.6]}`) + run := &model.WeatherOutlookRun{ + LocationID: "stl", + LocationName: "St. Louis", + Latitude: &latitude, + Longitude: &longitude, + AsOf: asOf, + IssuedAt: &issuedAt, + Outlooks: []model.WeatherOutlook{{ + ID: "cat-1", + Provider: "spc", + Product: "convective", + Day: 1, + OutlookType: "categorical", + Label: "SLGT", + LabelText: "Slight Risk", + SeverityRank: &severityRank, + ValidFrom: asOf, + ValidTo: asOf.Add(6 * time.Hour), + IssuedAt: issuedAt, + ExpiresAt: asOf.Add(6 * time.Hour), + Forecaster: "DIAL", + Headline: "Severe storms possible", + Summary: "Scattered severe storms are possible.", + Discussion: "Discussion text.", + SourceURL: "https://example.test/source", + ImageURL: "https://example.test/image.png", + ContainsLocation: true, + Geometry: geometry, + }}, + } + + payload := OutlookRunPayload(run, UnitsUS, loc) + out, ok := payload.(*model.WeatherOutlookRun) + if !ok { + t.Fatalf("expected *model.WeatherOutlookRun payload, got %T", payload) + } + if out == run { + t.Fatalf("expected outlook run payload to be copied") + } + if out.Latitude == run.Latitude || out.Longitude == run.Longitude || out.IssuedAt == run.IssuedAt { + t.Fatalf("expected run pointers to be copied") + } + if len(out.Outlooks) != 1 { + t.Fatalf("expected one outlook, got %d", len(out.Outlooks)) + } + if out.Outlooks[0].SeverityRank == run.Outlooks[0].SeverityRank { + t.Fatalf("expected severity rank pointer to be copied") + } + if &out.Outlooks[0].Geometry[0] == &run.Outlooks[0].Geometry[0] { + t.Fatalf("expected geometry bytes to be copied") + } + + assertOffsetSeconds(t, out.AsOf, -5*60*60) + assertOffsetSeconds(t, *out.IssuedAt, -5*60*60) + assertOffsetSeconds(t, out.Outlooks[0].ValidFrom, -5*60*60) + assertOffsetSeconds(t, out.Outlooks[0].ValidTo, -5*60*60) + assertOffsetSeconds(t, out.Outlooks[0].IssuedAt, -5*60*60) + assertOffsetSeconds(t, out.Outlooks[0].ExpiresAt, -5*60*60) + if !out.AsOf.UTC().Equal(asOf) || !out.Outlooks[0].ValidFrom.UTC().Equal(asOf) { + t.Fatalf("expected timezone conversion to preserve instants") + } + + if *out.Latitude != latitude || *out.Longitude != longitude { + t.Fatalf("expected latitude/longitude preserved, got %v %v", out.Latitude, out.Longitude) + } + if out.Outlooks[0].SeverityRank == nil || *out.Outlooks[0].SeverityRank != severityRank { + t.Fatalf("expected severity rank preserved, got %v", out.Outlooks[0].SeverityRank) + } + if string(out.Outlooks[0].Geometry) != string(geometry) { + t.Fatalf("expected geometry bytes preserved, got %s", out.Outlooks[0].Geometry) + } + + *out.Latitude = 99 + *out.Longitude = -99 + *out.IssuedAt = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) + *out.Outlooks[0].SeverityRank = 99 + out.Outlooks[0].Geometry[0] = '[' + if *run.Latitude != latitude || *run.Longitude != longitude || !run.IssuedAt.Equal(issuedAt) { + t.Fatalf("expected source run pointers not to mutate") + } + if *run.Outlooks[0].SeverityRank != severityRank { + t.Fatalf("expected source severity rank not to mutate") + } + if string(run.Outlooks[0].Geometry) != string(geometry) { + t.Fatalf("expected source geometry not to mutate, got %s", run.Outlooks[0].Geometry) + } + assertOffsetSeconds(t, run.AsOf, 0) + assertOffsetSeconds(t, run.Outlooks[0].ValidFrom, 0) +} + func float64Ptr(v float64) *float64 { return &v } diff --git a/templates/outlooks_convective.txt.tmpl b/templates/outlooks_convective.txt.tmpl new file mode 100644 index 0000000..6c31c7a --- /dev/null +++ b/templates/outlooks_convective.txt.tmpl @@ -0,0 +1,30 @@ +{{- if .Data -}} +Convective Outlook +Location ID: {{if .Data.LocationID}}{{.Data.LocationID}}{{else}}n/a{{end}} +Location Name: {{if .Data.LocationName}}{{.Data.LocationName}}{{else}}n/a{{end}} +As Of: {{.Data.AsOf}} +{{- if .Data.IssuedAt}} +Issued At: {{.Data.IssuedAt}} +{{- end}} +Outlooks: {{len .Data.Outlooks}} +{{- range $i, $outlook := .Data.Outlooks}} + +[{{$i}}] Day {{$outlook.Day}} {{$outlook.OutlookType}} {{$outlook.Label}} +Valid: {{$outlook.ValidFrom}} -> {{$outlook.ValidTo}} +Contains Location: {{$outlook.ContainsLocation}} +{{- if $outlook.LabelText}} +Label Text: {{$outlook.LabelText}} +{{- end}} +{{- if $outlook.Headline}} +Headline: {{$outlook.Headline}} +{{- end}} +{{- if $outlook.Summary}} +Summary: {{$outlook.Summary}} +{{- end}} +{{- if $outlook.SourceURL}} +Source URL: {{$outlook.SourceURL}} +{{- end}} +{{- end}} +{{- else -}} +No convective outlook data available. +{{- end}}