diff --git a/codecov.yml b/codecov.yml index 1315d4be..00f1d4da 100644 --- a/codecov.yml +++ b/codecov.yml @@ -19,7 +19,6 @@ ignore: - deploy - iOS_App - docs - - garden-app/pkg/weather/netatmo # ignore because it is basically untestable - garden-app/cmd/completion.go - "garden-app/**/*_test.go" - "garden-app/**/mock*.go" diff --git a/garden-app/go.mod b/garden-app/go.mod index 8d03fe35..53be2c3b 100644 --- a/garden-app/go.mod +++ b/garden-app/go.mod @@ -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 ) diff --git a/garden-app/go.sum b/garden-app/go.sum index ad8d9e5b..2e367e59 100644 --- a/garden-app/go.sum +++ b/garden-app/go.sum @@ -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= diff --git a/garden-app/pkg/weather/netatmo/client.go b/garden-app/pkg/weather/netatmo/client.go index a3fa1f10..87c39c94 100644 --- a/garden-app/pkg/weather/netatmo/client.go +++ b/garden-app/pkg/weather/netatmo/client.go @@ -52,12 +52,14 @@ type Client struct { storageCallback func(map[string]interface{}) error } +var DefaultClient = http.DefaultClient + // 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 { @@ -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 } diff --git a/garden-app/pkg/weather/netatmo/client_test.go b/garden-app/pkg/weather/netatmo/client_test.go new file mode 100644 index 00000000..081e77de --- /dev/null +++ b/garden-app/pkg/weather/netatmo/client_test.go @@ -0,0 +1,146 @@ +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 func() { + require.NoError(t, r.Stop()) + }() + + 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 func() { + require.NoError(t, r.Stop()) + }() + + 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) + }) + } +} diff --git a/garden-app/pkg/weather/netatmo/testdata/fixtures/GetAverageHighTemperature_NoRefresh.yaml b/garden-app/pkg/weather/netatmo/testdata/fixtures/GetAverageHighTemperature_NoRefresh.yaml new file mode 100644 index 00000000..5a0d0116 --- /dev/null +++ b/garden-app/pkg/weather/netatmo/testdata/fixtures/GetAverageHighTemperature_NoRefresh.yaml @@ -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 diff --git a/garden-app/pkg/weather/netatmo/testdata/fixtures/GetDeviceIDs.yaml b/garden-app/pkg/weather/netatmo/testdata/fixtures/GetDeviceIDs.yaml new file mode 100644 index 00000000..b5fc613d --- /dev/null +++ b/garden-app/pkg/weather/netatmo/testdata/fixtures/GetDeviceIDs.yaml @@ -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 diff --git a/garden-app/pkg/weather/netatmo/testdata/fixtures/GetTotalRain_NoRefresh.yaml b/garden-app/pkg/weather/netatmo/testdata/fixtures/GetTotalRain_NoRefresh.yaml new file mode 100644 index 00000000..d544f844 --- /dev/null +++ b/garden-app/pkg/weather/netatmo/testdata/fixtures/GetTotalRain_NoRefresh.yaml @@ -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 diff --git a/garden-app/pkg/weather/netatmo/testdata/fixtures/GetTotalRain_Refresh.yaml b/garden-app/pkg/weather/netatmo/testdata/fixtures/GetTotalRain_Refresh.yaml new file mode 100644 index 00000000..a3a08f3d --- /dev/null +++ b/garden-app/pkg/weather/netatmo/testdata/fixtures/GetTotalRain_Refresh.yaml @@ -0,0 +1,115 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 180 + transfer_encoding: [] + trailer: {} + host: api.netatmo.com + remote_addr: "" + request_uri: "" + body: client_id=CLIENT_ID&client_secret=CLIENT_SECRET&grant_type=refresh_token&refresh_token=REFRESH_TOKEN + form: + client_id: + - CLIENT_ID + client_secret: + - CLIENT_SECRET + grant_type: + - refresh_token + refresh_token: + - REFRESH_TOKEN + headers: + Content-Type: + - application/x-www-form-urlencoded + url: https://api.netatmo.com/oauth2/token + method: POST + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: + - chunked + trailer: {} + content_length: -1 + uncompressed: true + body: '{"access_token":"ACCESS_TOKEN","refresh_token":"NEW_REFRESH_TOKEN","expires_in":10800,"expire_in":10800,"scope":["read_station"]}' + headers: + Access-Control-Allow-Origin: + - "*" + Cache-Control: + - no-store + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Thu, 11 Jul 2024 18:26:44 GMT + 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 + - id: 1 + 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.0270078182220459,"time_server":1720722405}' + 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 18:26:45 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