diff --git a/internal/app/app.go b/internal/app/app.go index 690adeb..77c10f3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -10,9 +10,9 @@ import ( distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor" "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/changes" + "gitea.maximumdirect.net/eric/weatherreporter/internal/collect" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/facts" "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) { - client, err := weatherapi.New(req.Config) + result, err := collect.Run(ctx, collect.Request{Config: req.Config}) if err != nil { return nil, err } - bundle, err := client.FetchBundle(ctx) - if err != nil { - return nil, err - } - return bundle, nil + return result.Bundle, nil } 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 { return nil, err } - if err := weatherapi.SaveBundle(req.OutputPath, bundle); err != nil { - return nil, err + if err := fileutil.WriteJSONAtomic(req.OutputPath, bundle); err != nil { + return nil, fmt.Errorf("save bundle: %w", err) } return bundle, nil } diff --git a/internal/collect/collect.go b/internal/collect/collect.go new file mode 100644 index 0000000..3cf690d --- /dev/null +++ b/internal/collect/collect.go @@ -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 +} diff --git a/internal/collect/collect_test.go b/internal/collect/collect_test.go new file mode 100644 index 0000000..7d3ae97 --- /dev/null +++ b/internal/collect/collect_test.go @@ -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) + } + })) +}