All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
701 lines
23 KiB
Go
701 lines
23 KiB
Go
// service_test.go validates application service delegation behavior.
|
|
// Layer: internal/app tests for read use-case orchestration.
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
|
)
|
|
|
|
type fakeRepository struct {
|
|
observation *model.WeatherObservation
|
|
forecast *model.WeatherForecastRun
|
|
narrative *model.WeatherForecastRun
|
|
discussion *model.WeatherForecastDiscussion
|
|
storyRun *model.WeatherStoryRun
|
|
story *model.WeatherStory
|
|
alerts *model.WeatherAlertRun
|
|
outlookRun *model.WeatherOutlookRun
|
|
conditions *CurrentConditions
|
|
err error
|
|
|
|
currentConditionsWindow int
|
|
alertRunCalls int
|
|
outlookRunCalls int
|
|
}
|
|
|
|
func (r *fakeRepository) LatestObservation(context.Context) (*model.WeatherObservation, error) {
|
|
return r.observation, r.err
|
|
}
|
|
|
|
func (r *fakeRepository) LatestHourlyForecast(context.Context) (*model.WeatherForecastRun, error) {
|
|
return r.forecast, r.err
|
|
}
|
|
|
|
func (r *fakeRepository) LatestNarrativeForecast(context.Context) (*model.WeatherForecastRun, error) {
|
|
return r.narrative, r.err
|
|
}
|
|
|
|
func (r *fakeRepository) LatestForecastDiscussion(context.Context) (*model.WeatherForecastDiscussion, error) {
|
|
return r.discussion, r.err
|
|
}
|
|
|
|
func (r *fakeRepository) LatestWeatherStoryRun(context.Context) (*model.WeatherStoryRun, error) {
|
|
return r.storyRun, r.err
|
|
}
|
|
|
|
func (r *fakeRepository) LatestWeatherStory(context.Context) (*model.WeatherStory, error) {
|
|
return r.story, r.err
|
|
}
|
|
|
|
func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
|
|
r.alertRunCalls++
|
|
return r.alerts, r.err
|
|
}
|
|
|
|
func (r *fakeRepository) LatestConvectiveOutlookRun(context.Context) (*model.WeatherOutlookRun, error) {
|
|
r.outlookRunCalls++
|
|
return r.outlookRun, r.err
|
|
}
|
|
|
|
func (r *fakeRepository) CurrentConditions(_ context.Context, observationWindowMinutes int) (*CurrentConditions, error) {
|
|
r.currentConditionsWindow = observationWindowMinutes
|
|
return r.conditions, r.err
|
|
}
|
|
|
|
func TestServiceDelegatesObservation(t *testing.T) {
|
|
repo := &fakeRepository{observation: &model.WeatherObservation{StationID: "KSTL"}}
|
|
svc := NewService(repo)
|
|
|
|
obs, err := svc.LatestObservation(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if obs == nil || obs.StationID != "KSTL" {
|
|
t.Fatalf("unexpected observation: %+v", obs)
|
|
}
|
|
}
|
|
|
|
func TestServiceDelegatesForecast(t *testing.T) {
|
|
repo := &fakeRepository{forecast: &model.WeatherForecastRun{LocationID: "stl"}}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestHourlyForecast(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if run == nil || run.LocationID != "stl" {
|
|
t.Fatalf("unexpected forecast: %+v", run)
|
|
}
|
|
}
|
|
|
|
func TestServiceDelegatesNarrativeForecast(t *testing.T) {
|
|
repo := &fakeRepository{narrative: &model.WeatherForecastRun{LocationID: "stl-narrative"}}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestNarrativeForecast(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if run == nil || run.LocationID != "stl-narrative" {
|
|
t.Fatalf("unexpected forecast: %+v", run)
|
|
}
|
|
}
|
|
|
|
func TestServiceDelegatesAlerts(t *testing.T) {
|
|
repo := &fakeRepository{alerts: &model.WeatherAlertRun{LocationID: "stl"}}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestAlertRun(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if run == nil || run.LocationID != "stl" {
|
|
t.Fatalf("unexpected alert run: %+v", run)
|
|
}
|
|
}
|
|
|
|
func TestServiceLatestActiveAlertRunDelegatesAndFilters(t *testing.T) {
|
|
activeAt := testTime(12)
|
|
repo := &fakeRepository{alerts: testAlertRun()}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if repo.alertRunCalls != 1 {
|
|
t.Fatalf("expected one repository call, got %d", repo.alertRunCalls)
|
|
}
|
|
assertAlertIDs(t, run, []string{"current", "effective-at-boundary", "missing-effective", "missing-expires", "later-onset", "ends-preferred"})
|
|
}
|
|
|
|
func TestServiceLatestActiveAlertRunNoData(t *testing.T) {
|
|
repo := &fakeRepository{}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if run != nil {
|
|
t.Fatalf("expected nil alert run, got %+v", run)
|
|
}
|
|
}
|
|
|
|
func TestServiceLatestActiveAlertRunPropagatesErrors(t *testing.T) {
|
|
want := errors.New("alert read failed")
|
|
repo := &fakeRepository{err: want}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
|
|
if !errors.Is(err, want) {
|
|
t.Fatalf("expected error %v, got %v", want, err)
|
|
}
|
|
if run != nil {
|
|
t.Fatalf("expected nil alert run on error, got %+v", run)
|
|
}
|
|
}
|
|
|
|
func TestServiceLatestActiveAlertRunKeepsMetadataWithEmptyAlerts(t *testing.T) {
|
|
activeAt := testTime(12)
|
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{
|
|
testAlert("expired", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12), testTimePtr(13)),
|
|
testAlert("cancel", "Cancel", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(13)),
|
|
testAlert("future", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(14), testTimePtr(14)),
|
|
})}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if run == nil {
|
|
t.Fatal("expected alert run")
|
|
}
|
|
if run.LocationID != "stl" || run.LocationName != "St. Louis" || !run.AsOf.Equal(testTime(10)) {
|
|
t.Fatalf("unexpected run metadata: %+v", run)
|
|
}
|
|
if run.Latitude == nil || *run.Latitude != 38.62 {
|
|
t.Fatalf("unexpected latitude: %v", run.Latitude)
|
|
}
|
|
if run.Longitude == nil || *run.Longitude != -90.2 {
|
|
t.Fatalf("unexpected longitude: %v", run.Longitude)
|
|
}
|
|
if run.Alerts == nil {
|
|
t.Fatal("expected empty alert slice, got nil")
|
|
}
|
|
if len(run.Alerts) != 0 {
|
|
t.Fatalf("expected no alerts, got %+v", run.Alerts)
|
|
}
|
|
}
|
|
|
|
func TestServiceLatestActiveAlertRunDoesNotMutateRepositoryRun(t *testing.T) {
|
|
original := testAlertRun()
|
|
repo := &fakeRepository{alerts: original}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(run.Alerts) == 0 {
|
|
t.Fatal("expected active alerts")
|
|
}
|
|
|
|
*run.Latitude = 99
|
|
*run.Longitude = -99
|
|
*run.Alerts[0].Sent = testTime(1)
|
|
*run.Alerts[0].Effective = testTime(2)
|
|
*run.Alerts[0].Onset = testTime(3)
|
|
*run.Alerts[0].Ends = testTime(4)
|
|
*run.Alerts[0].Expires = testTime(5)
|
|
*run.Alerts[0].References[0].Sent = testTime(5)
|
|
run.Alerts[0].ID = "changed"
|
|
run.Alerts[0].References[0].ID = "changed"
|
|
run.Alerts = run.Alerts[:1]
|
|
|
|
if *original.Latitude != 38.62 {
|
|
t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude)
|
|
}
|
|
if *original.Longitude != -90.2 {
|
|
t.Fatalf("expected original longitude unchanged, got %v", *original.Longitude)
|
|
}
|
|
if original.Alerts[0].ID != "current" {
|
|
t.Fatalf("expected original alert ID unchanged, got %q", original.Alerts[0].ID)
|
|
}
|
|
if original.Alerts[0].Sent == nil || !original.Alerts[0].Sent.Equal(testTime(9)) {
|
|
t.Fatalf("expected original sent unchanged, got %v", original.Alerts[0].Sent)
|
|
}
|
|
if original.Alerts[0].Effective == nil || !original.Alerts[0].Effective.Equal(testTime(10)) {
|
|
t.Fatalf("expected original effective unchanged, got %v", original.Alerts[0].Effective)
|
|
}
|
|
if original.Alerts[0].Onset == nil || !original.Alerts[0].Onset.Equal(testTime(11)) {
|
|
t.Fatalf("expected original onset unchanged, got %v", original.Alerts[0].Onset)
|
|
}
|
|
if original.Alerts[0].Ends == nil || !original.Alerts[0].Ends.Equal(testTime(13)) {
|
|
t.Fatalf("expected original ends unchanged, got %v", original.Alerts[0].Ends)
|
|
}
|
|
if original.Alerts[0].Expires == nil || !original.Alerts[0].Expires.Equal(testTime(12)) {
|
|
t.Fatalf("expected original expires unchanged, got %v", original.Alerts[0].Expires)
|
|
}
|
|
if original.Alerts[0].References[0].ID != "ref-current" {
|
|
t.Fatalf("expected original reference ID unchanged, got %q", original.Alerts[0].References[0].ID)
|
|
}
|
|
if original.Alerts[0].References[0].Sent == nil || !original.Alerts[0].References[0].Sent.Equal(testTime(8)) {
|
|
t.Fatalf("expected original reference sent unchanged, got %v", original.Alerts[0].References[0].Sent)
|
|
}
|
|
if len(original.Alerts) != 10 {
|
|
t.Fatalf("expected original alert slice unchanged, got %d entries", len(original.Alerts))
|
|
}
|
|
}
|
|
|
|
func TestServiceLatestActiveAlertRunUsesEndsBeforeExpires(t *testing.T) {
|
|
activeAt := testTime(12)
|
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{
|
|
testAlert("ends-at-boundary", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12), testTimePtr(13)),
|
|
testAlert("ends-after-active-expires-before", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(11)),
|
|
testAlert("expires-fallback", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), nil, testTimePtr(13)),
|
|
testAlert("expires-fallback-expired", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), nil, testTimePtr(12)),
|
|
})}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
assertAlertIDs(t, run, []string{"ends-after-active-expires-before", "expires-fallback"})
|
|
}
|
|
|
|
func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
|
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if repo.outlookRunCalls != 1 {
|
|
t.Fatalf("expected one repository call, got %d", repo.outlookRunCalls)
|
|
}
|
|
if run == nil || run.LocationID != "stl" {
|
|
t.Fatalf("unexpected outlook run: %+v", run)
|
|
}
|
|
assertDiscussionDays(t, run, []int{1, 2})
|
|
}
|
|
|
|
func TestServiceLatestConvectiveOutlookNoData(t *testing.T) {
|
|
repo := &fakeRepository{}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if run != nil {
|
|
t.Fatalf("expected nil outlook run, got %+v", run)
|
|
}
|
|
}
|
|
|
|
func TestServiceDelegatesForecastDiscussion(t *testing.T) {
|
|
repo := &fakeRepository{discussion: &model.WeatherForecastDiscussion{OfficeID: "LSX"}}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestForecastDiscussion(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if run == nil || run.OfficeID != "LSX" {
|
|
t.Fatalf("unexpected forecast discussion: %+v", run)
|
|
}
|
|
}
|
|
|
|
func TestServiceDelegatesWeatherStoryRun(t *testing.T) {
|
|
repo := &fakeRepository{storyRun: &model.WeatherStoryRun{OfficeID: "LSX"}}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestWeatherStoryRun(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if run == nil || run.OfficeID != "LSX" {
|
|
t.Fatalf("unexpected weather story run: %+v", run)
|
|
}
|
|
}
|
|
|
|
func TestServiceDelegatesWeatherStory(t *testing.T) {
|
|
repo := &fakeRepository{story: &model.WeatherStory{OfficeID: "LSX", Title: "Rain chances"}}
|
|
svc := NewService(repo)
|
|
|
|
story, err := svc.LatestWeatherStory(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if story == nil || story.Title != "Rain chances" {
|
|
t.Fatalf("unexpected weather story: %+v", story)
|
|
}
|
|
}
|
|
|
|
func TestServiceUsesDefaultCurrentConditionsWindow(t *testing.T) {
|
|
repo := &fakeRepository{conditions: &CurrentConditions{ConditionCode: model.WMOUnknown}}
|
|
svc := NewService(repo)
|
|
|
|
_, err := svc.CurrentConditions(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if repo.currentConditionsWindow != ObservationWindowMinutesDefault {
|
|
t.Fatalf("expected observation window %d, got %d", ObservationWindowMinutesDefault, repo.currentConditionsWindow)
|
|
}
|
|
}
|
|
|
|
func TestServiceLatestConvectiveOutlookFiltersByDay(t *testing.T) {
|
|
day := 2
|
|
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{Day: &day})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
assertOutlookIDs(t, run, []string{"day-2"})
|
|
assertDiscussionDays(t, run, []int{2})
|
|
}
|
|
|
|
func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
|
|
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{OutlookType: " Tornado "})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
assertOutlookIDs(t, run, []string{"tor-1"})
|
|
assertDiscussionDays(t, run, []int{1})
|
|
}
|
|
|
|
func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
|
|
activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC)
|
|
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &activeAt})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
assertOutlookIDs(t, run, []string{"cat-1", "tor-1"})
|
|
assertDiscussionDays(t, run, []int{1})
|
|
}
|
|
|
|
func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
|
|
day := 1
|
|
activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC)
|
|
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{
|
|
Day: &day,
|
|
OutlookType: "categorical",
|
|
ActiveAt: &activeAt,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
assertOutlookIDs(t, run, []string{"cat-1"})
|
|
assertDiscussionDays(t, run, []int{1})
|
|
}
|
|
|
|
func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) {
|
|
run := testOutlookRun()
|
|
run.Outlooks = run.Outlooks[:1]
|
|
validFrom := run.Outlooks[0].ValidFrom
|
|
validTo := run.Outlooks[0].ValidTo
|
|
repo := &fakeRepository{outlookRun: run}
|
|
svc := NewService(repo)
|
|
|
|
fromRun, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &validFrom})
|
|
if err != nil {
|
|
t.Fatalf("unexpected validFrom error: %v", err)
|
|
}
|
|
assertOutlookIDs(t, fromRun, []string{"cat-1"})
|
|
assertDiscussionDays(t, fromRun, []int{1})
|
|
|
|
toRun, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &validTo})
|
|
if err != nil {
|
|
t.Fatalf("unexpected validTo error: %v", err)
|
|
}
|
|
assertOutlookIDs(t, toRun, nil)
|
|
assertDiscussionDays(t, toRun, nil)
|
|
}
|
|
|
|
func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *testing.T) {
|
|
day := 3
|
|
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{Day: &day})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if run == nil {
|
|
t.Fatal("expected outlook run")
|
|
}
|
|
if run.LocationID != "stl" || run.LocationName != "St. Louis" || !run.AsOf.Equal(testTime(12)) {
|
|
t.Fatalf("unexpected run metadata: %+v", run)
|
|
}
|
|
if run.Outlooks == nil {
|
|
t.Fatal("expected empty outlook slice, got nil")
|
|
}
|
|
if len(run.Outlooks) != 0 {
|
|
t.Fatalf("expected no outlooks, got %+v", run.Outlooks)
|
|
}
|
|
if run.Discussions == nil {
|
|
t.Fatal("expected empty discussions slice, got nil")
|
|
}
|
|
if len(run.Discussions) != 0 {
|
|
t.Fatalf("expected no discussions, got %+v", run.Discussions)
|
|
}
|
|
}
|
|
|
|
func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T) {
|
|
original := testOutlookRun()
|
|
repo := &fakeRepository{outlookRun: original}
|
|
svc := NewService(repo)
|
|
|
|
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(run.Outlooks) == 0 {
|
|
t.Fatal("expected outlooks")
|
|
}
|
|
|
|
*run.Latitude = 99
|
|
*run.Longitude = -99
|
|
*run.IssuedAt = testTime(99)
|
|
*run.Outlooks[0].SeverityRank = 99
|
|
*run.Discussions[0].UpdatedAt = testTime(98)
|
|
run.Outlooks[0].Geometry[0] = '{'
|
|
run.Outlooks[0].ID = "changed"
|
|
run.Discussions[0].Headline = "changed"
|
|
run.Outlooks = run.Outlooks[:1]
|
|
run.Discussions = run.Discussions[:1]
|
|
|
|
if *original.Latitude != 38.62 {
|
|
t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude)
|
|
}
|
|
if *original.Longitude != -90.2 {
|
|
t.Fatalf("expected original longitude unchanged, got %v", *original.Longitude)
|
|
}
|
|
if !original.IssuedAt.Equal(testTime(11)) {
|
|
t.Fatalf("expected original issuedAt unchanged, got %v", original.IssuedAt)
|
|
}
|
|
if *original.Outlooks[0].SeverityRank != 5 {
|
|
t.Fatalf("expected original severity rank unchanged, got %v", *original.Outlooks[0].SeverityRank)
|
|
}
|
|
if string(original.Outlooks[0].Geometry) != `["cat"]` {
|
|
t.Fatalf("expected original geometry unchanged, got %s", original.Outlooks[0].Geometry)
|
|
}
|
|
if original.Outlooks[0].ID != "cat-1" {
|
|
t.Fatalf("expected original outlook ID unchanged, got %q", original.Outlooks[0].ID)
|
|
}
|
|
if len(original.Outlooks) != 3 {
|
|
t.Fatalf("expected original outlook slice unchanged, got %d entries", len(original.Outlooks))
|
|
}
|
|
if original.Discussions[0].UpdatedAt == nil || !original.Discussions[0].UpdatedAt.Equal(testTime(10)) {
|
|
t.Fatalf("expected original discussion updatedAt unchanged, got %v", original.Discussions[0].UpdatedAt)
|
|
}
|
|
if original.Discussions[0].Headline != "Day 1 headline" {
|
|
t.Fatalf("expected original discussion headline unchanged, got %q", original.Discussions[0].Headline)
|
|
}
|
|
if len(original.Discussions) != 3 {
|
|
t.Fatalf("expected original discussion slice unchanged, got %d entries", len(original.Discussions))
|
|
}
|
|
}
|
|
|
|
func TestServicePropagatesErrors(t *testing.T) {
|
|
want := errors.New("boom")
|
|
repo := &fakeRepository{err: want}
|
|
svc := NewService(repo)
|
|
|
|
if _, err := svc.LatestObservation(context.Background()); !errors.Is(err, want) {
|
|
t.Fatalf("expected error %v, got %v", want, err)
|
|
}
|
|
}
|
|
|
|
func testAlertRun() *model.WeatherAlertRun {
|
|
return testAlertRunWithAlerts([]model.WeatherAlert{
|
|
testAlert("current", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(12)),
|
|
testAlert("expired", "Update", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12), testTimePtr(13)),
|
|
testAlert("future-effective", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(15), testTimePtr(15)),
|
|
testAlert("canceled", " cancel ", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(13)),
|
|
testAlert("effective-at-boundary", "Alert", testTimePtr(9), testTimePtr(12), testTimePtr(12), testTimePtr(14), testTimePtr(14)),
|
|
testAlert("missing-effective", "Alert", testTimePtr(9), nil, nil, testTimePtr(14), testTimePtr(14)),
|
|
testAlert("missing-expires", "Alert", testTimePtr(9), testTimePtr(10), nil, nil, nil),
|
|
testAlert("later-onset", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(13), testTimePtr(14), testTimePtr(14)),
|
|
testAlert("ends-preferred", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(14), testTimePtr(11)),
|
|
testAlert("ends-at-boundary", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(12), testTimePtr(14)),
|
|
})
|
|
}
|
|
|
|
func testAlertRunWithAlerts(alerts []model.WeatherAlert) *model.WeatherAlertRun {
|
|
latitude := 38.62
|
|
longitude := -90.2
|
|
return &model.WeatherAlertRun{
|
|
LocationID: "stl",
|
|
LocationName: "St. Louis",
|
|
AsOf: testTime(10),
|
|
Latitude: &latitude,
|
|
Longitude: &longitude,
|
|
Alerts: alerts,
|
|
}
|
|
}
|
|
|
|
func testAlert(id string, messageType string, sent *time.Time, effective *time.Time, onset *time.Time, ends *time.Time, expires *time.Time) model.WeatherAlert {
|
|
refSent := testTime(8)
|
|
return model.WeatherAlert{
|
|
ID: id,
|
|
Event: "Thunderstorm Warning",
|
|
Headline: "Storm headline",
|
|
Severity: "Severe",
|
|
Urgency: "Immediate",
|
|
Certainty: "Likely",
|
|
Status: "Actual",
|
|
MessageType: messageType,
|
|
Category: "Met",
|
|
Response: "Shelter",
|
|
Description: "Storm description",
|
|
Instruction: "Take shelter",
|
|
Sent: sent,
|
|
Effective: effective,
|
|
Onset: onset,
|
|
Ends: ends,
|
|
Expires: expires,
|
|
AreaDescription: "St. Louis City",
|
|
SenderName: "NWS St. Louis",
|
|
References: []model.AlertReference{{
|
|
ID: "ref-" + id,
|
|
Identifier: "identifier-" + id,
|
|
Sender: "sender-" + id,
|
|
Sent: &refSent,
|
|
}},
|
|
}
|
|
}
|
|
|
|
func testTimePtr(hour int) *time.Time {
|
|
value := testTime(hour)
|
|
return &value
|
|
}
|
|
|
|
func testOutlookRun() *model.WeatherOutlookRun {
|
|
latitude := 38.62
|
|
longitude := -90.2
|
|
issuedAt := testTime(11)
|
|
return &model.WeatherOutlookRun{
|
|
LocationID: "stl",
|
|
LocationName: "St. Louis",
|
|
Latitude: &latitude,
|
|
Longitude: &longitude,
|
|
AsOf: testTime(12),
|
|
IssuedAt: &issuedAt,
|
|
Outlooks: []model.WeatherOutlook{
|
|
testOutlook("cat-1", 1, "categorical", true, testTime(12), testTime(18), 5, `["cat"]`),
|
|
testOutlook("tor-1", 1, "tornado", true, testTime(13), testTime(19), 7, `["tor"]`),
|
|
testOutlook("day-2", 2, "wind", true, testTime(18), testTime(24), 2, `["wind"]`),
|
|
},
|
|
Discussions: []model.WeatherOutlookDiscussion{
|
|
testOutlookDiscussion(1),
|
|
testOutlookDiscussion(2),
|
|
testOutlookDiscussion(3),
|
|
},
|
|
}
|
|
}
|
|
|
|
func testOutlookDiscussion(day int) model.WeatherOutlookDiscussion {
|
|
updatedAt := testTime(9 + day)
|
|
dayText := strconv.Itoa(day)
|
|
return model.WeatherOutlookDiscussion{
|
|
Day: day,
|
|
Headline: "Day " + dayText + " headline",
|
|
Summary: "Day " + dayText + " summary",
|
|
Discussion: "Day " + dayText + " discussion",
|
|
UpdatedAt: &updatedAt,
|
|
}
|
|
}
|
|
|
|
func testOutlook(id string, day int, outlookType string, containsLocation bool, validFrom time.Time, validTo time.Time, severityRank int, geometry string) model.WeatherOutlook {
|
|
return model.WeatherOutlook{
|
|
ID: id,
|
|
Provider: "spc",
|
|
Product: "convective",
|
|
Day: day,
|
|
OutlookType: outlookType,
|
|
Label: "SLGT",
|
|
LabelText: "Slight Risk",
|
|
SeverityRank: &severityRank,
|
|
ValidFrom: validFrom,
|
|
ValidTo: validTo,
|
|
IssuedAt: validFrom.Add(-time.Hour),
|
|
ExpiresAt: validTo,
|
|
Forecaster: "DIAL",
|
|
SourceURL: "https://example.test/" + id,
|
|
ImageURL: "https://example.test/" + id + ".png",
|
|
ContainsLocation: containsLocation,
|
|
Geometry: []byte(geometry),
|
|
}
|
|
}
|
|
|
|
func testTime(hour int) time.Time {
|
|
return time.Date(2026, 6, 11, hour, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
func assertOutlookIDs(t *testing.T, run *model.WeatherOutlookRun, want []string) {
|
|
t.Helper()
|
|
if run == nil {
|
|
t.Fatal("expected outlook run")
|
|
}
|
|
if len(run.Outlooks) != len(want) {
|
|
t.Fatalf("expected outlook IDs %v, got %+v", want, run.Outlooks)
|
|
}
|
|
for i := range want {
|
|
if run.Outlooks[i].ID != want[i] {
|
|
t.Fatalf("expected outlook IDs %v, got %+v", want, run.Outlooks)
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertDiscussionDays(t *testing.T, run *model.WeatherOutlookRun, want []int) {
|
|
t.Helper()
|
|
if run == nil {
|
|
t.Fatal("expected outlook run")
|
|
}
|
|
if len(run.Discussions) != len(want) {
|
|
t.Fatalf("expected discussion days %v, got %+v", want, run.Discussions)
|
|
}
|
|
for i := range want {
|
|
if run.Discussions[i].Day != want[i] {
|
|
t.Fatalf("expected discussion days %v, got %+v", want, run.Discussions)
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertAlertIDs(t *testing.T, run *model.WeatherAlertRun, want []string) {
|
|
t.Helper()
|
|
if run == nil {
|
|
t.Fatal("expected alert run")
|
|
}
|
|
if len(run.Alerts) != len(want) {
|
|
t.Fatalf("expected alert IDs %v, got %+v", want, run.Alerts)
|
|
}
|
|
for i := range want {
|
|
if run.Alerts[i].ID != want[i] {
|
|
t.Fatalf("expected alert IDs %v, got %+v", want, run.Alerts)
|
|
}
|
|
}
|
|
}
|