360 lines
11 KiB
Go
360 lines
11 KiB
Go
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 = `<?xml version="1.0"?>
|
|
<rss version="2.0">
|
|
<channel>
|
|
<title>SPC AC RSS</title>
|
|
<lastBuildDate>Thu, 11 Jun 2026 21:00:00 +0000</lastBuildDate>
|
|
</channel>
|
|
</rss>`
|