Skip to content

Commit

Permalink
Setup server-side VCR testing for UI regression
Browse files Browse the repository at this point in the history
- Enable mocking time with `clock` package
- Enable mocking new ID creation
- Add VCR setup in middleware
- Fix duplicate metric registration
- Fix empty NotificationClientID for creating Garden
- Generate some tests
  • Loading branch information
calvinmclean committed Aug 18, 2024
1 parent f920597 commit 8a63e94
Show file tree
Hide file tree
Showing 47 changed files with 3,944 additions and 156 deletions.
11 changes: 11 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ tasks:
cmds:
- DEV_TEMPLATE=server/templates/* go run main.go serve --config config.yaml

run-vcr:
desc: Run backend Go app server with VCR enabled
aliases: [vcr]
dir: ./garden-app
vars:
vcr_base_dir: server/vcr/testdata/vcr_server
env:
VCR_CASSETTE: "{{ .vcr_base_dir }}/fixtures/{{ .CLI_ARGS }}"
cmds:
- go run main.go serve --config ./{{ .vcr_base_dir }}/config.yaml

run-controller:
desc: Run mock controller
aliases: [rc]
Expand Down
5 changes: 2 additions & 3 deletions garden-app/.gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
garden-app
config.yaml
plants.yaml
gardens.yaml
/config.yaml
/gardens.yaml
cover.out
coverage.out
integration_coverage.out
Expand Down
43 changes: 43 additions & 0 deletions garden-app/clock/clock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package clock

import (
"time"

"github.com/benbjohnson/clock"
"github.com/go-co-op/gocron"
)

// Clock allows mocking time
type Clock struct {
clock.Clock
}

// DefaultClock is the underlying Clock used and can be overridden to mock
var DefaultClock = Clock{clock.New()}

var _ gocron.TimeWrapper = Clock{}

func (c Clock) Now(loc *time.Location) time.Time {
return c.Clock.Now().In(loc)
}

func (c Clock) Unix(sec int64, nsec int64) time.Time {
return time.Unix(sec, nsec).In(DefaultClock.Clock.Now().Location())

Check warning on line 25 in garden-app/clock/clock.go

View check run for this annotation

Codecov / codecov/patch

garden-app/clock/clock.go#L24-L25

Added lines #L24 - L25 were not covered by tests
}

func Now() time.Time {
return DefaultClock.Clock.Now()
}

// MockTime sets up the DefaultClock with a consistent time so it can be used across tests
func MockTime() *clock.Mock {
mock := clock.NewMock()
mock.Set(time.Date(2023, time.August, 23, 10, 0, 0, 0, time.UTC))
DefaultClock = Clock{Clock: mock}
return mock
}

// Reset returns the DefaultClock to real time
func Reset() {
DefaultClock = Clock{clock.New()}
}
4 changes: 4 additions & 0 deletions garden-app/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"strings"

"github.com/calvinmclean/automated-garden/garden-app/server"
"github.com/calvinmclean/automated-garden/garden-app/server/vcr"

"github.com/spf13/cobra"
"github.com/spf13/viper"
)
Expand All @@ -16,6 +18,8 @@ var (
)

func Execute() {
defer vcr.StopRecorder()

Check warning on line 21 in garden-app/cmd/root.go

View check run for this annotation

Codecov / codecov/patch

garden-app/cmd/root.go#L21

Added line #L21 was not covered by tests

api := server.NewAPI()
command := api.Command()

Expand Down
6 changes: 4 additions & 2 deletions garden-app/controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"syscall"
"time"

"github.com/calvinmclean/automated-garden/garden-app/clock"
"github.com/calvinmclean/automated-garden/garden-app/pkg/action"
"github.com/calvinmclean/automated-garden/garden-app/pkg/mqtt"
"github.com/calvinmclean/automated-garden/garden-app/server"
Expand Down Expand Up @@ -115,7 +116,7 @@ func NewController(cfg Config) (*Controller, error) {
}

// Override configured ClientID with the TopicPrefix from command flags
controller.MQTTConfig.ClientID = fmt.Sprintf(controller.TopicPrefix)
controller.MQTTConfig.ClientID = fmt.Sprint(controller.TopicPrefix)
controller.mqttClient, err = mqtt.NewClient(controller.MQTTConfig, mqtt.DefaultHandler(controller.logger), handlers...)
if err != nil {
return nil, fmt.Errorf("unable to initialize MQTT client: %w", err)
Expand All @@ -132,6 +133,7 @@ func (c *Controller) Start() {
// Initialize scheduler and schedule publishing Jobs
c.logger.Debug("initializing scheduler")
scheduler := gocron.NewScheduler(time.Local)
scheduler.CustomTime(clock.DefaultClock)
if c.MoistureInterval != 0 {
for p := 0; p < c.NumZones; p++ {
c.logger.With(
Expand Down Expand Up @@ -170,7 +172,7 @@ func (c *Controller) Start() {
var shutdownStart time.Time
go func() {
<-c.quit
shutdownStart = time.Now()
shutdownStart = clock.Now()
c.logger.Info("gracefully shutting down controller")

scheduler.Stop()
Expand Down
13 changes: 10 additions & 3 deletions garden-app/go.mod
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
module github.com/calvinmclean/automated-garden/garden-app

go 1.21.3
go 1.22

toolchain go1.23.0

require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/ajg/form v1.5.1
github.com/calvinmclean/babyapi v0.22.0
github.com/benbjohnson/clock v1.3.5
github.com/calvinmclean/babyapi v0.23.0
github.com/eclipse/paho.mqtt.golang v1.4.3
github.com/go-chi/render v1.0.3
github.com/go-co-op/gocron v1.35.2
Expand All @@ -24,7 +27,7 @@ require (
github.com/tarmac-project/hord v0.6.0
github.com/tarmac-project/hord/drivers/hashmap v0.6.0
github.com/tarmac-project/hord/drivers/redis v0.6.0
gopkg.in/dnaeon/go-vcr.v3 v3.2.0
gopkg.in/dnaeon/go-vcr.v4 v4.0.0-20240818155041-3873f09a3029
gopkg.in/yaml.v3 v3.0.1
)

Expand Down Expand Up @@ -136,3 +139,7 @@ require (
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)

replace gopkg.in/dnaeon/go-vcr.v4 => github.com/calvinmclean/go-vcr v0.4.0

//replace gopkg.in/dnaeon/go-vcr.v4 => ../../go-vcr
12 changes: 8 additions & 4 deletions garden-app/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,21 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7D
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE=
github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/calvinmclean/babyapi v0.22.0 h1:g27F4DFdGeQfI97Pn+vGZtRkvR/RdWwqq2OMgSO7BAk=
github.com/calvinmclean/babyapi v0.22.0/go.mod h1:zSNiVRsL3DBPOMkXxMJOTFNtzU1ZrPFKD0LFx2JVp4I=
github.com/calvinmclean/babyapi v0.23.0 h1:rmYnAz80cX6TTDtbjQGt8DzjR7CDZNxkJm0/KvWRQHY=
github.com/calvinmclean/babyapi v0.23.0/go.mod h1:zSNiVRsL3DBPOMkXxMJOTFNtzU1ZrPFKD0LFx2JVp4I=
github.com/calvinmclean/go-vcr v0.3.0 h1:IDcpV3LktoWJjK+p11XdITqU6NOHZ+mCLICW0l7Hf+s=
github.com/calvinmclean/go-vcr v0.3.0/go.mod h1:Oy/wiaJrF0S8WmwjGRpDSL09yGnb9HXV4qP87dHkfFk=
github.com/calvinmclean/go-vcr v0.4.0 h1:QFl8yETQkARY+9pQErhvSW4o6foKEZFGkV5L+iTRcX4=
github.com/calvinmclean/go-vcr v0.4.0/go.mod h1:65yxh9goQVrudqofKtHA4JNFWd6XZRkWfKN4YpMx7KI=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
Expand Down Expand Up @@ -797,8 +803,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/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=
gopkg.in/dnaeon/go-vcr.v3 v3.2.0 h1:Rltp0Vf+Aq0u4rQXgmXgtgoRDStTnFN83cWgSGSoRzM=
gopkg.in/dnaeon/go-vcr.v3 v3.2.0/go.mod h1:2IMOnnlx9I6u9x+YBsM3tAMx6AlOxnJ0pWxQAzZ79Ag=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
Expand Down
9 changes: 5 additions & 4 deletions garden-app/integration_tests/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"
"time"

"github.com/calvinmclean/automated-garden/garden-app/clock"
"github.com/calvinmclean/automated-garden/garden-app/controller"
"github.com/calvinmclean/automated-garden/garden-app/pkg"
"github.com/calvinmclean/automated-garden/garden-app/pkg/action"
Expand Down Expand Up @@ -161,7 +162,7 @@ func GardenTests(t *testing.T) {
// Create new Garden with LightOnTime in the near future, so LightDelay will assume the light is currently off,
// meaning adhoc action is going to be predictably delayed
maxZones := uint(1)
startTime := pkg.NewStartTime(time.Now().In(time.Local).Add(1 * time.Second).Truncate(time.Second))
startTime := pkg.NewStartTime(clock.Now().In(time.Local).Add(1 * time.Second).Truncate(time.Second))
newGarden := &pkg.Garden{
Name: "TestGarden",
TopicPrefix: "test",
Expand Down Expand Up @@ -209,7 +210,7 @@ func GardenTests(t *testing.T) {
})
t.Run("ChangeLightScheduleStartTimeResetsLightSchedule", func(t *testing.T) {
// Reschedule Light to turn in in 1 second, for 1 second
newStartTime := pkg.NewStartTime(time.Now().Add(1 * time.Second).Truncate(time.Second))
newStartTime := pkg.NewStartTime(clock.Now().Add(1 * time.Second).Truncate(time.Second))
var g server.GardenResponse
status, err := makeRequest(http.MethodPatch, "/gardens/"+gardenID, pkg.Garden{
LightSchedule: &pkg.LightSchedule{
Expand Down Expand Up @@ -368,7 +369,7 @@ func ZoneTests(t *testing.T) {
})
t.Run("ChangeWaterScheduleStartTimeResetsWaterSchedule", func(t *testing.T) {
// Reschedule to Water in 2 second, for 1 second
newStartTime := time.Now().Add(2 * time.Second).Truncate(time.Second)
newStartTime := clock.Now().Add(2 * time.Second).Truncate(time.Second)
var ws server.WaterScheduleResponse
status, err := makeRequest(http.MethodPatch, "/water_schedules/"+waterScheduleID, pkg.WaterSchedule{
StartTime: pkg.NewStartTime(newStartTime),
Expand Down Expand Up @@ -419,7 +420,7 @@ func WaterScheduleTests(t *testing.T) {
})

// Reschedule to Water in 2 second, for 1 second
newStartTime := time.Now().Add(2 * time.Second).Truncate(time.Second)
newStartTime := clock.Now().Add(2 * time.Second).Truncate(time.Second)
var ws server.WaterScheduleResponse
status, err := makeRequest(http.MethodPatch, "/water_schedules/"+waterScheduleID, pkg.WaterSchedule{
StartTime: pkg.NewStartTime(newStartTime),
Expand Down
10 changes: 8 additions & 2 deletions garden-app/pkg/garden.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"regexp"
"time"

"github.com/calvinmclean/automated-garden/garden-app/clock"
"github.com/calvinmclean/automated-garden/garden-app/pkg/influxdb"
"github.com/calvinmclean/babyapi"
)
Expand Down Expand Up @@ -92,7 +93,7 @@ func (g *Garden) Health(ctx context.Context, influxdbClient influxdb.Client) *Ga

// EndDated returns true if the Garden is end-dated
func (g *Garden) EndDated() bool {
return g.EndDate != nil && g.EndDate.Before(time.Now())
return g.EndDate != nil && g.EndDate.Before(clock.Now())
}

func (g *Garden) SetEndDate(now time.Time) {
Expand Down Expand Up @@ -156,7 +157,7 @@ func (g *Garden) Bind(r *http.Request) error {
return err
}

now := time.Now()
now := clock.Now()
switch r.Method {
case http.MethodPost:
g.CreatedAt = &now
Expand Down Expand Up @@ -196,6 +197,11 @@ func (g *Garden) Bind(r *http.Request) error {
return errors.New("missing required light_schedule.start_time field")
}
}

// Ignore empty string provided for NotificationClientID
if g.NotificationClientID != nil && *g.NotificationClientID == "" {
g.NotificationClientID = nil
}
case http.MethodPatch:
illegalRegexp := regexp.MustCompile(`[\$\#\*\>\+\/]`)
if illegalRegexp.MatchString(g.TopicPrefix) {
Expand Down
15 changes: 8 additions & 7 deletions garden-app/pkg/garden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"
"time"

"github.com/calvinmclean/automated-garden/garden-app/clock"
"github.com/calvinmclean/automated-garden/garden-app/pkg/influxdb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
Expand All @@ -21,13 +22,13 @@ func TestHealth(t *testing.T) {
}{
{
"GardenIsUp",
time.Now(),
clock.Now(),
nil,
HealthStatusUp,
},
{
"GardenIsDown",
time.Now().Add(-5 * time.Minute),
clock.Now().Add(-5 * time.Minute),
nil,
HealthStatusDown,
},
Expand Down Expand Up @@ -61,8 +62,8 @@ func TestHealth(t *testing.T) {
}

func TestGardenEndDated(t *testing.T) {
pastDate := time.Now().Add(-1 * time.Minute)
futureDate := time.Now().Add(time.Minute)
pastDate := clock.Now().Add(-1 * time.Minute)
futureDate := clock.Now().Add(time.Minute)
tests := []struct {
name string
endDate *time.Time
Expand All @@ -84,7 +85,7 @@ func TestGardenEndDated(t *testing.T) {
}

func TestGardenPatch(t *testing.T) {
now := time.Now()
now := clock.Now()
ten := uint(10)
trueBool := true
falseBool := false
Expand Down Expand Up @@ -172,7 +173,7 @@ func TestGardenPatch(t *testing.T) {
})

t.Run("PatchDoesNotAddEndDate", func(t *testing.T) {
now := time.Now()
now := clock.Now()
g := &Garden{}

err := g.Patch(&Garden{EndDate: &now})
Expand All @@ -184,7 +185,7 @@ func TestGardenPatch(t *testing.T) {
})

t.Run("PatchRemoveEndDate", func(t *testing.T) {
now := time.Now()
now := clock.Now()
g := &Garden{
EndDate: &now,
}
Expand Down
8 changes: 7 additions & 1 deletion garden-app/pkg/influxdb/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package influxdb
import (
"bytes"
"context"
"sync"
"text/template"
"time"

Expand Down Expand Up @@ -44,6 +45,12 @@ const (
|> mean()`
)

func init() {
sync.OnceFunc(func() {
prometheus.MustRegister(influxDBClientSummary)
})()
}

var influxDBClientSummary = prometheus.NewSummaryVec(prometheus.SummaryOpts{
Namespace: "garden_app",
Name: "influxdb_client_duration_seconds",
Expand Down Expand Up @@ -95,7 +102,6 @@ type client struct {

// NewClient creates an InfluxDB client from the viper config
func NewClient(config Config) Client {
prometheus.MustRegister(influxDBClientSummary)
return &client{
influxdb2.NewClient(config.Address, config.Token),
config,
Expand Down
3 changes: 2 additions & 1 deletion garden-app/pkg/start_time_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/ajg/form"
"github.com/calvinmclean/automated-garden/garden-app/clock"
"github.com/stretchr/testify/assert"
)

Expand All @@ -33,7 +34,7 @@ func TestTimeLocationFromOffset(t *testing.T) {
},
}

now := time.Now()
now := clock.Now()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
expectedLoc, _ := time.LoadLocation(tt.expectedLoc)
Expand Down
Loading

0 comments on commit 8a63e94

Please sign in to comment.