Files
weatherapi/internal/app/service_test.go

424 lines
13 KiB
Go

// service_test.go validates application service delegation behavior.
// Layer: internal/app tests for read use-case orchestration.
package app
import (
"context"
"errors"
"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
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) {
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 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)
}
}
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"})
}
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"})
}
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"})
}
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"})
}
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"})
}
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"})
toRun, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &validTo})
if err != nil {
t.Fatalf("unexpected validTo error: %v", err)
}
assertOutlookIDs(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)
}
}
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.Outlooks[0].Geometry[0] = '{'
run.Outlooks[0].ID = "changed"
run.Outlooks = run.Outlooks[: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))
}
}
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 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", false, testTime(18), testTime(24), 2, `["wind"]`),
},
}
}
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)
}
}
}