Skip to content

Commit

Permalink
Add tests for Netatmo WeatherClient using VCR
Browse files Browse the repository at this point in the history
  • Loading branch information
calvinmclean committed Jul 11, 2024
1 parent 9ac7ea9 commit 7d381e2
Show file tree
Hide file tree
Showing 8 changed files with 435 additions and 2 deletions.
1 change: 1 addition & 0 deletions garden-app/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ require (
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.17.0
github.com/stretchr/testify v1.8.4
gopkg.in/dnaeon/go-vcr.v3 v3.2.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down
2 changes: 2 additions & 0 deletions garden-app/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,8 @@ 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
6 changes: 4 additions & 2 deletions garden-app/pkg/weather/netatmo/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,14 @@ type Client struct {
storageCallback func(map[string]interface{}) error
}

var DefaultClient *http.Client = http.DefaultClient

Check warning on line 55 in garden-app/pkg/weather/netatmo/client.go

View workflow job for this annotation

GitHub Actions / lint

var-declaration: should omit type *http.Client from declaration of var DefaultClient; it will be inferred from the right-hand side (revive)

// NewClient creates a new Netatmo API client from configuration
// If StationID is not provided, StationName is used to get it from the API
// If RainModuleID is not provided, RainModuleName is used to get it from the API
// For Authentication, AccessToken, RefreshToken, ClientID and ClientSecret are required
func NewClient(options map[string]interface{}, storageCallback func(map[string]interface{}) error) (*Client, error) {
client := &Client{Client: http.DefaultClient, storageCallback: storageCallback}
client := &Client{Client: DefaultClient, storageCallback: storageCallback}

err := mapstructure.Decode(options, &client.Config)
if err != nil {
Expand Down Expand Up @@ -205,7 +207,7 @@ func (c *Client) refreshToken() error {
"client_secret": {c.ClientSecret},
}

req, err := http.NewRequest("POST", "https://api.netatmo.com/oauth2/token", strings.NewReader(formData.Encode()))
req, err := http.NewRequest(http.MethodPost, "https://api.netatmo.com/oauth2/token", strings.NewReader(formData.Encode()))
if err != nil {
return err
}
Expand Down
142 changes: 142 additions & 0 deletions garden-app/pkg/weather/netatmo/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package netatmo

import (
"net/http"
"testing"
"time"

"github.com/stretchr/testify/require"
"gopkg.in/dnaeon/go-vcr.v3/cassette"
"gopkg.in/dnaeon/go-vcr.v3/recorder"
)

func TestNewClientUsingDeviceName(t *testing.T) {
r, err := recorder.New("testdata/fixtures/GetDeviceIDs")
if err != nil {
t.Fatal(err)
}
defer r.Stop()

Check failure on line 18 in garden-app/pkg/weather/netatmo/client_test.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `r.Stop` is not checked (errcheck)

if r.Mode() != recorder.ModeRecordOnce {
t.Fatal("Recorder should be in ModeRecordOnce")
}

DefaultClient = r.GetDefaultClient()

opts := map[string]any{
"authentication": map[string]any{
"access_token": "ACCESS_TOKE",
"refresh_token": "REFRESH_TOKEN",
"expiration_date": time.Now().Add(1 * time.Minute).Format(time.RFC3339Nano),
},
"client_id": "CLIENT_ID",
"client_secret": "CLIENT_SECRET",
"outdoor_module_name": "Outdoor Module",
"rain_module_name": "Smart Rain Gauge",
"station_name": "Weather Station",
}
client, err := NewClient(opts, func(m map[string]interface{}) error {
return nil
})
require.NoError(t, err)
require.NotNil(t, client)
require.Equal(t, "STATION_ID", client.Config.StationID)
require.Equal(t, "OUTDOOR_MODULE_ID", client.Config.OutdoorModuleID)
require.Equal(t, "RAIN_MODULE_ID", client.Config.RainModuleID)
}

func TestWeatherRequestMethods(t *testing.T) {
tests := []struct {
name string
fixture string
tokenExpiration time.Time
storageCallback func(newOpts map[string]interface{}) error
exec func(t *testing.T, client *Client)
}{
{
"GetTotalRain_NoRefresh",
"testdata/fixtures/GetTotalRain_NoRefresh",
time.Now().Add(1 * time.Minute),
func(newOpts map[string]interface{}) error { return nil },
func(t *testing.T, client *Client) {
rain, err := client.GetTotalRain(72 * time.Hour)
require.NoError(t, err)
require.Equal(t, float32(0), rain)
},
},
{
"GetTotalRain_Refresh",
"testdata/fixtures/GetTotalRain_Refresh",
time.Now().Add(1 * time.Minute),
func(newOpts map[string]interface{}) error {
newRefreshToken := newOpts["authentication"].(map[string]any)["refresh_token"]
require.Equal(t, "NEW_REFRESH_TOKEN", newRefreshToken)
return nil
},
func(t *testing.T, client *Client) {
rain, err := client.GetTotalRain(72 * time.Hour)
require.NoError(t, err)
require.Equal(t, float32(0), rain)
},
},
{
"GetAverageHighTemperature_NoRefresh",
"testdata/fixtures/GetAverageHighTemperature_NoRefresh",
time.Now().Add(1 * time.Minute),
func(newOpts map[string]interface{}) error { return nil },
func(t *testing.T, client *Client) {
temp, err := client.GetAverageHighTemperature(72 * time.Hour)
require.NoError(t, err)
require.Equal(t, float32(48.066666), temp)
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := map[string]any{
"authentication": map[string]any{
"access_token": "ACCESS_TOKEN",
"refresh_token": "REFRESH_TOKEN",
"expiration_date": tt.tokenExpiration.Format(time.RFC3339Nano),
},
"client_id": "CLIENT_ID",
"client_secret": "CLIENT_SECRET",
"outdoor_module_id": "OUTDOOR_MODULE_ID",
"rain_module_id": "RAIN_MODULE_ID",
"station_id": "STATION_ID",
}
client, err := NewClient(opts, tt.storageCallback)
require.NoError(t, err)

r, err := recorder.New(tt.fixture)
if err != nil {
t.Fatal(err)
}
defer r.Stop()

Check failure on line 116 in garden-app/pkg/weather/netatmo/client_test.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `r.Stop` is not checked (errcheck)

if r.Mode() != recorder.ModeRecordOnce {
t.Fatal("Recorder should be in ModeRecordOnce")
}

// Modify request from garden-app to use placeholder for date_begin query param
r.SetMatcher(func(r1 *http.Request, r2 cassette.Request) bool {
query := r1.URL.Query()
if query.Get("date_begin") != "" {
query.Set("date_begin", "DATE_BEGIN")
r1.URL.RawQuery = query.Encode()
}
if query.Get("date_end") != "" {
query.Set("date_end", "DATE_END")
r1.URL.RawQuery = query.Encode()
}

return cassette.DefaultMatcher(r1, r2)
})

client.Client = r.GetDefaultClient()

tt.exec(t, client)
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
transfer_encoding: []
trailer: {}
host: api.netatmo.com
remote_addr: ""
request_uri: ""
body: ""
form: {}
headers:
Accept:
- application/json
Authorization:
- Bearer ACCESS_TOKEN
url: https://api.netatmo.com/api/getmeasure?date_begin=DATE_BEGIN&date_end=DATE_END&device_id=STATION_ID&module_id=OUTDOOR_MODULE_ID&optimize=false&real_time=false&scale=1day&type=max_temp
method: GET
response:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
transfer_encoding:
- chunked
trailer: {}
content_length: -1
uncompressed: true
body: '{"body":{"1720465200":[49],"1720551600":[48.2],"1720638000":[47]},"status":"ok","time_exec":0.02807903289794922,"time_server":1720725568}'
headers:
Access-Control-Allow-Origin:
- "*"
Cache-Control:
- no-cache, must-revalidate
Connection:
- keep-alive
Content-Type:
- application/json; charset=utf-8
Date:
- Thu, 11 Jul 2024 19:19:28 GMT
Expires:
- "0"
Server:
- nginx
Strict-Transport-Security:
- max-age=31536000; includeSubDomains
X-Powered-By:
- Netatmo
X-Xss-Protection:
- 1; mode=block
status: 200 OK
code: 200
duration: 10ms
57 changes: 57 additions & 0 deletions garden-app/pkg/weather/netatmo/testdata/fixtures/GetDeviceIDs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
transfer_encoding: []
trailer: {}
host: api.netatmo.com
remote_addr: ""
request_uri: ""
body: ""
form: {}
headers:
Accept:
- application/json
Authorization:
- Bearer ACCESS_TOKEN
url: https://api.netatmo.com/api/getstationsdata?get_favorites=false
method: GET
response:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
transfer_encoding:
- chunked
trailer: {}
content_length: -1
uncompressed: true
body: '{"body":{"devices":[{"_id":"STATION_ID","module_name":"Weather Station","modules":[{"_id":"OUTDOOR_MODULE_ID","module_name":"Outdoor Module"},{"_id":"RAIN_MODULE_ID","module_name":"Smart Rain Gauge"}]}]}}'
headers:
Access-Control-Allow-Origin:
- "*"
Cache-Control:
- no-cache, must-revalidate
Connection:
- keep-alive
Content-Type:
- application/json; charset=utf-8
Date:
- Thu, 11 Jul 2024 19:30:53 GMT
Expires:
- "0"
Server:
- nginx
Strict-Transport-Security:
- max-age=31536000; includeSubDomains
X-Powered-By:
- Netatmo
X-Xss-Protection:
- 1; mode=block
status: 200 OK
code: 200
duration: 10ms
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
transfer_encoding: []
trailer: {}
host: api.netatmo.com
remote_addr: ""
request_uri: ""
body: ""
form: {}
headers:
Accept:
- application/json
Authorization:
- Bearer ACCESS_TOKEN
url: https://api.netatmo.com/api/getmeasure?date_begin=DATE_BEGIN&device_id=STATION_ID&module_id=RAIN_MODULE_ID&optimize=false&real_time=false&scale=1day&type=sum_rain
method: GET
response:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
transfer_encoding:
- chunked
trailer: {}
content_length: -1
uncompressed: true
body: '{"body":{"1720551600":[0],"1720638000":[0],"1720724400":[0]},"status":"ok","time_exec":0.03505086898803711,"time_server":1720711560}'
headers:
Access-Control-Allow-Origin:
- "*"
Cache-Control:
- no-cache, must-revalidate
Connection:
- keep-alive
Content-Type:
- application/json; charset=utf-8
Date:
- Thu, 11 Jul 2024 15:26:00 GMT
Expires:
- "0"
Server:
- nginx
Strict-Transport-Security:
- max-age=31536000; includeSubDomains
X-Powered-By:
- Netatmo
X-Xss-Protection:
- 1; mode=block
status: 200 OK
code: 200
duration: 10ms
Loading

0 comments on commit 7d381e2

Please sign in to comment.