Refactored the application structure to better separate concerns between files and packages
Some checks failed
ci/woodpecker/push/build-image Pipeline failed

This commit is contained in:
2026-03-20 09:44:53 -05:00
parent 8deb4fd12e
commit bb18bcfb15
41 changed files with 1445 additions and 1086 deletions

View File

@@ -0,0 +1,7 @@
// constants.go defines shared application-level constants.
// Layer: internal/app service behavior defaults.
package app
const (
ObservationWindowMinutesDefault = 30
)

View File

@@ -0,0 +1,17 @@
// current_conditions.go defines the current-conditions aggregate model.
// Layer: internal/app domain-adjacent read model.
package app
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
// CurrentConditions is an averaged current-conditions aggregate over a recent window.
type CurrentConditions struct {
TemperatureC *float64
ApparentTemperatureC *float64
DewpointC *float64
RelativeHumidityPercent *float64
WindSpeedKmh *float64
WindDirectionDegrees *float64
ConditionCode model.WMOCode
IsDay *bool
}

42
internal/app/service.go Normal file
View File

@@ -0,0 +1,42 @@
// service.go defines application read ports and use-case orchestration.
// Layer: internal/app core business-facing API.
package app
import (
"context"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
// Repository defines outbound data access used by weatherapi use cases.
type Repository interface {
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error)
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, error)
}
// Service provides weather read use-cases.
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) LatestObservation(ctx context.Context) (*model.WeatherObservation, error) {
return s.repo.LatestObservation(ctx)
}
func (s *Service) LatestHourlyForecast(ctx context.Context) (*model.WeatherForecastRun, error) {
return s.repo.LatestHourlyForecast(ctx)
}
func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) {
return s.repo.LatestAlertRun(ctx)
}
func (s *Service) CurrentConditions(ctx context.Context) (*CurrentConditions, error) {
return s.repo.CurrentConditions(ctx, ObservationWindowMinutesDefault)
}

View File

@@ -0,0 +1,100 @@
// service_test.go validates application service delegation behavior.
// Layer: internal/app tests for read use-case orchestration.
package app
import (
"context"
"errors"
"testing"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
type fakeRepository struct {
observation *model.WeatherObservation
forecast *model.WeatherForecastRun
alerts *model.WeatherAlertRun
conditions *CurrentConditions
err error
currentConditionsWindow 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) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
return r.alerts, 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 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 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 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)
}
}