// 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"}) } 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)), testAlert("cancel", "Cancel", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)), testAlert("future", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), 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].Expires = testTime(4) *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].Expires == nil || !original.Alerts[0].Expires.Equal(testTime(13)) { 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) != 8 { t.Fatalf("expected original alert slice unchanged, got %d entries", len(original.Alerts)) } } 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 TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) { containsLocation := true repo := &fakeRepository{outlookRun: testOutlookRun()} svc := NewService(repo) run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ContainsLocation: &containsLocation}) if err != nil { t.Fatalf("unexpected error: %v", err) } assertOutlookIDs(t, run, []string{"cat-1", "tor-1", "day-2"}) assertDiscussionDays(t, run, []int{1, 2}) } func TestServiceLatestConvectiveOutlookContainsLocationFalseReturnsEmptyRun(t *testing.T) { containsLocation := false repo := &fakeRepository{outlookRun: testOutlookRun()} svc := NewService(repo) run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ContainsLocation: &containsLocation}) if err != nil { t.Fatalf("unexpected error: %v", err) } assertOutlookIDs(t, run, nil) assertDiscussionDays(t, run, nil) } 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 containsLocation := true 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", ContainsLocation: &containsLocation, 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)), testAlert("expired", "Update", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12)), testAlert("future-effective", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(15)), testAlert("canceled", " cancel ", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)), testAlert("effective-at-boundary", "Alert", testTimePtr(9), testTimePtr(12), testTimePtr(12), testTimePtr(14)), testAlert("missing-effective", "Alert", testTimePtr(9), nil, nil, testTimePtr(14)), testAlert("missing-expires", "Alert", testTimePtr(9), testTimePtr(10), nil, nil), testAlert("later-onset", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(13), 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, 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, 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) } } }