diff --git a/Makefile b/Makefile index 50adc9a..f351262 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,7 @@ validate: fix test .PHONY: generate generate: make -C api generate + go generate ./... .PHONY: cover cover: diff --git a/go.mod b/go.mod index 33523d2..a6f7fa5 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/oapi-codegen/runtime v1.1.2 github.com/oklog/ulid/v2 v2.1.1 github.com/stretchr/testify v1.11.1 + go.uber.org/mock v0.6.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 resty.dev/v3 v3.0.0-beta.4 @@ -32,10 +33,14 @@ require ( github.com/nats-io/nuid v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/tools v0.39.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +tool go.uber.org/mock/mockgen diff --git a/go.sum b/go.sum index 481250f..08e7755 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-co-op/gocron/v2 v2.18.2 h1:+5VU41FUXPWSPKLXZQ/77SGzUiPCcakU0v7ENc2H20Q= github.com/go-co-op/gocron/v2 v2.18.2/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -61,16 +63,22 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/app/flow/flow.go b/internal/app/flow/flow.go index 0e10f62..4d48ea9 100644 --- a/internal/app/flow/flow.go +++ b/internal/app/flow/flow.go @@ -90,12 +90,41 @@ func (*Flow) mergeWeathers(dateKey string, location *time.Location, weathers []* date, _ := time.Parse("2006-01-02", dateKey) date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location) - high := float64(weathers[0].Temperature().Value) - low := float64(weathers[0].Temperature().Value) - worstCondition := weathers[0].Condition() + // Source와 TargetDate가 동일한 경우, ForecastDate가 최신 항목만 선택 + filteredWeathers := make(map[string]*domain.Weather) - for _, w := range weathers { - temp := float64(w.Temperature().Value) + for _, weather := range weathers { + key := string(weather.Source()) + weather.TargetDate().Format("2006-01-02") + + existing, ok := filteredWeathers[key] + if !ok { + filteredWeathers[key] = weather + + continue + } + + if weather.ForecastDate().Before(existing.ForecastDate()) { + continue + } + + filteredWeathers[key] = weather + } + + var filteredSlice []*domain.Weather + for _, weather := range filteredWeathers { + filteredSlice = append(filteredSlice, weather) + } + + if len(filteredSlice) == 0 { + panic("no weather found") + } + + high := float64(filteredSlice[0].Temperature().Value) + low := float64(filteredSlice[0].Temperature().Value) + worstCondition := filteredSlice[0].Condition() + + for _, weather := range filteredSlice { + temp := float64(weather.Temperature().Value) if temp > high { high = temp } @@ -104,19 +133,17 @@ func (*Flow) mergeWeathers(dateKey string, location *time.Location, weathers []* low = temp } - if w.Condition().IsWorseThan(worstCondition) { - worstCondition = w.Condition() + if weather.Condition().IsWorseThan(worstCondition) { + worstCondition = weather.Condition() } } - newVar := &DailyWeather{ + return &DailyWeather{ Date: date, High: high, Low: low, Condition: string(worstCondition), } - - return newVar } func (*Flow) groupByDate(weathers []*domain.Weather, location *time.Location) map[string][]*domain.Weather { diff --git a/internal/app/flow/flow_test.go b/internal/app/flow/flow_test.go new file mode 100644 index 0000000..4e52f2f --- /dev/null +++ b/internal/app/flow/flow_test.go @@ -0,0 +1,111 @@ +//nolint:dupl +package flow_test + +import ( + "context" + "testing" + "time" + + "github.com/neatflowcv/seven-skies/internal/app/flow" + "github.com/neatflowcv/seven-skies/internal/pkg/domain" + "github.com/neatflowcv/seven-skies/internal/pkg/repository/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestFlow_ListDailyWeathers(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mock := mocks.NewMockRepository(ctrl) + now := time.Now() + mock.EXPECT().ListWeathers(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*domain.Weather{ + domain.NewWeather( + "1", + domain.WeatherSourceOpenWeather, + now, + now.Add(time.Hour*24), + domain.WeatherConditionClear, + domain.Temperature{Value: domain.Celsius(20)}, + ), + }, nil) + flow := flow.NewFlow(mock) + + weathers, err := flow.ListDailyWeathers(context.Background(), "Asia/Seoul", now) + + require.NoError(t, err) + require.Len(t, weathers, 1) + require.InDelta(t, 20.0, weathers[0].High, 0.01) + require.InDelta(t, 20.0, weathers[0].Low, 0.01) + require.Equal(t, "CLEAR", weathers[0].Condition) +} + +func TestFlow_ListDailyWeathers_SameSourceAndTargetDate(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mock := mocks.NewMockRepository(ctrl) + now := time.Now() + mock.EXPECT().ListWeathers(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*domain.Weather{ + domain.NewWeather( + "1", + domain.WeatherSourceOpenWeather, + now, + now, + domain.WeatherConditionClear, + domain.Temperature{Value: domain.Celsius(20)}, + ), + domain.NewWeather( + "2", + domain.WeatherSourceOpenWeather, + now, + now.Add(time.Hour*24), + domain.WeatherConditionCloudy, + domain.Temperature{Value: domain.Celsius(30)}, + ), + }, nil) + flow := flow.NewFlow(mock) + + weathers, err := flow.ListDailyWeathers(context.Background(), "Asia/Seoul", time.Now()) + + require.NoError(t, err) + require.Len(t, weathers, 1) + require.InDelta(t, 30.0, weathers[0].High, 0.01) + require.InDelta(t, 30.0, weathers[0].Low, 0.01) + require.Equal(t, "CLOUDY", weathers[0].Condition) +} + +func TestFlow_ListDailyWeathers_DifferentSourceAndTargetDate(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mock := mocks.NewMockRepository(ctrl) + now := time.Now() + mock.EXPECT().ListWeathers(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*domain.Weather{ + domain.NewWeather( + "1", + domain.WeatherSourceOpenWeather, + now, + now, + domain.WeatherConditionClear, + domain.Temperature{Value: domain.Celsius(20)}, + ), + domain.NewWeather( + "2", + domain.WeatherSourceKMA, + now, + now.Add(time.Hour*24), + domain.WeatherConditionCloudy, + domain.Temperature{Value: domain.Celsius(30)}, + ), + }, nil) + flow := flow.NewFlow(mock) + + weathers, err := flow.ListDailyWeathers(context.Background(), "Asia/Seoul", time.Now()) + + require.NoError(t, err) + require.Len(t, weathers, 1) + require.InDelta(t, 30.0, weathers[0].High, 0.01) + require.InDelta(t, 20.0, weathers[0].Low, 0.01) + require.Equal(t, "CLOUDY", weathers[0].Condition) +} diff --git a/internal/pkg/repository/mocks/repository.go b/internal/pkg/repository/mocks/repository.go new file mode 100644 index 0000000..16f9072 --- /dev/null +++ b/internal/pkg/repository/mocks/repository.go @@ -0,0 +1,120 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/neatflowcv/seven-skies/internal/pkg/repository (interfaces: Repository) +// +// Generated by this command: +// +// mockgen -typed -package=mocks -destination=mocks/repository.go . Repository +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + time "time" + + domain "github.com/neatflowcv/seven-skies/internal/pkg/domain" + gomock "go.uber.org/mock/gomock" +) + +// MockRepository is a mock of Repository interface. +type MockRepository struct { + ctrl *gomock.Controller + recorder *MockRepositoryMockRecorder + isgomock struct{} +} + +// MockRepositoryMockRecorder is the mock recorder for MockRepository. +type MockRepositoryMockRecorder struct { + mock *MockRepository +} + +// NewMockRepository creates a new mock instance. +func NewMockRepository(ctrl *gomock.Controller) *MockRepository { + mock := &MockRepository{ctrl: ctrl} + mock.recorder = &MockRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { + return m.recorder +} + +// CreateWeather mocks base method. +func (m *MockRepository) CreateWeather(ctx context.Context, weather *domain.Weather) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateWeather", ctx, weather) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateWeather indicates an expected call of CreateWeather. +func (mr *MockRepositoryMockRecorder) CreateWeather(ctx, weather any) *MockRepositoryCreateWeatherCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateWeather", reflect.TypeOf((*MockRepository)(nil).CreateWeather), ctx, weather) + return &MockRepositoryCreateWeatherCall{Call: call} +} + +// MockRepositoryCreateWeatherCall wrap *gomock.Call +type MockRepositoryCreateWeatherCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockRepositoryCreateWeatherCall) Return(arg0 error) *MockRepositoryCreateWeatherCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockRepositoryCreateWeatherCall) Do(f func(context.Context, *domain.Weather) error) *MockRepositoryCreateWeatherCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockRepositoryCreateWeatherCall) DoAndReturn(f func(context.Context, *domain.Weather) error) *MockRepositoryCreateWeatherCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListWeathers mocks base method. +func (m *MockRepository) ListWeathers(ctx context.Context, from, to time.Time) ([]*domain.Weather, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListWeathers", ctx, from, to) + ret0, _ := ret[0].([]*domain.Weather) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListWeathers indicates an expected call of ListWeathers. +func (mr *MockRepositoryMockRecorder) ListWeathers(ctx, from, to any) *MockRepositoryListWeathersCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListWeathers", reflect.TypeOf((*MockRepository)(nil).ListWeathers), ctx, from, to) + return &MockRepositoryListWeathersCall{Call: call} +} + +// MockRepositoryListWeathersCall wrap *gomock.Call +type MockRepositoryListWeathersCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockRepositoryListWeathersCall) Return(arg0 []*domain.Weather, arg1 error) *MockRepositoryListWeathersCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockRepositoryListWeathersCall) Do(f func(context.Context, time.Time, time.Time) ([]*domain.Weather, error)) *MockRepositoryListWeathersCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockRepositoryListWeathersCall) DoAndReturn(f func(context.Context, time.Time, time.Time) ([]*domain.Weather, error)) *MockRepositoryListWeathersCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/pkg/repository/repository.go b/internal/pkg/repository/repository.go index af0cfbb..bc7409e 100644 --- a/internal/pkg/repository/repository.go +++ b/internal/pkg/repository/repository.go @@ -7,6 +7,8 @@ import ( "github.com/neatflowcv/seven-skies/internal/pkg/domain" ) +//go:generate mockgen -typed -package=mocks -destination=mocks/repository.go . Repository + type Repository interface { CreateWeather(ctx context.Context, weather *domain.Weather) error ListWeathers(ctx context.Context, from, to time.Time) ([]*domain.Weather, error)