mirror of
https://github.com/sstent/go-garminconnect.git
synced 2026-02-06 06:22:06 +00:00
garth more done
This commit is contained in:
@@ -293,4 +293,4 @@ func (c *Client) DownloadActivity(ctx context.Context, activityID int64) ([]byte
|
||||
}
|
||||
|
||||
return resp.Body(), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ func TestActivitiesEndpoints(t *testing.T) {
|
||||
client := NewClientWithBaseURL(mockServer.URL())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func()
|
||||
testFunc func(t *testing.T)
|
||||
name string
|
||||
setup func()
|
||||
testFunc func(t *testing.T)
|
||||
}{
|
||||
{
|
||||
name: "GetActivitiesSuccess",
|
||||
@@ -28,17 +28,17 @@ func TestActivitiesEndpoints(t *testing.T) {
|
||||
mockServer.SetActivitiesHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Create a properly formatted time string for Garmin format
|
||||
timeStr := time.Now().Add(-24 * time.Hour).Format("2006-01-02T15:04:05")
|
||||
|
||||
|
||||
// Create response with raw JSON to avoid time marshaling issues
|
||||
response := map[string]interface{}{
|
||||
"activities": []map[string]interface{}{
|
||||
{
|
||||
"activityId": 1,
|
||||
"activityName": "Morning Run",
|
||||
"activityType": "RUNNING",
|
||||
"startTimeLocal": timeStr,
|
||||
"duration": 3600.0,
|
||||
"distance": 10.0,
|
||||
"activityId": 1,
|
||||
"activityName": "Morning Run",
|
||||
"activityType": "RUNNING",
|
||||
"startTimeLocal": timeStr,
|
||||
"duration": 3600.0,
|
||||
"distance": 10.0,
|
||||
},
|
||||
},
|
||||
"pagination": map[string]interface{}{
|
||||
@@ -47,7 +47,7 @@ func TestActivitiesEndpoints(t *testing.T) {
|
||||
"totalCount": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
@@ -81,18 +81,18 @@ func TestActivitiesEndpoints(t *testing.T) {
|
||||
|
||||
// Use proper time format for Garmin API
|
||||
timeStr := time.Now().Add(-24 * time.Hour).Format("2006-01-02T15:04:05")
|
||||
|
||||
|
||||
response := map[string]interface{}{
|
||||
"activityId": activityID,
|
||||
"activityName": "Mock Activity",
|
||||
"activityType": "RUNNING",
|
||||
"startTimeLocal": timeStr,
|
||||
"duration": 3600.0,
|
||||
"distance": 10.0,
|
||||
"calories": 500.0,
|
||||
"averageHR": 150,
|
||||
"maxHR": 170,
|
||||
"elevationGain": 100.0,
|
||||
"activityId": activityID,
|
||||
"activityName": "Mock Activity",
|
||||
"activityType": "RUNNING",
|
||||
"startTimeLocal": timeStr,
|
||||
"duration": 3600.0,
|
||||
"distance": 10.0,
|
||||
"calories": 500.0,
|
||||
"averageHR": 150,
|
||||
"maxHR": 170,
|
||||
"elevationGain": 100.0,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -156,7 +156,7 @@ func TestActivitiesEndpoints(t *testing.T) {
|
||||
fitData := make([]byte, 20)
|
||||
fitData[0] = 14 // header size
|
||||
copy(fitData[8:12], []byte(".FIT"))
|
||||
|
||||
|
||||
id, err := client.UploadActivity(context.Background(), fitData)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(12345), id)
|
||||
@@ -187,4 +187,4 @@ func TestActivitiesEndpoints(t *testing.T) {
|
||||
tt.testFunc(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestGetBodyComposition(t *testing.T) {
|
||||
// Check for required parameters without enforcing order
|
||||
startDate := r.URL.Query().Get("startDate")
|
||||
endDate := r.URL.Query().Get("endDate")
|
||||
|
||||
|
||||
assert.Equal(t, "2023-01-01", startDate, "startDate should match")
|
||||
assert.Equal(t, "2023-01-31", endDate, "endDate should match")
|
||||
|
||||
@@ -115,4 +115,4 @@ func TestGetBodyComposition(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestGearService(t *testing.T) {
|
||||
// Create test server
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/gear-service/stats/valid-uuid":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -129,4 +129,4 @@ func TestGearService(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to get gear activities")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,11 @@ func (e *APIError) Error() string {
|
||||
|
||||
// Error types for API responses
|
||||
type ErrNotFound struct{}
|
||||
|
||||
func (e ErrNotFound) Error() string { return "resource not found" }
|
||||
|
||||
type ErrBadRequest struct{}
|
||||
|
||||
func (e ErrBadRequest) Error() string { return "bad request" }
|
||||
|
||||
// Time represents a Garmin Connect time value
|
||||
@@ -47,7 +49,7 @@ func (t *Time) UnmarshalJSON(data []byte) error {
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
// Try multiple time formats that Garmin might use
|
||||
formats := []string{
|
||||
"2006-01-02T15:04:05.000Z",
|
||||
@@ -57,14 +59,14 @@ func (t *Time) UnmarshalJSON(data []byte) error {
|
||||
time.RFC3339,
|
||||
time.RFC3339Nano,
|
||||
}
|
||||
|
||||
|
||||
for _, format := range formats {
|
||||
if parsedTime, err := time.Parse(format, s); err == nil {
|
||||
*t = Time(parsedTime)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If none of the formats work, try parsing as RFC3339
|
||||
parsedTime, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -96,16 +97,25 @@ func (g *GarthAuthenticator) Login(username, password string) (*Session, error)
|
||||
|
||||
// getRequestToken obtains OAuth1 request token
|
||||
func (g *GarthAuthenticator) getRequestToken() (token, secret string, err error) {
|
||||
_, err = g.HTTPClient.R().
|
||||
resp, err := g.HTTPClient.R().
|
||||
SetHeader("Accept", "text/html").
|
||||
SetResult(&struct{}{}).
|
||||
Post(g.BaseURL + "/oauth-service/oauth/request_token")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return "", "", fmt.Errorf("request token request failed: %w", err)
|
||||
}
|
||||
|
||||
// Parse token and secret from response body
|
||||
return "temp_token", "temp_secret", nil
|
||||
values, err := url.ParseQuery(resp.String())
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to parse request token response: %w", err)
|
||||
}
|
||||
|
||||
token = values.Get("oauth_token")
|
||||
secret = values.Get("oauth_token_secret")
|
||||
if token == "" || secret == "" {
|
||||
return "", "", errors.New("request token response missing oauth_token or oauth_token_secret")
|
||||
}
|
||||
return token, secret, nil
|
||||
}
|
||||
|
||||
// authenticate handles username/password authentication and MFA
|
||||
@@ -199,12 +209,44 @@ func (d DefaultConsolePrompter) GetMFACode(ctx context.Context) (string, error)
|
||||
|
||||
// getAccessToken exchanges request token for access token
|
||||
func (g *GarthAuthenticator) getAccessToken(token, secret, verifier string) (accessToken, accessSecret string, err error) {
|
||||
return "access_token", "access_secret", nil
|
||||
resp, err := g.HTTPClient.R().
|
||||
SetQueryParam("oauth_token", token).
|
||||
SetQueryParam("oauth_verifier", verifier).
|
||||
Post(g.BaseURL + "/oauth-service/oauth/access_token")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("access token request failed: %w", err)
|
||||
}
|
||||
|
||||
values, err := url.ParseQuery(resp.String())
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to parse access token response: %w", err)
|
||||
}
|
||||
|
||||
accessToken = values.Get("oauth_token")
|
||||
accessSecret = values.Get("oauth_token_secret")
|
||||
if accessToken == "" || accessSecret == "" {
|
||||
return "", "", errors.New("access token response missing oauth_token or oauth_token_secret")
|
||||
}
|
||||
return accessToken, accessSecret, nil
|
||||
}
|
||||
|
||||
// getOAuth2Token exchanges OAuth1 token for OAuth2 token
|
||||
func (g *GarthAuthenticator) getOAuth2Token(token, secret string) (oauth2Token string, err error) {
|
||||
return "oauth2_access_token", nil
|
||||
resp, err := g.HTTPClient.R().
|
||||
SetFormData(map[string]string{
|
||||
"token": token,
|
||||
"token_secret": secret,
|
||||
}).
|
||||
Post(g.BaseURL + "/oauth-service/oauth/exchange/user/2.0")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("OAuth2 token exchange failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode() != 200 {
|
||||
return "", fmt.Errorf("OAuth2 token exchange failed with status %d", resp.StatusCode())
|
||||
}
|
||||
|
||||
return strings.TrimSpace(resp.String()), nil
|
||||
}
|
||||
|
||||
// Save persists the session to the specified path
|
||||
|
||||
@@ -9,20 +9,27 @@ import (
|
||||
)
|
||||
|
||||
func TestOAuth1LoginFlow(t *testing.T) {
|
||||
// Setup mock server to simulate Garmin SSO flow
|
||||
// Setup mock server to simulate complete Garmin SSO flow
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// The request token step uses text/html Accept header
|
||||
if r.URL.Path == "/oauth-service/oauth/request_token" {
|
||||
assert.Equal(t, "text/html", r.Header.Get("Accept"))
|
||||
} else {
|
||||
// Other requests use application/json
|
||||
assert.Equal(t, "application/json", r.Header.Get("Accept"))
|
||||
}
|
||||
assert.Equal(t, "garmin-connect-client", r.Header.Get("User-Agent"))
|
||||
switch r.URL.Path {
|
||||
case "/oauth-service/oauth/request_token":
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("oauth_token=test_token&oauth_token_secret=test_secret"))
|
||||
|
||||
// Simulate successful SSO response
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<input type="hidden" name="oauth_verifier" value="test_verifier" />`))
|
||||
case "/sso/signin":
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<input type="hidden" name="oauth_verifier" value="test_verifier" />`))
|
||||
|
||||
case "/oauth-service/oauth/access_token":
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("oauth_token=access_token&oauth_token_secret=access_secret"))
|
||||
|
||||
case "/oauth-service/oauth/exchange/user/2.0":
|
||||
w.Write([]byte("oauth2_token"))
|
||||
|
||||
default:
|
||||
t.Errorf("Unexpected request to path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
@@ -34,21 +41,43 @@ func TestOAuth1LoginFlow(t *testing.T) {
|
||||
session, err := auth.Login("test_user", "test_pass")
|
||||
assert.NoError(t, err, "Login should succeed")
|
||||
assert.NotNil(t, session, "Session should be created")
|
||||
|
||||
// Verify session values
|
||||
assert.Equal(t, "access_token", session.OAuth1Token)
|
||||
assert.Equal(t, "access_secret", session.OAuth1Secret)
|
||||
assert.Equal(t, "oauth2_token", session.OAuth2Token)
|
||||
assert.False(t, session.IsExpired(), "Session should not be expired")
|
||||
}
|
||||
|
||||
func TestMFAFlow(t *testing.T) {
|
||||
mfaTriggered := false
|
||||
// Setup mock server to simulate MFA requirement
|
||||
// Setup mock server to simulate MFA requirement and complete flow
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !mfaTriggered {
|
||||
switch {
|
||||
case r.URL.Path == "/oauth-service/oauth/request_token":
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("oauth_token=test_token&oauth_token_secret=test_secret"))
|
||||
|
||||
case r.URL.Path == "/sso/signin" && !mfaTriggered:
|
||||
// First response requires MFA
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<div class="mfa-required"><input type="hidden" name="mfaContext" value="context123" /></div>`))
|
||||
mfaTriggered = true
|
||||
} else {
|
||||
// Second response after MFA
|
||||
|
||||
case r.URL.Path == "/sso/verifyMFA":
|
||||
// MFA verification
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<input type="hidden" name="oauth_verifier" value="mfa_verifier" />`))
|
||||
|
||||
case r.URL.Path == "/oauth-service/oauth/access_token":
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("oauth_token=access_token&oauth_token_secret=access_secret"))
|
||||
|
||||
case r.URL.Path == "/oauth-service/oauth/exchange/user/2.0":
|
||||
w.Write([]byte("oauth2_token"))
|
||||
|
||||
default:
|
||||
t.Errorf("Unexpected request to path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
@@ -61,6 +90,12 @@ func TestMFAFlow(t *testing.T) {
|
||||
session, err := auth.Login("mfa_user", "mfa_pass")
|
||||
assert.NoError(t, err, "MFA login should succeed")
|
||||
assert.NotNil(t, session, "Session should be created")
|
||||
|
||||
// Verify session values
|
||||
assert.Equal(t, "access_token", session.OAuth1Token)
|
||||
assert.Equal(t, "access_secret", session.OAuth1Secret)
|
||||
assert.Equal(t, "oauth2_token", session.OAuth2Token)
|
||||
assert.False(t, session.IsExpired(), "Session should not be expired")
|
||||
}
|
||||
|
||||
func TestLoginFailure(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user