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 19, 2024
1 parent f920597 commit e2c39a8
Show file tree
Hide file tree
Showing 50 changed files with 3,972 additions and 180 deletions.
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// README at: https://github.com/devcontainers/templates/tree/main/src/go
{
"name": "automated-garden",
"image": "mcr.microsoft.com/devcontainers/go:1-1.21-bullseye",
"image": "mcr.microsoft.com/devcontainers/go:1-1.22-bullseye",
"features": {
"ghcr.io/devcontainers/features/sshd:1": {
"version": "latest"
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: "1.21"
go-version: "1.22"
cache: false
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
Expand All @@ -38,7 +38,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.21"
go-version: "1.22"

- name: Test
run: task -t GithubActionTasks.yml main:unit-test
Expand All @@ -62,7 +62,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.21"
go-version: "1.22"

- name: Integration Test
run: task -t GithubActionTasks.yml main:integration-test
Expand Down
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
2 changes: 1 addition & 1 deletion garden-app/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@

# build go app
FROM golang:1.21-alpine AS build
FROM golang:1.22-alpine AS build
RUN mkdir /build
ADD . /build
WORKDIR /build
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
10 changes: 6 additions & 4 deletions garden-app/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,19 @@ 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.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 +801,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
53 changes: 30 additions & 23 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 @@ -208,30 +209,36 @@ 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))
var g server.GardenResponse
status, err := makeRequest(http.MethodPatch, "/gardens/"+gardenID, pkg.Garden{
LightSchedule: &pkg.LightSchedule{
StartTime: newStartTime,
Duration: &pkg.Duration{Duration: time.Second},
},
}, &g)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, status)
assert.Equal(t, newStartTime.String(), g.LightSchedule.StartTime.String())
// Reschedule Light to turn in in 2 second, for 1 second
newStartTimeDelay := 2 * time.Second
newStartTime := pkg.NewStartTime(clock.Now().Add(newStartTimeDelay).Truncate(time.Second))

t.Run("ModifyLightSchedule", func(t *testing.T) {
var g server.GardenResponse
status, err := makeRequest(http.MethodPatch, "/gardens/"+gardenID, pkg.Garden{
LightSchedule: &pkg.LightSchedule{
StartTime: newStartTime,
Duration: &pkg.Duration{Duration: time.Second},
},
}, &g)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, status)
assert.Equal(t, newStartTime.String(), g.LightSchedule.StartTime.String())
})

time.Sleep(100 * time.Millisecond)

// Make sure NextOnTime and state are changed
var g2 server.GardenResponse
status, err = makeRequest(http.MethodGet, "/gardens/"+gardenID, nil, &g2)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, status)
assert.Equal(t, newStartTime.String(), pkg.NewStartTime(g2.NextLightAction.Time.Local()).String())
assert.Equal(t, pkg.LightStateOn, g2.NextLightAction.State)
t.Run("CheckNewNextOnTime", func(t *testing.T) {
var g server.GardenResponse
status, err := makeRequest(http.MethodGet, "/gardens/"+gardenID, nil, &g)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, status)
assert.Equal(t, newStartTime.String(), pkg.NewStartTime(g.NextLightAction.Time.Local()).String())
assert.Equal(t, pkg.LightStateOn, g.NextLightAction.State)
})

time.Sleep(2 * time.Second)
// wait a little extra
time.Sleep(2*newStartTimeDelay + 500*time.Millisecond)

// Assert both LightActions
c.AssertLightActions(t,
Expand Down Expand Up @@ -368,7 +375,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 +426,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
Loading

0 comments on commit e2c39a8

Please sign in to comment.