Add canonical weather collection package
This commit is contained in:
@@ -10,9 +10,9 @@ import (
|
|||||||
|
|
||||||
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
@@ -394,15 +394,11 @@ func reportRegistry(cfg config.Config) (report.Registry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
|
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
|
||||||
client, err := weatherapi.New(req.Config)
|
result, err := collect.Run(ctx, collect.Request{Config: req.Config})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
bundle, err := client.FetchBundle(ctx)
|
return result.Bundle, nil
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return bundle, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
|
func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
|
||||||
@@ -413,8 +409,8 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := weatherapi.SaveBundle(req.OutputPath, bundle); err != nil {
|
if err := fileutil.WriteJSONAtomic(req.OutputPath, bundle); err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("save bundle: %w", err)
|
||||||
}
|
}
|
||||||
return bundle, nil
|
return bundle, nil
|
||||||
}
|
}
|
||||||
|
|||||||
31
internal/collect/collect.go
Normal file
31
internal/collect/collect.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Package collect owns upstream weather source collection for application use.
|
||||||
|
package collect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Config config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Bundle *weatherdata.Bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(ctx context.Context, req Request) (*Result, error) {
|
||||||
|
client, err := weatherapi.New(req.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare weather collection: %w", err)
|
||||||
|
}
|
||||||
|
bundle, err := client.FetchBundle(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("collect weather bundle: %w", err)
|
||||||
|
}
|
||||||
|
return &Result{Bundle: bundle}, nil
|
||||||
|
}
|
||||||
99
internal/collect/collect_test.go
Normal file
99
internal/collect/collect_test.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package collect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunFetchesBundle(t *testing.T) {
|
||||||
|
server := collectionTestServer(t, nil)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
|
|
||||||
|
result, err := Run(context.Background(), Request{Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if result == nil || result.Bundle == nil {
|
||||||
|
t.Fatal("Run() result bundle = nil, want fetched bundle")
|
||||||
|
}
|
||||||
|
if result.Bundle.Hourly == nil {
|
||||||
|
t.Fatal("Hourly = nil, want fetched hourly forecast")
|
||||||
|
}
|
||||||
|
if result.Bundle.WeatherStory == nil || result.Bundle.WeatherStory.Title != "Several Chances for Rain Through Monday" {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want fetched weather story", result.Bundle.WeatherStory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWrapsAdapterConstructionError(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = ""
|
||||||
|
cfg.Secrets.Directory = "super-secret-directory"
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), Request{Config: cfg})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want adapter construction error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "prepare weather collection") {
|
||||||
|
t.Fatalf("error = %q, want collection setup context", err.Error())
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), cfg.Secrets.Directory) {
|
||||||
|
t.Fatalf("error = %q, want no secret path leakage", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWrapsFetchError(t *testing.T) {
|
||||||
|
server := collectionTestServer(t, map[string]int{"/observations": http.StatusBadGateway})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), Request{Config: cfg})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want fetch error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "collect weather bundle") {
|
||||||
|
t.Fatalf("error = %q, want collection fetch context", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "/observations") {
|
||||||
|
t.Fatalf("error = %q, want source endpoint context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionTestServer(t *testing.T, statusByPath map[string]int) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if status := statusByPath[r.URL.Path]; status != 0 {
|
||||||
|
http.Error(w, "upstream failure", status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/observations":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
|
||||||
|
case "/conditions/current":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
|
||||||
|
case "/forecast/hourly":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00"}]}}`))
|
||||||
|
case "/forecast/narrative":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[]}}`))
|
||||||
|
case "/alerts/active":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
||||||
|
case "/discussion":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
|
||||||
|
case "/weatherstories/latest":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||||
|
case "/outlooks/convective":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user