with garth

This commit is contained in:
2025-08-28 09:58:24 -07:00
parent dc5bfcb281
commit 73258c0b41
31 changed files with 983 additions and 738 deletions

View File

@@ -4,9 +4,8 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strconv"
@@ -28,15 +27,15 @@ type Activity struct {
// ActivityDetail represents comprehensive activity data
type ActivityDetail struct {
Activity
Calories float64 `json:"calories"`
AverageHR int `json:"averageHR"`
MaxHR int `json:"maxHR"`
AverageTemp float64 `json:"averageTemperature"`
ElevationGain float64 `json:"elevationGain"`
ElevationLoss float64 `json:"elevationLoss"`
Weather Weather `json:"weather"`
Gear Gear `json:"gear"`
GPSTracks []GPSTrackPoint `json:"gpsTracks"`
Calories float64 `json:"calories"`
AverageHR int `json:"averageHR"`
MaxHR int `json:"maxHR"`
AverageTemp float64 `json:"averageTemperature"`
ElevationGain float64 `json:"elevationGain"`
ElevationLoss float64 `json:"elevationLoss"`
Weather Weather `json:"weather"`
Gear Gear `json:"gear"`
GPSTracks []GPSTrackPoint `json:"gpsTracks"`
}
// garminTime implements custom JSON unmarshaling for Garmin's time format
@@ -72,15 +71,15 @@ type ActivityResponse struct {
// ActivityDetailResponse is used for JSON unmarshaling with custom time handling
type ActivityDetailResponse struct {
ActivityResponse
Calories float64 `json:"calories"`
AverageHR int `json:"averageHR"`
MaxHR int `json:"maxHR"`
AverageTemp float64 `json:"averageTemperature"`
ElevationGain float64 `json:"elevationGain"`
ElevationLoss float64 `json:"elevationLoss"`
Weather Weather `json:"weather"`
Gear Gear `json:"gear"`
GPSTracks []GPSTrackPoint `json:"gpsTracks"`
Calories float64 `json:"calories"`
AverageHR int `json:"averageHR"`
MaxHR int `json:"maxHR"`
AverageTemp float64 `json:"averageTemperature"`
ElevationGain float64 `json:"elevationGain"`
ElevationLoss float64 `json:"elevationLoss"`
Weather Weather `json:"weather"`
Gear Gear `json:"gear"`
GPSTracks []GPSTrackPoint `json:"gpsTracks"`
}
// Convert to ActivityDetail
@@ -94,15 +93,15 @@ func (adr *ActivityDetailResponse) ToActivityDetail() ActivityDetail {
Duration: adr.Duration,
Distance: adr.Distance,
},
Calories: adr.Calories,
AverageHR: adr.AverageHR,
MaxHR: adr.MaxHR,
AverageTemp: adr.AverageTemp,
ElevationGain: adr.ElevationGain,
ElevationLoss: adr.ElevationLoss,
Weather: adr.Weather,
Gear: adr.Gear,
GPSTracks: adr.GPSTracks,
Calories: adr.Calories,
AverageHR: adr.AverageHR,
MaxHR: adr.MaxHR,
AverageTemp: adr.AverageTemp,
ElevationGain: adr.ElevationGain,
ElevationLoss: adr.ElevationLoss,
Weather: adr.Weather,
Gear: adr.Gear,
GPSTracks: adr.GPSTracks,
}
}
@@ -121,7 +120,7 @@ func (ar *ActivityResponse) ToActivity() Activity {
// Weather contains weather conditions during activity
type Weather struct {
Condition string `json:"condition"`
Temperature float64 `json:"temperature"`
Temperature float64 `json:"temperature"`
Humidity float64 `json:"humidity"`
}
@@ -225,47 +224,41 @@ func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (*Act
// UploadActivity handles FIT file uploads
func (c *Client) UploadActivity(ctx context.Context, fitFile []byte) (int64, error) {
path := "/upload-service/upload/.fit"
// Validate FIT file
if valid := fit.Validate(fitFile); !valid {
return 0, fmt.Errorf("invalid FIT file: signature verification failed")
if err := fit.ValidateFIT(fitFile); err != nil {
return 0, fmt.Errorf("invalid FIT file: %w", err)
}
// Prepare multipart form
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "activity.fit")
// Refresh token if needed
if err := c.refreshTokenIfNeeded(); err != nil {
return 0, err
}
path := "/upload-service/upload/.fit"
resp, err := c.HTTPClient.R().
SetContext(ctx).
SetFileReader("file", "activity.fit", bytes.NewReader(fitFile)).
SetHeader("Content-Type", "multipart/form-data").
Post(path)
if err != nil {
return 0, err
}
if _, err = io.Copy(part, bytes.NewReader(fitFile)); err != nil {
return 0, err
}
writer.Close()
fullURL := c.baseURL.ResolveReference(&url.URL{Path: path}).String()
req, err := http.NewRequestWithContext(ctx, "POST", fullURL, body)
if err != nil {
return 0, err
if resp.StatusCode() == http.StatusUnauthorized {
return 0, errors.New("token expired, please reauthenticate")
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return 0, fmt.Errorf("upload failed with status %d", resp.StatusCode)
if resp.StatusCode() >= 400 {
return 0, handleAPIError(resp)
}
// Parse response to get activity ID
var result struct {
ActivityID int64 `json:"activityId"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
if err := json.Unmarshal(resp.Body(), &result); err != nil {
return 0, err
}
@@ -274,35 +267,29 @@ func (c *Client) UploadActivity(ctx context.Context, fitFile []byte) (int64, err
// DownloadActivity retrieves a FIT file for an activity
func (c *Client) DownloadActivity(ctx context.Context, activityID int64) ([]byte, error) {
// Refresh token if needed
if err := c.refreshTokenIfNeeded(); err != nil {
return nil, err
}
path := fmt.Sprintf("/download-service/export/activity/%d", activityID)
fullURL := c.baseURL.ResolveReference(&url.URL{Path: path}).String()
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
resp, err := c.HTTPClient.R().
SetContext(ctx).
SetHeader("Accept", "application/fit").
Get(path)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/fit")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download failed with status %d", resp.StatusCode)
if resp.StatusCode() == http.StatusUnauthorized {
return nil, errors.New("token expired, please reauthenticate")
}
return io.ReadAll(resp.Body)
}
// Validate FIT file structure
func ValidateFIT(fitFile []byte) error {
if len(fitFile) < fit.MinFileSize() {
return fmt.Errorf("file too small to be a valid FIT file")
}
if string(fitFile[8:12]) != ".FIT" {
return fmt.Errorf("invalid FIT file signature")
}
return nil
if resp.StatusCode() >= 400 {
return nil, handleAPIError(resp)
}
return resp.Body(), nil
}

View File

@@ -2,15 +2,24 @@ package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"testing"
"time"
"github.com/sstent/go-garminconnect/internal/auth/garth"
"github.com/stretchr/testify/assert"
)
// TEST PROGRESS:
// - [ ] Move ValidateFIT to internal/fit package
// - [ ] Create unified mock server implementation
// - [ ] Extend mock server for upload handler
// - [ ] Remove ValidateFIT from this file
// - [ ] Create shared test helper package
// TestGetActivities is now part of table-driven tests below
func TestActivitiesEndpoints(t *testing.T) {
@@ -18,11 +27,15 @@ func TestActivitiesEndpoints(t *testing.T) {
mockServer := NewMockServer()
defer mockServer.Close()
// Create client with mock server URL
client, err := NewClient(mockServer.URL(), nil)
// Create a mock session
session := &garth.Session{OAuth2Token: "test-token"}
// Create client with mock server URL and session
client, err := NewClient(session, "")
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
client.HTTPClient.SetBaseURL(mockServer.URL())
testCases := []struct {
name string
@@ -126,6 +139,7 @@ func TestActivitiesEndpoints(t *testing.T) {
Name: fmt.Sprintf("Activity %d", i+1),
})
}
mockServer.SetActivitiesHandler(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(ActivitiesResponse{
Activities: activities,
@@ -136,7 +150,7 @@ func TestActivitiesEndpoints(t *testing.T) {
},
})
})
result, pagination, err := client.GetActivities(context.Background(), 1, 500)
assert.NoError(t, err)
assert.Len(t, result, 500)
@@ -201,38 +215,3 @@ func TestActivitiesEndpoints(t *testing.T) {
})
}
}
func TestValidateFIT(t *testing.T) {
testCases := []struct {
name string
data []byte
expected error
}{
{
name: "ValidFIT",
data: []byte{0x0E, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, '.', 'F', 'I', 'T', 0x00, 0x00},
expected: nil,
},
{
name: "TooSmall",
data: []byte{0x0E},
expected: fmt.Errorf("file too small to be a valid FIT file"),
},
{
name: "InvalidSignature",
data: []byte{0x0E, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 'I', 'N', 'V', 'L', 0x00, 0x00},
expected: fmt.Errorf("invalid FIT file signature"),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateFIT(tc.data)
if tc.expected == nil {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tc.expected.Error())
}
})
}
}

View File

@@ -10,25 +10,22 @@ import (
func (c *Client) GetBodyComposition(ctx context.Context, req BodyCompositionRequest) ([]BodyComposition, error) {
// Validate date range
if req.StartDate.IsZero() || req.EndDate.IsZero() || req.StartDate.After(req.EndDate) {
return nil, fmt.Errorf("invalid date range: start %s to end %s",
req.StartDate.Format("2006-01-02"),
return nil, fmt.Errorf("invalid date range: start %s to end %s",
req.StartDate.Format("2006-01-02"),
req.EndDate.Format("2006-01-02"))
}
// Build URL with query parameters
u := c.baseURL.ResolveReference(&url.URL{
Path: "/body-composition",
RawQuery: fmt.Sprintf("startDate=%s&endDate=%s",
req.StartDate.Format("2006-01-02"),
req.EndDate.Format("2006-01-02"),
),
})
// Build query parameters
params := url.Values{}
params.Add("startDate", req.StartDate.Format("2006-01-02"))
params.Add("endDate", req.EndDate.Format("2006-01-02"))
path := fmt.Sprintf("/body-composition?%s", params.Encode())
// Execute GET request
var results []BodyComposition
err := c.Get(ctx, u.String(), &results)
err := c.Get(ctx, path, &results)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to get body composition: %w", err)
}
return results, nil

View File

@@ -7,20 +7,25 @@ import (
"testing"
"time"
"github.com/sstent/go-garminconnect/internal/auth/garth"
"github.com/stretchr/testify/assert"
)
func TestGetBodyComposition(t *testing.T) {
// Create test server for mocking API responses
// Create mock session
session := &garth.Session{OAuth2Token: "valid-token"}
// Create test server for mocking API responses
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/body-composition?startDate=2023-01-01&endDate=2023-01-31", r.URL.String())
// Return different responses based on test cases
if r.Header.Get("Authorization") == "Bearer invalid-token" {
if r.Header.Get("Authorization") != "Bearer valid-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Query().Get("startDate") == "2023-02-01" {
w.WriteHeader(http.StatusBadRequest)
return
@@ -41,8 +46,9 @@ func TestGetBodyComposition(t *testing.T) {
defer server.Close()
// Setup client with test server
client := NewClient(server.URL, "valid-token")
client, _ := NewClient(session, "")
client.HTTPClient.SetBaseURL(server.URL)
// Test cases
testCases := []struct {
name string
@@ -54,12 +60,14 @@ func TestGetBodyComposition(t *testing.T) {
}{
{
name: "Successful request",
token: "valid-token",
token: "valid-token", // Test case doesn't actually change client token now
start: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2023, 1, 31, 0, 0, 0, 0, time.UTC),
expectError: false,
expectedLen: 1,
},
// Unauthorized test case is handled by the mock server's token check
// We need to create a new client with invalid token
{
name: "Unauthorized access",
token: "invalid-token",
@@ -78,7 +86,18 @@ func TestGetBodyComposition(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
client.token = tc.token
// For unauthorized test, create a separate client
if tc.token == "invalid-token" {
invalidSession := &garth.Session{OAuth2Token: "invalid-token"}
invalidClient, _ := NewClient(invalidSession, "")
invalidClient.HTTPClient.SetBaseURL(server.URL)
client = invalidClient
} else {
validSession := &garth.Session{OAuth2Token: "valid-token"}
validClient, _ := NewClient(validSession, "")
validClient.HTTPClient.SetBaseURL(server.URL)
client = validClient
}
results, err := client.GetBodyComposition(context.Background(), BodyCompositionRequest{
StartDate: Time(tc.start),
EndDate: Time(tc.end),
@@ -91,7 +110,7 @@ func TestGetBodyComposition(t *testing.T) {
assert.NoError(t, err)
assert.Len(t, results, tc.expectedLen)
if tc.expectedLen > 0 {
result := results[0]
assert.Equal(t, 2.8, result.BoneMass)

View File

@@ -3,137 +3,123 @@ package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"golang.org/x/time/rate"
"github.com/go-resty/resty/v2"
"github.com/sstent/go-garminconnect/internal/auth/garth"
)
const BaseURL = "https://connect.garmin.com/modern/proxy"
// Client handles communication with the Garmin Connect API
type Client struct {
baseURL *url.URL
httpClient *http.Client
limiter *rate.Limiter
logger Logger
token string
HTTPClient *resty.Client
sessionPath string
session *garth.Session
}
// NewClient creates a new API client
func NewClient(token string) (*Client, error) {
u, err := url.Parse(BaseURL)
if err != nil {
return nil, fmt.Errorf("invalid base URL: %w", err)
// NewClient creates a new API client with session management
func NewClient(session *garth.Session, sessionPath string) (*Client, error) {
if session == nil {
return nil, errors.New("session is required")
}
httpClient := &http.Client{
Timeout: 30 * time.Second,
}
client := resty.New()
client.SetTimeout(30 * time.Second)
client.SetHeader("Authorization", "Bearer "+session.OAuth2Token)
client.SetHeader("User-Agent", "go-garminconnect/1.0")
client.SetHeader("Content-Type", "application/json")
client.SetHeader("Accept", "application/json")
return &Client{
baseURL: u,
httpClient: httpClient,
limiter: rate.NewLimiter(rate.Every(time.Second/10), 10), // 10 requests per second
logger: &stdLogger{},
token: token,
HTTPClient: client,
sessionPath: sessionPath,
session: session,
}, nil
}
// SetLogger sets the client's logger
func (c *Client) SetLogger(logger Logger) {
c.logger = logger
}
// SetRateLimit configures the rate limiter
func (c *Client) SetRateLimit(interval time.Duration, burst int) {
c.limiter = rate.NewLimiter(rate.Every(interval), burst)
}
// setAuthHeaders adds authorization headers to requests
func (c *Client) setAuthHeaders(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("User-Agent", "go-garminconnect/1.0")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
}
// doRequest executes API requests with rate limiting and authentication
func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader, v interface{}) error {
// Wait for rate limiter
if err := c.limiter.Wait(ctx); err != nil {
return fmt.Errorf("rate limit wait failed: %w", err)
// Get performs a GET request with automatic token refresh
func (c *Client) Get(ctx context.Context, path string, v interface{}) error {
// Refresh token if needed
if err := c.refreshTokenIfNeeded(); err != nil {
return err
}
// Build full URL
fullURL := c.baseURL.ResolveReference(&url.URL{Path: path}).String()
resp, err := c.HTTPClient.R().
SetContext(ctx).
SetResult(v).
Get(path)
// Create request
req, err := http.NewRequestWithContext(ctx, method, fullURL, body)
if err != nil {
return fmt.Errorf("create request failed: %w", err)
return err
}
// Add authentication headers
c.setAuthHeaders(req)
// Execute request
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
if resp.StatusCode() == http.StatusUnauthorized {
// Force token refresh on next attempt
c.session = nil
return errors.New("token expired, please reauthenticate")
}
defer resp.Body.Close()
// Handle error responses
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if resp.StatusCode() >= 400 {
return handleAPIError(resp)
}
// Parse successful response
if v == nil {
return nil
}
// Post performs a POST request
func (c *Client) Post(ctx context.Context, path string, body interface{}, v interface{}) error {
resp, err := c.HTTPClient.R().
SetContext(ctx).
SetBody(body).
SetResult(v).
Post(path)
if err != nil {
return err
}
if resp.StatusCode() >= 400 {
return handleAPIError(resp)
}
return nil
}
// refreshTokenIfNeeded refreshes the token if expired
func (c *Client) refreshTokenIfNeeded() error {
if c.session == nil || !c.session.IsExpired() {
return nil
}
return json.NewDecoder(resp.Body).Decode(v)
if c.sessionPath == "" {
return errors.New("session path not configured for refresh")
}
session, err := garth.LoadSession(c.sessionPath)
if err != nil {
return fmt.Errorf("failed to load session for refresh: %w", err)
}
if session.IsExpired() {
return errors.New("session expired, please reauthenticate")
}
c.session = session
c.HTTPClient.SetHeader("Authorization", "Bearer "+session.OAuth2Token)
return nil
}
// handleAPIError processes non-200 responses
func handleAPIError(resp *http.Response) error {
func handleAPIError(resp *resty.Response) error {
errorResponse := struct {
Code int `json:"code"`
Message string `json:"message"`
}{}
if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err == nil {
if err := json.Unmarshal(resp.Body(), &errorResponse); err == nil {
return fmt.Errorf("API error %d: %s", errorResponse.Code, errorResponse.Message)
}
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
return fmt.Errorf("unexpected status code: %d", resp.StatusCode())
}
// Get performs a GET request
func (c *Client) Get(ctx context.Context, path string, v interface{}) error {
return c.doRequest(ctx, http.MethodGet, path, nil, v)
}
// Post performs a POST request
func (c *Client) Post(ctx context.Context, path string, body io.Reader, v interface{}) error {
return c.doRequest(ctx, http.MethodPost, path, body, v)
}
// Logger defines the logging interface
type Logger interface {
Debugf(format string, args ...interface{})
Infof(format string, args ...interface{})
Errorf(format string, args ...interface{})
}
// stdLogger is the default logger
type stdLogger struct{}
func (l *stdLogger) Debugf(format string, args ...interface{}) {}
func (l *stdLogger) Infof(format string, args ...interface{}) {}
func (l *stdLogger) Errorf(format string, args ...interface{}) {}

View File

@@ -10,29 +10,29 @@ import (
// GearStats represents detailed statistics for a gear item
type GearStats struct {
UUID string `json:"uuid"` // Unique identifier for the gear item
Name string `json:"name"` // Display name of the gear item
Distance float64 `json:"distance"` // in meters
TotalActivities int `json:"totalActivities"` // number of activities
TotalTime int `json:"totalTime"` // in seconds
Calories int `json:"calories"` // total calories
ElevationGain float64 `json:"elevationGain"` // in meters
ElevationLoss float64 `json:"elevationLoss"` // in meters
UUID string `json:"uuid"` // Unique identifier for the gear item
Name string `json:"name"` // Display name of the gear item
Distance float64 `json:"distance"` // in meters
TotalActivities int `json:"totalActivities"` // number of activities
TotalTime int `json:"totalTime"` // in seconds
Calories int `json:"calories"` // total calories
ElevationGain float64 `json:"elevationGain"` // in meters
ElevationLoss float64 `json:"elevationLoss"` // in meters
}
// GearActivity represents a simplified activity linked to a gear item
type GearActivity struct {
ActivityID int64 `json:"activityId"` // Activity identifier
ActivityName string `json:"activityName"` // Name of the activity
StartTime time.Time `json:"startTimeLocal"` // Local start time of the activity
Duration int `json:"duration"` // Duration in seconds
Distance float64 `json:"distance"` // Distance in meters
ActivityID int64 `json:"activityId"` // Activity identifier
ActivityName string `json:"activityName"` // Name of the activity
StartTime time.Time `json:"startTimeLocal"` // Local start time of the activity
Duration int `json:"duration"` // Duration in seconds
Distance float64 `json:"distance"` // Distance in meters
}
// GetGearStats retrieves statistics for a specific gear item by its UUID
func (c *Client) GetGearStats(ctx context.Context, gearUUID string) (GearStats, error) {
endpoint := fmt.Sprintf("/gear-service/stats/%s", gearUUID)
var stats GearStats
err := c.Get(ctx, endpoint, &stats)
if err != nil {
@@ -44,20 +44,15 @@ func (c *Client) GetGearStats(ctx context.Context, gearUUID string) (GearStats,
// GetGearActivities retrieves paginated activities associated with a gear item
func (c *Client) GetGearActivities(ctx context.Context, gearUUID string, start, limit int) ([]GearActivity, error) {
endpoint := fmt.Sprintf("/gear-service/activities/%s", gearUUID)
path := fmt.Sprintf("/gear-service/activities/%s", gearUUID)
params := url.Values{}
params.Add("start", strconv.Itoa(start))
params.Add("limit", strconv.Itoa(limit))
u := c.baseURL.ResolveReference(&url.URL{
Path: endpoint,
RawQuery: params.Encode(),
})
var activities []GearActivity
err := c.Get(ctx, u.String(), &activities)
err := c.Get(ctx, fmt.Sprintf("%s?%s", path, params.Encode()), &activities)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to get gear activities: %w", err)
}
return activities, nil

View File

@@ -10,6 +10,7 @@ import (
"testing"
"time"
"github.com/sstent/go-garminconnect/internal/auth/garth"
"github.com/stretchr/testify/assert"
)
@@ -37,7 +38,7 @@ func TestGearService(t *testing.T) {
activities := []GearActivity{
{ActivityID: 1, ActivityName: "Run 1", StartTime: time.Now(), Duration: 1800, Distance: 5000},
{ActivityID: 2, ActivityName: "Run 2", StartTime: time.Now().Add(-24*time.Hour), Duration: 3600, Distance: 10000},
{ActivityID: 2, ActivityName: "Run 2", StartTime: time.Now().Add(-24 * time.Hour), Duration: 3600, Distance: 10000},
}
// Simulate pagination
@@ -64,36 +65,39 @@ func TestGearService(t *testing.T) {
}))
defer srv.Close()
// Create mock session
session := &garth.Session{OAuth2Token: "test-token"}
// Create client
client, _ := NewClient(srv.URL, http.DefaultClient)
client.SetLogger(NewTestLogger(t))
client, _ := NewClient(session, "")
client.HTTPClient.SetBaseURL(srv.URL)
t.Run("GetGearStats success", func(t *testing.T) {
stats, err := client.GetGearStats("valid-uuid")
stats, err := client.GetGearStats(context.Background(), "valid-uuid")
assert.NoError(t, err)
assert.Equal(t, "Test Gear", stats.Name)
assert.Equal(t, 1500.5, stats.Distance)
})
t.Run("GetGearStats not found", func(t *testing.T) {
_, err := client.GetGearStats("invalid-uuid")
_, err := client.GetGearStats(context.Background(), "invalid-uuid")
assert.Error(t, err)
assert.Contains(t, err.Error(), "status code: 404")
assert.Contains(t, err.Error(), "API error")
})
t.Run("GetGearActivities pagination", func(t *testing.T) {
activities, err := client.GetGearActivities("valid-uuid", 0, 1)
activities, err := client.GetGearActivities(context.Background(), "valid-uuid", 0, 1)
assert.NoError(t, err)
assert.Len(t, activities, 1)
assert.Equal(t, "Run 1", activities[0].ActivityName)
activities, err = client.GetGearActivities("valid-uuid", 1, 1)
activities, err = client.GetGearActivities(context.Background(), "valid-uuid", 1, 1)
assert.NoError(t, err)
assert.Len(t, activities, 1)
assert.Equal(t, "Run 2", activities[0].ActivityName)
_, err = client.GetGearActivities("invalid-uuid", 0, 10)
_, err = client.GetGearActivities(context.Background(), "invalid-uuid", 0, 10)
assert.Error(t, err)
assert.Contains(t, err.Error(), "status code: 404")
assert.Contains(t, err.Error(), "API error")
})
}

View File

@@ -8,10 +8,10 @@ import (
// SleepData represents a user's sleep information
type SleepData struct {
Date time.Time `json:"date"`
Duration float64 `json:"duration"` // in minutes
Quality float64 `json:"quality"` // 0-100 scale
SleepStages struct {
Date time.Time `json:"date"`
Duration float64 `json:"duration"` // in minutes
Quality float64 `json:"quality"` // 0-100 scale
SleepStages struct {
Deep float64 `json:"deep"`
Light float64 `json:"light"`
REM float64 `json:"rem"`
@@ -21,26 +21,26 @@ type SleepData struct {
// HRVData represents Heart Rate Variability data
type HRVData struct {
Date time.Time `json:"date"`
RestingHrv float64 `json:"restingHrv"` // in milliseconds
WeeklyAvg float64 `json:"weeklyAvg"`
LastNightAvg float64 `json:"lastNightAvg"`
Date time.Time `json:"date"`
RestingHrv float64 `json:"restingHrv"` // in milliseconds
WeeklyAvg float64 `json:"weeklyAvg"`
LastNightAvg float64 `json:"lastNightAvg"`
}
// BodyBatteryData represents Garmin's Body Battery energy metric
type BodyBatteryData struct {
Date time.Time `json:"date"`
Charged int `json:"charged"` // 0-100 scale
Drained int `json:"drained"` // 0-100 scale
Highest int `json:"highest"` // highest value of the day
Lowest int `json:"lowest"` // lowest value of the day
Date time.Time `json:"date"`
Charged int `json:"charged"` // 0-100 scale
Drained int `json:"drained"` // 0-100 scale
Highest int `json:"highest"` // highest value of the day
Lowest int `json:"lowest"` // lowest value of the day
}
// GetSleepData retrieves sleep data for a specific date
func (c *Client) GetSleepData(ctx context.Context, date time.Time) (*SleepData, error) {
var data SleepData
path := fmt.Sprintf("/wellness-service/sleep/daily/%s", date.Format("2006-01-02"))
if err := c.Get(ctx, path, &data); err != nil {
return nil, fmt.Errorf("failed to get sleep data: %w", err)
}
@@ -51,7 +51,7 @@ func (c *Client) GetSleepData(ctx context.Context, date time.Time) (*SleepData,
func (c *Client) GetHRVData(ctx context.Context, date time.Time) (*HRVData, error) {
var data HRVData
path := fmt.Sprintf("/hrv-service/hrv/%s", date.Format("2006-01-02"))
if err := c.Get(ctx, path, &data); err != nil {
return nil, fmt.Errorf("failed to get HRV data: %w", err)
}
@@ -62,7 +62,7 @@ func (c *Client) GetHRVData(ctx context.Context, date time.Time) (*HRVData, erro
func (c *Client) GetBodyBatteryData(ctx context.Context, date time.Time) (*BodyBatteryData, error) {
var data BodyBatteryData
path := fmt.Sprintf("/bodybattery-service/bodybattery/%s", date.Format("2006-01-02"))
if err := c.Get(ctx, path, &data); err != nil {
return nil, fmt.Errorf("failed to get Body Battery data: %w", err)
}

View File

@@ -14,11 +14,11 @@ import (
func BenchmarkGetSleepData(b *testing.B) {
now := time.Now()
testDate := now.Format("2006-01-02")
// Create test server
mockServer := NewMockServer()
defer mockServer.Close()
// Setup successful response
mockResponse := map[string]interface{}{
"date": testDate,
@@ -47,11 +47,11 @@ func BenchmarkGetSleepData(b *testing.B) {
func BenchmarkGetHRVData(b *testing.B) {
now := time.Now()
testDate := now.Format("2006-01-02")
// Create test server
mockServer := NewMockServer()
defer mockServer.Close()
// Setup successful response
mockResponse := map[string]interface{}{
"date": testDate,
@@ -75,11 +75,11 @@ func BenchmarkGetHRVData(b *testing.B) {
func BenchmarkGetBodyBatteryData(b *testing.B) {
now := time.Now()
testDate := now.Format("2006-01-02")
// Create test server
mockServer := NewMockServer()
defer mockServer.Close()
// Setup successful response
mockResponse := map[string]interface{}{
"date": testDate,
@@ -103,7 +103,7 @@ func BenchmarkGetBodyBatteryData(b *testing.B) {
func TestGetSleepData(t *testing.T) {
now := time.Now()
testDate := now.Format("2006-01-02")
tests := []struct {
name string
date time.Time
@@ -191,7 +191,7 @@ func TestGetSleepData(t *testing.T) {
func TestGetHRVData(t *testing.T) {
now := time.Now()
testDate := now.Format("2006-01-02")
tests := []struct {
name string
date time.Time
@@ -211,9 +211,9 @@ func TestGetHRVData(t *testing.T) {
},
mockStatus: http.StatusOK,
expected: &HRVData{
Date: now.Truncate(24 * time.Hour),
RestingHrv: 65.0,
WeeklyAvg: 62.0,
Date: now.Truncate(24 * time.Hour),
RestingHrv: 65.0,
WeeklyAvg: 62.0,
LastNightAvg: 68.0,
},
},
@@ -255,7 +255,7 @@ func TestGetHRVData(t *testing.T) {
func TestGetBodyBatteryData(t *testing.T) {
now := time.Now()
testDate := now.Format("2006-01-02")
tests := []struct {
name string
date time.Time

View File

@@ -15,12 +15,12 @@ type MockServer struct {
mu sync.Mutex
// Endpoint handlers
activitiesHandler http.HandlerFunc
activitiesHandler http.HandlerFunc
activityDetailsHandler http.HandlerFunc
uploadHandler http.HandlerFunc
userHandler http.HandlerFunc
healthHandler http.HandlerFunc
authHandler http.HandlerFunc
uploadHandler http.HandlerFunc
userHandler http.HandlerFunc
healthHandler http.HandlerFunc
authHandler http.HandlerFunc
}
// NewMockServer creates a new mock Garmin Connect server
@@ -67,6 +67,13 @@ func (m *MockServer) SetActivitiesHandler(handler http.HandlerFunc) {
m.activitiesHandler = handler
}
// SetUploadHandler sets a custom handler for upload endpoint
func (m *MockServer) SetUploadHandler(handler http.HandlerFunc) {
m.mu.Lock()
defer m.mu.Unlock()
m.uploadHandler = handler
}
// Default handler implementations would follow for each endpoint
// ...

View File

@@ -24,11 +24,11 @@ func (t Time) Format(layout string) string {
// BodyComposition represents body composition metrics from Garmin Connect
type BodyComposition struct {
BoneMass float64 `json:"boneMass"` // Grams
MuscleMass float64 `json:"muscleMass"` // Grams
BodyFat float64 `json:"bodyFat"` // Percentage
Hydration float64 `json:"hydration"` // Percentage
Timestamp Time `json:"timestamp"` // Measurement time
BoneMass float64 `json:"boneMass"` // Grams
MuscleMass float64 `json:"muscleMass"` // Grams
BodyFat float64 `json:"bodyFat"` // Percentage
Hydration float64 `json:"hydration"` // Percentage
Timestamp Time `json:"timestamp"` // Measurement time
}
// BodyCompositionRequest defines parameters for body composition API requests

View File

@@ -8,17 +8,17 @@ import (
// UserProfile represents a Garmin Connect user profile
type UserProfile struct {
DisplayName string `json:"displayName"`
FullName string `json:"fullName"`
EmailAddress string `json:"emailAddress"`
Username string `json:"username"`
ProfileID string `json:"profileId"`
ProfileImage string `json:"profileImageUrlLarge"`
Location string `json:"location"`
FitnessLevel string `json:"fitnessLevel"`
Height float64 `json:"height"`
Weight float64 `json:"weight"`
Birthdate string `json:"birthDate"`
DisplayName string `json:"displayName"`
FullName string `json:"fullName"`
EmailAddress string `json:"emailAddress"`
Username string `json:"username"`
ProfileID string `json:"profileId"`
ProfileImage string `json:"profileImageUrlLarge"`
Location string `json:"location"`
FitnessLevel string `json:"fitnessLevel"`
Height float64 `json:"height"`
Weight float64 `json:"weight"`
Birthdate string `json:"birthDate"`
}
// UserStats represents fitness statistics for a user
@@ -35,16 +35,16 @@ type UserStats struct {
func (c *Client) GetUserProfile(ctx context.Context) (*UserProfile, error) {
var profile UserProfile
path := "/userprofile-service/socialProfile"
if err := c.Get(ctx, path, &profile); err != nil {
return nil, fmt.Errorf("failed to get user profile: %w", err)
}
// Handle empty profile response
if profile.ProfileID == "" {
return nil, fmt.Errorf("user profile not found")
}
return &profile, nil
}
@@ -52,7 +52,7 @@ func (c *Client) GetUserProfile(ctx context.Context) (*UserProfile, error) {
func (c *Client) GetUserStats(ctx context.Context, date time.Time) (*UserStats, error) {
var stats UserStats
path := fmt.Sprintf("/stats-service/stats/daily/%s", date.Format("2006-01-02"))
if err := c.Get(ctx, path, &stats); err != nil {
return nil, fmt.Errorf("failed to get user stats: %w", err)
}

View File

@@ -22,31 +22,31 @@ func TestGetUserProfile(t *testing.T) {
{
name: "successful profile retrieval",
mockResponse: map[string]interface{}{
"displayName": "John Doe",
"fullName": "John Michael Doe",
"emailAddress": "john.doe@example.com",
"username": "johndoe",
"profileId": "123456",
"displayName": "John Doe",
"fullName": "John Michael Doe",
"emailAddress": "john.doe@example.com",
"username": "johndoe",
"profileId": "123456",
"profileImageUrlLarge": "https://example.com/profile.jpg",
"location": "San Francisco, CA",
"fitnessLevel": "INTERMEDIATE",
"height": 180.0,
"weight": 75.0,
"birthDate": "1985-01-01",
"location": "San Francisco, CA",
"fitnessLevel": "INTERMEDIATE",
"height": 180.0,
"weight": 75.0,
"birthDate": "1985-01-01",
},
mockStatus: http.StatusOK,
expected: &UserProfile{
DisplayName: "John Doe",
FullName: "John Michael Doe",
EmailAddress: "john.doe@example.com",
Username: "johndoe",
ProfileID: "123456",
ProfileImage: "https://example.com/profile.jpg",
Location: "San Francisco, CA",
FitnessLevel: "INTERMEDIATE",
Height: 180.0,
Weight: 75.0,
Birthdate: "1985-01-01",
DisplayName: "John Doe",
FullName: "John Michael Doe",
EmailAddress: "john.doe@example.com",
Username: "johndoe",
ProfileID: "123456",
ProfileImage: "https://example.com/profile.jpg",
Location: "San Francisco, CA",
FitnessLevel: "INTERMEDIATE",
Height: 180.0,
Weight: 75.0,
Birthdate: "1985-01-01",
},
},
{
@@ -109,20 +109,20 @@ func BenchmarkGetUserProfile(b *testing.B) {
// Create test server
mockServer := NewMockServer()
defer mockServer.Close()
// Setup successful response
mockResponse := map[string]interface{}{
"displayName": "Benchmark User",
"fullName": "Benchmark User Full",
"emailAddress": "benchmark@example.com",
"username": "benchmark",
"profileId": "benchmark-123",
"displayName": "Benchmark User",
"fullName": "Benchmark User Full",
"emailAddress": "benchmark@example.com",
"username": "benchmark",
"profileId": "benchmark-123",
"profileImageUrlLarge": "https://example.com/benchmark.jpg",
"location": "Benchmark City",
"fitnessLevel": "ADVANCED",
"height": 185.0,
"weight": 80.0,
"birthDate": "1990-01-01",
"location": "Benchmark City",
"fitnessLevel": "ADVANCED",
"height": 185.0,
"weight": 80.0,
"birthDate": "1990-01-01",
}
mockServer.SetResponse("/userprofile-service/socialProfile", http.StatusOK, mockResponse)
@@ -139,19 +139,19 @@ func BenchmarkGetUserProfile(b *testing.B) {
func BenchmarkGetUserStats(b *testing.B) {
now := time.Now()
testDate := now.Format("2006-01-02")
// Create test server
mockServer := NewMockServer()
defer mockServer.Close()
// Setup successful response
mockResponse := map[string]interface{}{
"totalSteps": 15000,
"totalDistance": 12000.0,
"totalCalories": 3000,
"activeMinutes": 60,
"totalSteps": 15000,
"totalDistance": 12000.0,
"totalCalories": 3000,
"activeMinutes": 60,
"restingHeartRate": 50,
"date": testDate,
"date": testDate,
}
path := fmt.Sprintf("/stats-service/stats/daily/%s", now.Format("2006-01-02"))
mockServer.SetResponse(path, http.StatusOK, mockResponse)
@@ -168,7 +168,7 @@ func BenchmarkGetUserStats(b *testing.B) {
func TestGetUserStats(t *testing.T) {
now := time.Now()
testDate := now.Format("2006-01-02")
// Define test cases
tests := []struct {
name string
@@ -182,12 +182,12 @@ func TestGetUserStats(t *testing.T) {
name: "successful stats retrieval",
date: now,
mockResponse: map[string]interface{}{
"totalSteps": 10000,
"totalDistance": 8500.5,
"totalCalories": 2200,
"activeMinutes": 45,
"totalSteps": 10000,
"totalDistance": 8500.5,
"totalCalories": 2200,
"activeMinutes": 45,
"restingHeartRate": 55,
"date": testDate,
"date": testDate,
},
mockStatus: http.StatusOK,
expected: &UserStats{