package spc import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/ejr/feedkit/config" "gitea.maximumdirect.net/ejr/feedkit/event" spcprovider "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/spc" "gitea.maximumdirect.net/ejr/weatherfeeder/standards" ) func TestConvectiveOutlookSourceKinds(t *testing.T) { src, err := NewConvectiveOutlookSource(convectiveOutlookConfig(map[string]any{})) if err != nil { t.Fatalf("NewConvectiveOutlookSource() error = %v", err) } got := src.Kinds() if len(got) != 1 || got[0] != event.Kind(standards.KindOutlook) { t.Fatalf("Kinds() = %#v, want [outlook]", got) } } func TestConvectiveOutlookSourceRequiresLatitudeAndLongitude(t *testing.T) { for _, key := range []string{"latitude", "longitude"} { cfg := convectiveOutlookConfig(map[string]any{}) delete(cfg.Params, key) _, err := NewConvectiveOutlookSource(cfg) if err == nil { t.Fatalf("NewConvectiveOutlookSource() without %s error = nil, want error", key) } if !strings.Contains(err.Error(), "params."+key+" is required") { t.Fatalf("error = %q, want missing %s", err, key) } } } func TestConvectiveOutlookSourcePollEmitsRawBundle(t *testing.T) { srv := newSPCTestServer(t, spcServerOptions{}) src, err := NewConvectiveOutlookSource(convectiveOutlookConfig(serverOverrideParams(srv.URL, false))) if err != nil { t.Fatalf("NewConvectiveOutlookSource() error = %v", err) } events, err := src.Poll(t.Context()) if err != nil { t.Fatalf("Poll() error = %v", err) } if len(events) != 1 { t.Fatalf("Poll() returned %d events, want 1", len(events)) } got := events[0] if got.Kind != event.Kind(standards.KindOutlook) { t.Fatalf("Kind = %q, want outlook", got.Kind) } if got.Schema != standards.SchemaRawSPCConvectiveOutlookV1 { t.Fatalf("Schema = %q, want %q", got.Schema, standards.SchemaRawSPCConvectiveOutlookV1) } wantEffective := time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC) if got.EffectiveAt == nil || !got.EffectiveAt.Equal(wantEffective) { t.Fatalf("EffectiveAt = %v, want %s", got.EffectiveAt, wantEffective) } bundle, ok := got.Payload.(spcprovider.RawConvectiveOutlookBundle) if !ok { t.Fatalf("Payload type = %T, want RawConvectiveOutlookBundle", got.Payload) } if bundle.LocationID != "stl" || bundle.LocationName != "St. Louis, MO" { t.Fatalf("location metadata = %q/%q", bundle.LocationID, bundle.LocationName) } if bundle.Latitude != 38.6239 || bundle.Longitude != -90.3571 { t.Fatalf("coordinates = %v,%v", bundle.Latitude, bundle.Longitude) } if len(bundle.Products) != 9 { t.Fatalf("Products length = %d, want 9", len(bundle.Products)) } if len(bundle.Discussions) != 3 { t.Fatalf("Discussions length = %d, want 3", len(bundle.Discussions)) } if bundle.RSS != nil { t.Fatalf("RSS = %#v, want nil", bundle.RSS) } } func TestConvectiveOutlookSourceEffectiveAtFallsBackToDiscussionUpdated(t *testing.T) { srv := newSPCTestServer(t, spcServerOptions{blankIssueISO: true}) src, err := NewConvectiveOutlookSource(convectiveOutlookConfig(serverOverrideParams(srv.URL, false))) if err != nil { t.Fatalf("NewConvectiveOutlookSource() error = %v", err) } events, err := src.Poll(t.Context()) if err != nil { t.Fatalf("Poll() error = %v", err) } want := time.Date(2026, 6, 11, 20, 0, 0, 0, time.UTC) if events[0].EffectiveAt == nil || !events[0].EffectiveAt.Equal(want) { t.Fatalf("EffectiveAt = %v, want %s", events[0].EffectiveAt, want) } } func TestConvectiveOutlookSourceIncludesRSSOnlyWhenConfigured(t *testing.T) { srv := newSPCTestServer(t, spcServerOptions{}) withoutRSS, err := NewConvectiveOutlookSource(convectiveOutlookConfig(serverOverrideParams(srv.URL, false))) if err != nil { t.Fatalf("NewConvectiveOutlookSource(without RSS) error = %v", err) } events, err := withoutRSS.Poll(t.Context()) if err != nil { t.Fatalf("Poll(without RSS) error = %v", err) } if events[0].Payload.(spcprovider.RawConvectiveOutlookBundle).RSS != nil { t.Fatalf("RSS present without rss_url") } withRSS, err := NewConvectiveOutlookSource(convectiveOutlookConfig(serverOverrideParams(srv.URL, true))) if err != nil { t.Fatalf("NewConvectiveOutlookSource(with RSS) error = %v", err) } events, err = withRSS.Poll(t.Context()) if err != nil { t.Fatalf("Poll(with RSS) error = %v", err) } if events[0].Payload.(spcprovider.RawConvectiveOutlookBundle).RSS == nil { t.Fatalf("RSS missing with rss_url") } } func TestConvectiveOutlookSourceUnchangedResponseEmitsNoEvents(t *testing.T) { srv := newSPCTestServer(t, spcServerOptions{}) src, err := NewConvectiveOutlookSource(convectiveOutlookConfig(serverOverrideParams(srv.URL, false))) if err != nil { t.Fatalf("NewConvectiveOutlookSource() error = %v", err) } events, err := src.Poll(t.Context()) if err != nil { t.Fatalf("first Poll() error = %v", err) } if len(events) != 1 { t.Fatalf("first Poll() events = %d, want 1", len(events)) } events, err = src.Poll(t.Context()) if err != nil { t.Fatalf("second Poll() error = %v", err) } if len(events) != 0 { t.Fatalf("second Poll() events = %d, want 0", len(events)) } } func TestConvectiveOutlookSourceGeoJSONFailureReturnsError(t *testing.T) { srv := newSPCTestServer(t, spcServerOptions{failGeoJSONKey: "day2_wind"}) src, err := NewConvectiveOutlookSource(convectiveOutlookConfig(serverOverrideParams(srv.URL, false))) if err != nil { t.Fatalf("NewConvectiveOutlookSource() error = %v", err) } events, err := src.Poll(t.Context()) if err == nil { t.Fatalf("Poll() error = nil, want error") } if len(events) != 0 { t.Fatalf("Poll() events = %d, want 0", len(events)) } if !strings.Contains(err.Error(), "fetch geojson day2_wind") { t.Fatalf("error = %q", err) } } func TestConvectiveOutlookSourceDiscussionFailureReturnsError(t *testing.T) { srv := newSPCTestServer(t, spcServerOptions{failDiscussionKey: "day2"}) src, err := NewConvectiveOutlookSource(convectiveOutlookConfig(serverOverrideParams(srv.URL, false))) if err != nil { t.Fatalf("NewConvectiveOutlookSource() error = %v", err) } events, err := src.Poll(t.Context()) if err == nil { t.Fatalf("Poll() error = nil, want error") } if len(events) != 0 { t.Fatalf("Poll() events = %d, want 0", len(events)) } if !strings.Contains(err.Error(), "fetch discussion day2") { t.Fatalf("error = %q", err) } } func TestConvectiveOutlookSourcePayloadJSONShape(t *testing.T) { srv := newSPCTestServer(t, spcServerOptions{}) src, err := NewConvectiveOutlookSource(convectiveOutlookConfig(serverOverrideParams(srv.URL, false))) if err != nil { t.Fatalf("NewConvectiveOutlookSource() error = %v", err) } events, err := src.Poll(t.Context()) if err != nil { t.Fatalf("Poll() error = %v", err) } raw, err := json.Marshal(events[0].Payload) if err != nil { t.Fatalf("Marshal(payload) error = %v", err) } if !strings.Contains(string(raw), `"products"`) || !strings.Contains(string(raw), `"discussions"`) { t.Fatalf("payload JSON missing raw bundle fields: %s", raw) } } type spcServerOptions struct { blankIssueISO bool failGeoJSONKey string failDiscussionKey string } func newSPCTestServer(t *testing.T, opts spcServerOptions) *httptest.Server { t.Helper() mux := http.NewServeMux() for _, product := range spcprovider.GeoJSONProducts() { product := product mux.HandleFunc("/geojson/"+product.Key, func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Accept") != acceptGeoJSON { t.Errorf("geojson Accept = %q, want %q", r.Header.Get("Accept"), acceptGeoJSON) } if product.Key == opts.failGeoJSONKey { http.Error(w, "failed", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/geo+json") _, _ = w.Write(geoJSONFixtureForProduct(t, product.Key, opts.blankIssueISO)) }) } for _, product := range spcprovider.DiscussionProducts() { product := product mux.HandleFunc("/discussion/"+product.Key, func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Accept") != acceptDiscussion { t.Errorf("discussion Accept = %q, want %q", r.Header.Get("Accept"), acceptDiscussion) } if product.Key == opts.failDiscussionKey { http.Error(w, "failed", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html") _, _ = w.Write(discussionFixtureForProduct(t, product.Key)) }) } mux.HandleFunc("/rss", func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Accept") != acceptRSS { t.Errorf("rss Accept = %q, want %q", r.Header.Get("Accept"), acceptRSS) } w.Header().Set("Content-Type", "application/rss+xml") _, _ = w.Write([]byte(testRSS)) }) return httptest.NewServer(mux) } func convectiveOutlookConfig(extra map[string]any) config.SourceConfig { params := map[string]any{ "latitude": 38.6239, "longitude": -90.3571, "location_id": "stl", "location_name": "St. Louis, MO", "user_agent": "test-agent", } for k, v := range extra { params[k] = v } return config.SourceConfig{ Name: "spc-test", Driver: DriverConvectiveOutlook, Mode: config.SourceModePoll, Params: params, } } func serverOverrideParams(baseURL string, includeRSS bool) map[string]any { geoJSONURLs := map[string]any{} for _, product := range spcprovider.GeoJSONProducts() { geoJSONURLs[product.Key] = baseURL + "/geojson/" + product.Key } discussionURLs := map[string]any{} for _, product := range spcprovider.DiscussionProducts() { discussionURLs[product.Key] = baseURL + "/discussion/" + product.Key } out := map[string]any{ "geojson_urls": geoJSONURLs, "discussion_urls": discussionURLs, } if includeRSS { out["rss_url"] = baseURL + "/rss" } return out } func geoJSONFixtureForProduct(t *testing.T, key string, blankIssueISO bool) []byte { t.Helper() var name string switch { case strings.HasPrefix(key, "day1_"): name = "day1_cat.geojson" case strings.HasPrefix(key, "day2_"): name = "day2_torn.geojson" case strings.HasPrefix(key, "day3_"): name = "day3_cat.geojson" default: t.Fatalf("unknown product key %q", key) } raw := readSPCTestFixture(t, name) if blankIssueISO { raw = []byte(strings.ReplaceAll(string(raw), `"ISSUE_ISO": "2026-06-11T12:34:56Z"`, `"ISSUE_ISO": ""`)) raw = []byte(strings.ReplaceAll(string(raw), `"ISSUE_ISO": "2026-06-11T17:30:00Z"`, `"ISSUE_ISO": ""`)) raw = []byte(strings.ReplaceAll(string(raw), `"ISSUE_ISO": "2026-06-11T19:45:00Z"`, `"ISSUE_ISO": ""`)) } return raw } func discussionFixtureForProduct(t *testing.T, key string) []byte { t.Helper() switch key { case "day1": return readSPCTestFixture(t, "day1_prt.html") case "day2": return readSPCTestFixture(t, "day2_prt_corr.html") case "day3": return readSPCTestFixture(t, "day3_prt.html") default: t.Fatalf("unknown discussion key %q", key) return nil } } func readSPCTestFixture(t *testing.T, name string) []byte { t.Helper() path := filepath.Join("..", "..", "providers", "spc", "testdata", name) raw, err := os.ReadFile(path) if err != nil { t.Fatalf("read fixture %s: %v", path, err) } return raw } const testRSS = ` SPC AC RSS Thu, 11 Jun 2026 21:00:00 +0000 `