package nws import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "gitea.maximumdirect.net/ejr/feedkit/config" "gitea.maximumdirect.net/ejr/feedkit/event" "gitea.maximumdirect.net/ejr/weatherfeeder/standards" ) type forecastPoller interface { Poll(ctx context.Context) ([]event.Event, error) } func TestForecastSourcesEmitExpectedSchemaAndPreferGeneratedAt(t *testing.T) { tests := []struct { name string driver string wantSchema string newSource func(config.SourceConfig) (forecastPoller, error) }{ { name: "hourly", driver: "nws_forecast_hourly", wantSchema: standards.SchemaRawNWSHourlyForecastV1, newSource: func(cfg config.SourceConfig) (forecastPoller, error) { return NewHourlyForecastSource(cfg) }, }, { name: "narrative", driver: "nws_forecast_narrative", wantSchema: standards.SchemaRawNWSNarrativeForecastV1, newSource: func(cfg config.SourceConfig) (forecastPoller, error) { return NewNarrativeForecastSource(cfg) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"properties":{"generatedAt":"2026-03-28T12:00:00Z","updateTime":"2026-03-28T11:00:00Z"}}`)) })) defer srv.Close() src, err := tt.newSource(forecastSourceConfig(tt.driver, srv.URL)) if err != nil { t.Fatalf("newSource() error = %v", err) } if ks, ok := src.(interface{ Kinds() []event.Kind }); !ok { t.Fatalf("source does not implement Kinds()") } else if gotKinds := ks.Kinds(); len(gotKinds) != 1 || gotKinds[0] != event.Kind("forecast") { t.Fatalf("Kinds() = %#v, want [forecast]", gotKinds) } got, err := src.Poll(context.Background()) if err != nil { t.Fatalf("Poll() error = %v", err) } if len(got) != 1 { t.Fatalf("Poll() len = %d, want 1", len(got)) } if got[0].Schema != tt.wantSchema { t.Fatalf("Poll() schema = %q, want %q", got[0].Schema, tt.wantSchema) } if got[0].Kind != event.Kind("forecast") { t.Fatalf("Poll() kind = %q, want forecast", got[0].Kind) } wantEffectiveAt := time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC) if got[0].EffectiveAt == nil || !got[0].EffectiveAt.Equal(wantEffectiveAt) { t.Fatalf("Poll() effectiveAt = %v, want %s", got[0].EffectiveAt, wantEffectiveAt) } }) } } func TestForecastSourcePollEffectiveAtFallbackOrder(t *testing.T) { tests := []struct { name string body string wantEffectiveAt *time.Time }{ { name: "updateTime fallback", body: `{"properties":{"updateTime":"2026-03-28T11:00:00Z"}}`, wantEffectiveAt: func() *time.Time { t := time.Date(2026, 3, 28, 11, 0, 0, 0, time.UTC) return &t }(), }, { name: "updated fallback", body: `{"properties":{"updated":"2026-03-28T10:00:00Z"}}`, wantEffectiveAt: func() *time.Time { t := time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC) return &t }(), }, { name: "omitted when metadata lacks timestamps", body: `{"properties":{}}`, wantEffectiveAt: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(tt.body)) })) defer srv.Close() src, err := NewHourlyForecastSource(forecastSourceConfig("nws_forecast_hourly", srv.URL)) if err != nil { t.Fatalf("NewHourlyForecastSource() error = %v", err) } got, err := src.Poll(context.Background()) if err != nil { t.Fatalf("Poll() error = %v", err) } if len(got) != 1 { t.Fatalf("Poll() len = %d, want 1", len(got)) } if tt.wantEffectiveAt == nil { if got[0].EffectiveAt != nil { t.Fatalf("Poll() effectiveAt = %v, want nil", got[0].EffectiveAt) } return } if got[0].EffectiveAt == nil || !got[0].EffectiveAt.Equal(*tt.wantEffectiveAt) { t.Fatalf("Poll() effectiveAt = %v, want %s", got[0].EffectiveAt, *tt.wantEffectiveAt) } }) } } func TestForecastSourcePollMetadataDecodeFailureStillEmitsRawEvent(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`not-json`)) })) defer srv.Close() src, err := NewNarrativeForecastSource(forecastSourceConfig("nws_forecast_narrative", srv.URL)) if err != nil { t.Fatalf("NewNarrativeForecastSource() error = %v", err) } got, err := src.Poll(context.Background()) if err != nil { t.Fatalf("Poll() error = %v", err) } if len(got) != 1 { t.Fatalf("Poll() len = %d, want 1", len(got)) } if got[0].EffectiveAt != nil { t.Fatalf("Poll() effectiveAt = %v, want nil", got[0].EffectiveAt) } if got[0].Schema != standards.SchemaRawNWSNarrativeForecastV1 { t.Fatalf("Poll() schema = %q, want %q", got[0].Schema, standards.SchemaRawNWSNarrativeForecastV1) } raw, ok := got[0].Payload.(json.RawMessage) if !ok { t.Fatalf("Poll() payload type = %T, want json.RawMessage", got[0].Payload) } if string(raw) != "not-json" { t.Fatalf("Poll() payload = %q, want %q", string(raw), "not-json") } } func forecastSourceConfig(driver, url string) config.SourceConfig { return config.SourceConfig{ Name: "test-forecast-source", Driver: driver, Mode: config.SourceModePoll, Params: map[string]any{ "url": url, "user_agent": "test-agent", }, } }