This commit is contained in:
2025-08-27 11:58:01 -07:00
parent f24d21033a
commit f4b9f350ae
25 changed files with 2184 additions and 485 deletions

View File

@@ -6,12 +6,9 @@ import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"time"
@@ -231,8 +228,8 @@ func (c *Client) UploadActivity(ctx context.Context, fitFile []byte) (int64, err
path := "/upload-service/upload/.fit"
// Validate FIT file
if err := fit.Validate(fitFile); err != nil {
return 0, fmt.Errorf("invalid FIT file: %w", err)
if valid := fit.Validate(fitFile); !valid {
return 0, fmt.Errorf("invalid FIT file: signature verification failed")
}
// Prepare multipart form
@@ -247,7 +244,8 @@ func (c *Client) UploadActivity(ctx context.Context, fitFile []byte) (int64, err
}
writer.Close()
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+path, body)
fullURL := c.baseURL.ResolveReference(&url.URL{Path: path}).String()
req, err := http.NewRequestWithContext(ctx, "POST", fullURL, body)
if err != nil {
return 0, err
}
@@ -278,7 +276,8 @@ func (c *Client) UploadActivity(ctx context.Context, fitFile []byte) (int64, err
func (c *Client) DownloadActivity(ctx context.Context, activityID int64) ([]byte, error) {
path := fmt.Sprintf("/download-service/export/activity/%d", activityID)
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+path, nil)
fullURL := c.baseURL.ResolveReference(&url.URL{Path: path}).String()
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
if err != nil {
return nil, err
}
@@ -294,12 +293,12 @@ func (c *Client) DownloadActivity(ctx context.Context, activityID int64) ([]byte
return nil, fmt.Errorf("download failed with status %d", resp.StatusCode)
}
return ioutil.ReadAll(resp.Body)
return io.ReadAll(resp.Body)
}
// Validate FIT file structure
func ValidateFIT(fitFile []byte) error {
if len(fitFile) < fit.MinFileSize {
if len(fitFile) < fit.MinFileSize() {
return fmt.Errorf("file too small to be a valid FIT file")
}
if string(fitFile[8:12]) != ".FIT" {

View File

@@ -2,156 +2,237 @@ package api
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestGetActivities(t *testing.T) {
// TestGetActivities is now part of table-driven tests below
func TestActivitiesEndpoints(t *testing.T) {
// Create mock server
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Accept both escaped and unescaped versions
expected1 := "/activitylist-service/activities/search?page=1&pageSize=10"
expected2 := "/activitylist-service/activities/search%3Fpage=1&pageSize=10"
if r.URL.String() != expected1 && r.URL.String() != expected2 {
t.Errorf("Unexpected URL: %s", r.URL.String())
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"activities": [
{
"activityId": 123,
"activityName": "Morning Run",
"activityType": "RUNNING",
"startTimeLocal": "2023-07-15T08:00:00",
"duration": 3600,
"distance": 10000
mockServer := NewMockServer()
defer mockServer.Close()
// Create client with mock server URL
client, err := NewClient(mockServer.URL(), nil)
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
testCases := []struct {
name string
testFunc func(t *testing.T, client *Client)
description string
}{
{
name: "GetActivitiesSuccess",
description: "Test successful activity list retrieval",
testFunc: func(t *testing.T, client *Client) {
activities, pagination, err := client.GetActivities(context.Background(), 1, 10)
assert.NoError(t, err)
assert.Len(t, activities, 1)
assert.Equal(t, int64(1), activities[0].ActivityID)
assert.Equal(t, "Morning Run", activities[0].Name)
assert.Equal(t, 1, pagination.Page)
assert.Equal(t, 10, pagination.PageSize)
},
},
{
name: "GetActivityDetailsSuccess",
description: "Test successful activity details retrieval",
testFunc: func(t *testing.T, client *Client) {
activity, err := client.GetActivityDetails(context.Background(), 1)
assert.NoError(t, err)
assert.Equal(t, int64(1), activity.ActivityID)
assert.Equal(t, "Mock Activity", activity.Name)
assert.Equal(t, 150, activity.AverageHR)
assert.Equal(t, "RUNNING", activity.Type)
},
},
{
name: "GetActivitiesServerError",
description: "Test server error handling for activity list",
testFunc: func(t *testing.T, client *Client) {
mockServer.SetActivitiesHandler(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
_, _, err := client.GetActivities(context.Background(), 1, 10)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to get activities")
},
},
{
name: "GetActivityDetailsNotFound",
description: "Test not found error for activity details",
testFunc: func(t *testing.T, client *Client) {
mockServer.SetActivitiesHandler(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
_, err := client.GetActivityDetails(context.Background(), 999)
assert.Error(t, err)
assert.Contains(t, err.Error(), "resource not found")
},
},
{
name: "GetActivitiesInvalidPagination",
description: "Test invalid pagination parameters",
testFunc: func(t *testing.T, client *Client) {
_, _, err := client.GetActivities(context.Background(), 0, 0)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid pagination parameters")
},
},
{
name: "GetActivitiesTimeout",
description: "Test request timeout handling",
testFunc: func(t *testing.T, client *Client) {
mockServer.SetActivitiesHandler(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second) // Simulate delay
})
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, _, err := client.GetActivities(ctx, 1, 10)
assert.Error(t, err)
assert.Contains(t, err.Error(), "context deadline exceeded")
},
},
{
name: "GetActivitiesInvalidResponse",
description: "Test invalid response handling",
testFunc: func(t *testing.T, client *Client) {
mockServer.SetActivitiesHandler(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("invalid json"))
})
_, _, err := client.GetActivities(context.Background(), 1, 10)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse response")
},
},
{
name: "GetActivitiesLargeDataset",
description: "Test handling of large activity datasets",
testFunc: func(t *testing.T, client *Client) {
// Create large dataset
var activities []ActivityResponse
for i := 0; i < 500; i++ {
activities = append(activities, ActivityResponse{
ActivityID: int64(i + 1),
Name: fmt.Sprintf("Activity %d", i+1),
})
}
],
"pagination": {
"pageSize": 10,
"totalCount": 1,
"page": 1
mockServer.SetActivitiesHandler(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(ActivitiesResponse{
Activities: activities,
Pagination: Pagination{
Page: 1,
PageSize: 500,
TotalCount: 500,
},
})
})
result, pagination, err := client.GetActivities(context.Background(), 1, 500)
assert.NoError(t, err)
assert.Len(t, result, 500)
assert.Equal(t, 500, pagination.TotalCount)
},
},
{
name: "GetActivityDetailsInvalidResponse",
description: "Test invalid activity details response",
testFunc: func(t *testing.T, client *Client) {
mockServer.SetActivitiesHandler(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("invalid json"))
})
_, err := client.GetActivityDetails(context.Background(), 1)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse response")
},
},
{
name: "GetActivityDetailsMalformedID",
description: "Test handling of malformed activity ID in server response",
testFunc: func(t *testing.T, client *Client) {
mockServer.SetActivitiesHandler(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"activityId": "invalid"}`)) // Should be number
})
_, err := client.GetActivityDetails(context.Background(), 1)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse response")
},
},
{
name: "UploadActivitySuccess",
description: "Test successful activity upload",
testFunc: func(t *testing.T, client *Client) {
id, err := client.UploadActivity(context.Background(), []byte("test fit data"))
assert.NoError(t, err)
assert.Equal(t, int64(12345), id)
},
},
{
name: "UploadActivityInvalidData",
description: "Test uploading invalid FIT data",
testFunc: func(t *testing.T, client *Client) {
mockServer.SetUploadHandler(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error": "Invalid FIT file"}`))
})
_, err := client.UploadActivity(context.Background(), []byte("invalid"))
assert.Error(t, err)
assert.Contains(t, err.Error(), "upload failed with status 400")
},
},
}
// Run test cases
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Log(tc.description)
tc.testFunc(t, client)
})
}
}
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())
}
}`))
}))
defer testServer.Close()
// Create client with mock server URL
client, err := NewClient(testServer.URL, nil)
if err != nil {
t.Fatalf("failed to create client: %v", err)
})
}
// Execute test
activities, pagination, err := client.GetActivities(context.Background(), 1, 10)
// Validate results
assert.NoError(t, err)
assert.Len(t, activities, 1)
assert.Equal(t, int64(123), activities[0].ActivityID)
assert.Equal(t, "Morning Run", activities[0].Name)
assert.Equal(t, 1, pagination.Page)
assert.Equal(t, 10, pagination.PageSize)
}
func TestGetActivityDetails(t *testing.T) {
// Create mock server
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/activity-service/activity/123", r.URL.Path)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"activityId": 123,
"activityName": "Morning Run",
"activityType": "RUNNING",
"startTimeLocal": "2023-07-15T08:00:00",
"duration": 3600,
"distance": 10000,
"calories": 720,
"averageHR": 145,
"maxHR": 172,
"averageTemperature": 22.5,
"elevationGain": 150,
"elevationLoss": 150,
"weather": {
"condition": "SUNNY",
"temperature": 20,
"humidity": 60
},
"gear": {
"gearId": "shoes-001",
"name": "Running Shoes",
"model": "UltraBoost",
"description": "Primary running shoes"
},
"gpsTracks": [
{
"lat": 37.7749,
"lon": -122.4194,
"ele": 10,
"timestamp": "2023-07-15T08:00:00"
}
]
}`))
}))
defer testServer.Close()
// Create client with mock server URL
client, err := NewClient(testServer.URL, nil)
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
// Execute test
activity, err := client.GetActivityDetails(context.Background(), 123)
// Validate results
assert.NoError(t, err)
assert.Equal(t, int64(123), activity.ActivityID)
assert.Equal(t, "Morning Run", activity.Name)
assert.Equal(t, 145, activity.AverageHR)
assert.Equal(t, 720.0, activity.Calories)
assert.Equal(t, "SUNNY", activity.Weather.Condition)
assert.Equal(t, "Running Shoes", activity.Gear.Name)
assert.Len(t, activity.GPSTracks, 1)
}
func TestGetActivities_ErrorHandling(t *testing.T) {
// Create mock server that returns error
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer testServer.Close()
// Create client with mock server URL
client, err := NewClient(testServer.URL, nil)
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
// Execute test
_, _, err = client.GetActivities(context.Background(), 1, 10)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to get activities")
}
func TestGetActivityDetails_NotFound(t *testing.T) {
// Create mock server that returns 404
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer testServer.Close()
// Create client with mock server URL
client, err := NewClient(testServer.URL, nil)
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
// Execute test
_, err = client.GetActivityDetails(context.Background(), 999)
assert.Error(t, err)
assert.Contains(t, err.Error(), "resource not found")
}

View File

@@ -0,0 +1,35 @@
package api
import (
"context"
"fmt"
"net/url"
)
// GetBodyComposition retrieves body composition data within a date range
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"),
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"),
),
})
// Execute GET request
var results []BodyComposition
err := c.Get(ctx, u.String(), &results)
if err != nil {
return nil, err
}
return results, nil
}

View File

@@ -0,0 +1,104 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestGetBodyComposition(t *testing.T) {
// 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" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Query().Get("startDate") == "2023-02-01" {
w.WriteHeader(http.StatusBadRequest)
return
}
// Successful response
w.WriteHeader(http.StatusOK)
w.Write([]byte(`[
{
"boneMass": 2.8,
"muscleMass": 55.2,
"bodyFat": 15.3,
"hydration": 58.7,
"timestamp": "2023-01-15T08:00:00.000Z"
}
]`))
}))
defer server.Close()
// Setup client with test server
client := NewClient(server.URL, "valid-token")
// Test cases
testCases := []struct {
name string
token string
start time.Time
end time.Time
expectError bool
expectedLen int
}{
{
name: "Successful request",
token: "valid-token",
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,
},
{
name: "Unauthorized access",
token: "invalid-token",
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: true,
},
{
name: "Invalid date range",
token: "valid-token",
start: time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2023, 1, 31, 0, 0, 0, 0, time.UTC),
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
client.token = tc.token
results, err := client.GetBodyComposition(context.Background(), BodyCompositionRequest{
StartDate: Time(tc.start),
EndDate: Time(tc.end),
})
if tc.expectError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Len(t, results, tc.expectedLen)
if tc.expectedLen > 0 {
result := results[0]
assert.Equal(t, 2.8, result.BoneMass)
assert.Equal(t, 55.2, result.MuscleMass)
assert.Equal(t, 15.3, result.BodyFat)
assert.Equal(t, 58.7, result.Hydration)
}
})
}
}

View File

@@ -12,24 +12,26 @@ import (
"golang.org/x/time/rate"
)
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
Gear *GearService
token string
}
// NewClient creates a new API client
func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
u, err := url.Parse(baseURL)
func NewClient(token string) (*Client, error) {
u, err := url.Parse(BaseURL)
if err != nil {
return nil, fmt.Errorf("invalid base URL: %w", err)
}
if httpClient == nil {
httpClient = http.DefaultClient
httpClient := &http.Client{
Timeout: 30 * time.Second,
}
return &Client{
@@ -37,7 +39,7 @@ func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
httpClient: httpClient,
limiter: rate.NewLimiter(rate.Every(time.Second/10), 10), // 10 requests per second
logger: &stdLogger{},
Gear: &GearService{},
token: token,
}, nil
}
@@ -51,6 +53,67 @@ 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)
}
// Build full URL
fullURL := c.baseURL.ResolveReference(&url.URL{Path: path}).String()
// Create request
req, err := http.NewRequestWithContext(ctx, method, fullURL, body)
if err != nil {
return fmt.Errorf("create request failed: %w", 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)
}
defer resp.Body.Close()
// Handle error responses
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return handleAPIError(resp)
}
// Parse successful response
if v == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(v)
}
// handleAPIError processes non-200 responses
func handleAPIError(resp *http.Response) error {
errorResponse := struct {
Code int `json:"code"`
Message string `json:"message"`
}{}
if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err == nil {
return fmt.Errorf("API error %d: %s", errorResponse.Code, errorResponse.Message)
}
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)
@@ -61,64 +124,14 @@ func (c *Client) Post(ctx context.Context, path string, body io.Reader, v interf
return c.doRequest(ctx, http.MethodPost, path, body, v)
}
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)
}
// Create request
u := c.baseURL.ResolveReference(&url.URL{Path: path})
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
if err != nil {
return fmt.Errorf("create request failed: %w", err)
}
// Set headers
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
c.logger.Debugf("Request: %s %s", method, u.String())
// Execute request
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
c.logger.Debugf("Response status: %s", resp.Status)
// Handle specific status codes
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("resource not found")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
// Parse response
if v == nil {
return nil
}
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
return fmt.Errorf("decode response failed: %w", err)
}
return nil
}
// Logger defines the logging interface for the client
// 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 that uses the standard log package
// stdLogger is the default logger
type stdLogger struct{}
func (l *stdLogger) Debugf(format string, args ...interface{}) {}

View File

@@ -1,9 +1,8 @@
package api
import (
"encoding/json"
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
@@ -30,17 +29,12 @@ type GearActivity struct {
Distance float64 `json:"distance"` // Distance in meters
}
// GetGearStats retrieves statistics for a specific gear item by its UUID.
// Returns a GearStats struct containing gear usage metrics or an error.
func (c *Client) GetGearStats(gearUUID string) (GearStats, error) {
endpoint := "gear-service/stats/" + gearUUID
req, err := c.newRequest(http.MethodGet, endpoint, nil)
if err != nil {
return GearStats{}, err
}
// 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.do(req, &stats)
err := c.Get(ctx, endpoint, &stats)
if err != nil {
return GearStats{}, err
}
@@ -48,36 +42,23 @@ func (c *Client) GetGearStats(gearUUID string) (GearStats, error) {
return stats, nil
}
// GetGearActivities retrieves paginated activities associated with a gear item.
// start: pagination start index
// limit: maximum number of results to return
// Returns a slice of GearActivity structs or an error.
func (c *Client) GetGearActivities(gearUUID string, start, limit int) ([]GearActivity, error) {
endpoint := "gear-service/activities/" + gearUUID
// 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)
params := url.Values{}
params.Add("start", strconv.Itoa(start))
params.Add("limit", strconv.Itoa(limit))
req, err := c.newRequest(http.MethodGet, endpoint+"?"+params.Encode(), nil)
if err != nil {
return nil, err
}
u := c.baseURL.ResolveReference(&url.URL{
Path: endpoint,
RawQuery: params.Encode(),
})
var activities []GearActivity
_, err = c.do(req, &activities)
err := c.Get(ctx, u.String(), &activities)
if err != nil {
return nil, err
}
return activities, nil
}
// formatDuration converts total seconds to HH:MM:SS time format.
// Primarily used for displaying activity durations in a human-readable format.
func formatDuration(seconds int) string {
d := time.Duration(seconds) * time.Second
hours := int(d.Hours())
minutes := int(d.Minutes()) % 60
seconds = int(d.Seconds()) % 60
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
}

70
internal/api/health.go Normal file
View File

@@ -0,0 +1,70 @@
package api
import (
"context"
"fmt"
"time"
)
// 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 {
Deep float64 `json:"deep"`
Light float64 `json:"light"`
REM float64 `json:"rem"`
Awake float64 `json:"awake"`
} `json:"sleepStages"`
}
// 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"`
}
// 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
}
// 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)
}
return &data, nil
}
// GetHRVData retrieves Heart Rate Variability data for a specific date
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)
}
return &data, nil
}
// GetBodyBatteryData retrieves Body Battery data for a specific date
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)
}
return &data, nil
}

319
internal/api/health_test.go Normal file
View File

@@ -0,0 +1,319 @@
package api
import (
"context"
"fmt"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// BenchmarkGetSleepData measures performance of GetSleepData method
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,
"duration": 480.0,
"quality": 85.0,
"sleepStages": map[string]interface{}{
"deep": 120.0,
"light": 240.0,
"rem": 90.0,
"awake": 30.0,
},
}
path := fmt.Sprintf("/wellness-service/sleep/daily/%s", now.Format("2006-01-02"))
mockServer.SetResponse(path, http.StatusOK, mockResponse)
// Create client
client := NewClientWithBaseURL(mockServer.URL)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = client.GetSleepData(context.Background(), now)
}
}
// BenchmarkGetHRVData measures performance of GetHRVData method
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,
"restingHrv": 65.0,
"weeklyAvg": 62.0,
"lastNightAvg": 68.0,
}
path := fmt.Sprintf("/hrv-service/hrv/%s", now.Format("2006-01-02"))
mockServer.SetResponse(path, http.StatusOK, mockResponse)
// Create client
client := NewClientWithBaseURL(mockServer.URL)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = client.GetHRVData(context.Background(), now)
}
}
// BenchmarkGetBodyBatteryData measures performance of GetBodyBatteryData method
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,
"charged": 85,
"drained": 45,
"highest": 95,
"lowest": 30,
}
path := fmt.Sprintf("/bodybattery-service/bodybattery/%s", now.Format("2006-01-02"))
mockServer.SetResponse(path, http.StatusOK, mockResponse)
// Create client
client := NewClientWithBaseURL(mockServer.URL)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = client.GetBodyBatteryData(context.Background(), now)
}
}
func TestGetSleepData(t *testing.T) {
now := time.Now()
testDate := now.Format("2006-01-02")
tests := []struct {
name string
date time.Time
mockResponse interface{}
mockStatus int
expected *SleepData
expectedError string
}{
{
name: "successful sleep data retrieval",
date: now,
mockResponse: map[string]interface{}{
"date": testDate,
"duration": 480.0,
"quality": 85.0,
"sleepStages": map[string]interface{}{
"deep": 120.0,
"light": 240.0,
"rem": 90.0,
"awake": 30.0,
},
},
mockStatus: http.StatusOK,
expected: &SleepData{
Date: now.Truncate(24 * time.Hour),
Duration: 480.0,
Quality: 85.0,
SleepStages: struct {
Deep float64 `json:"deep"`
Light float64 `json:"light"`
REM float64 `json:"rem"`
Awake float64 `json:"awake"`
}{
Deep: 120.0,
Light: 240.0,
REM: 90.0,
Awake: 30.0,
},
},
},
{
name: "sleep data not found",
date: now,
mockResponse: map[string]interface{}{
"error": "No sleep data found",
},
mockStatus: http.StatusNotFound,
expectedError: "failed to get sleep data",
},
{
name: "invalid sleep response",
date: now,
mockResponse: map[string]interface{}{
"invalid": "data",
},
mockStatus: http.StatusOK,
expectedError: "failed to parse sleep data",
},
}
mockServer := NewMockServer()
defer mockServer.Close()
client := NewClientWithBaseURL(mockServer.URL)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockServer.Reset()
path := fmt.Sprintf("/wellness-service/sleep/daily/%s", tt.date.Format("2006-01-02"))
mockServer.SetResponse(path, tt.mockStatus, tt.mockResponse)
data, err := client.GetSleepData(context.Background(), tt.date)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
assert.Nil(t, data)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, data)
}
})
}
}
func TestGetHRVData(t *testing.T) {
now := time.Now()
testDate := now.Format("2006-01-02")
tests := []struct {
name string
date time.Time
mockResponse interface{}
mockStatus int
expected *HRVData
expectedError string
}{
{
name: "successful HRV data retrieval",
date: now,
mockResponse: map[string]interface{}{
"date": testDate,
"restingHrv": 65.0,
"weeklyAvg": 62.0,
"lastNightAvg": 68.0,
},
mockStatus: http.StatusOK,
expected: &HRVData{
Date: now.Truncate(24 * time.Hour),
RestingHrv: 65.0,
WeeklyAvg: 62.0,
LastNightAvg: 68.0,
},
},
{
name: "HRV data not available",
date: now,
mockResponse: map[string]interface{}{
"error": "No HRV data",
},
mockStatus: http.StatusNotFound,
expectedError: "failed to get HRV data",
},
}
mockServer := NewMockServer()
defer mockServer.Close()
client := NewClientWithBaseURL(mockServer.URL)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockServer.Reset()
path := fmt.Sprintf("/hrv-service/hrv/%s", tt.date.Format("2006-01-02"))
mockServer.SetResponse(path, tt.mockStatus, tt.mockResponse)
data, err := client.GetHRVData(context.Background(), tt.date)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
assert.Nil(t, data)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, data)
}
})
}
}
func TestGetBodyBatteryData(t *testing.T) {
now := time.Now()
testDate := now.Format("2006-01-02")
tests := []struct {
name string
date time.Time
mockResponse interface{}
mockStatus int
expected *BodyBatteryData
expectedError string
}{
{
name: "successful body battery retrieval",
date: now,
mockResponse: map[string]interface{}{
"date": testDate,
"charged": 85,
"drained": 45,
"highest": 95,
"lowest": 30,
},
mockStatus: http.StatusOK,
expected: &BodyBatteryData{
Date: now.Truncate(24 * time.Hour),
Charged: 85,
Drained: 45,
Highest: 95,
Lowest: 30,
},
},
{
name: "body battery data missing",
date: now,
mockResponse: map[string]interface{}{
"error": "Body battery data unavailable",
},
mockStatus: http.StatusNotFound,
expectedError: "failed to get Body Battery data",
},
}
mockServer := NewMockServer()
defer mockServer.Close()
client := NewClientWithBaseURL(mockServer.URL)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockServer.Reset()
path := fmt.Sprintf("/bodybattery-service/bodybattery/%s", tt.date.Format("2006-01-02"))
mockServer.SetResponse(path, tt.mockStatus, tt.mockResponse)
data, err := client.GetBodyBatteryData(context.Background(), tt.date)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
assert.Nil(t, data)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, data)
}
})
}
}

View File

@@ -0,0 +1,46 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"time"
)
// BodyCompositionHandler handles mock responses for body composition endpoint
func BodyCompositionHandler(w http.ResponseWriter, r *http.Request) {
// Validate parameters
start := r.URL.Query().Get("startDate")
end := r.URL.Query().Get("endDate")
if start == "" || end == "" || start > end {
w.WriteHeader(http.StatusBadRequest)
return
}
// Return different responses based on test cases
if r.Header.Get("Authorization") == "" || strings.Contains(r.Header.Get("Authorization"), "invalid") {
w.WriteHeader(http.StatusUnauthorized)
return
}
// Successful response
data := []BodyComposition{
{
BoneMass: 2.8,
MuscleMass: 55.2,
BodyFat: 15.3,
Hydration: 58.7,
Timestamp: Time(parseTime("2023-01-15T08:00:00Z")),
},
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(data)
}
// parseTime helper for creating time values in mock handlers
func parseTime(s string) time.Time {
t, _ := time.Parse(time.RFC3339, s)
return t
}

View File

@@ -0,0 +1,189 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
)
// MockServer simulates the Garmin Connect API
type MockServer struct {
server *httptest.Server
mu sync.Mutex
// Endpoint handlers
activitiesHandler http.HandlerFunc
activityDetailsHandler http.HandlerFunc
uploadHandler http.HandlerFunc
userHandler http.HandlerFunc
healthHandler http.HandlerFunc
authHandler http.HandlerFunc
}
// NewMockServer creates a new mock Garmin Connect server
func NewMockServer() *MockServer {
m := &MockServer{}
m.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
defer m.mu.Unlock()
switch {
case strings.HasPrefix(r.URL.Path, "/activity-service/activities"):
m.handleActivities(w, r)
case strings.HasPrefix(r.URL.Path, "/activity-service/activity/"):
m.handleActivityDetails(w, r)
case strings.HasPrefix(r.URL.Path, "/upload-service/upload"):
m.handleUpload(w, r)
case strings.HasPrefix(r.URL.Path, "/user-service/user"):
m.handleUserData(w, r)
case strings.HasPrefix(r.URL.Path, "/health-service"):
m.handleHealthData(w, r)
case strings.HasPrefix(r.URL.Path, "/auth"):
m.handleAuth(w, r)
default:
http.Error(w, "Not found", http.StatusNotFound)
}
}))
return m
}
// URL returns the base URL of the mock server
func (m *MockServer) URL() string {
return m.server.URL
}
// Close shuts down the mock server
func (m *MockServer) Close() {
m.server.Close()
}
// SetActivitiesHandler sets a custom handler for activities endpoint
func (m *MockServer) SetActivitiesHandler(handler http.HandlerFunc) {
m.mu.Lock()
defer m.mu.Unlock()
m.activitiesHandler = handler
}
// Default handler implementations would follow for each endpoint
// ...
// handleActivities is the default activities endpoint handler
func (m *MockServer) handleActivities(w http.ResponseWriter, r *http.Request) {
if m.activitiesHandler != nil {
m.activitiesHandler(w, r)
return
}
// Default implementation
activities := []ActivityResponse{
{
ActivityID: 1,
Name: "Morning Run",
StartTime: garminTime{time.Now().Add(-24 * time.Hour)},
Duration: 3600,
Distance: 10.0,
},
}
json.NewEncoder(w).Encode(ActivitiesResponse{
Activities: activities,
Pagination: Pagination{
Page: 1,
PageSize: 10,
TotalCount: 1,
},
})
}
// handleActivityDetails is the default activity details endpoint handler
func (m *MockServer) handleActivityDetails(w http.ResponseWriter, r *http.Request) {
if m.activityDetailsHandler != nil {
m.activityDetailsHandler(w, r)
return
}
// Extract activity ID from path
pathParts := strings.Split(r.URL.Path, "/")
activityID, err := strconv.ParseInt(pathParts[len(pathParts)-1], 10, 64)
if err != nil {
http.Error(w, "Invalid activity ID", http.StatusBadRequest)
return
}
activity := ActivityDetailResponse{
ActivityResponse: ActivityResponse{
ActivityID: activityID,
Name: "Mock Activity",
Type: "RUNNING",
StartTime: garminTime{time.Now().Add(-24 * time.Hour)},
Duration: 3600,
Distance: 10.0,
},
Calories: 500,
AverageHR: 150,
MaxHR: 170,
ElevationGain: 100,
}
json.NewEncoder(w).Encode(activity)
}
// handleUpload is the default activity upload handler
func (m *MockServer) handleUpload(w http.ResponseWriter, r *http.Request) {
if m.uploadHandler != nil {
m.uploadHandler(w, r)
return
}
// Simulate successful upload
response := map[string]interface{}{
"activityId": 12345,
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
// handleUserData is the default user data handler
func (m *MockServer) handleUserData(w http.ResponseWriter, r *http.Request) {
if m.userHandler != nil {
m.userHandler(w, r)
return
}
// Return mock user data
user := map[string]interface{}{
"displayName": "Mock User",
"email": "mock@example.com",
}
json.NewEncoder(w).Encode(user)
}
// handleHealthData is the default health data handler
func (m *MockServer) handleHealthData(w http.ResponseWriter, r *http.Request) {
if m.healthHandler != nil {
m.healthHandler(w, r)
return
}
// Return mock health data
data := map[string]interface{}{
"bodyBattery": 90,
"stress": 35,
"sleep": map[string]interface{}{
"duration": 480,
"quality": 85,
},
}
json.NewEncoder(w).Encode(data)
}
// handleAuth is the default authentication handler
func (m *MockServer) handleAuth(w http.ResponseWriter, r *http.Request) {
if m.authHandler != nil {
m.authHandler(w, r)
return
}
// Simulate successful authentication
response := map[string]interface{}{
"token": "mock-token-123",
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}

38
internal/api/types.go Normal file
View File

@@ -0,0 +1,38 @@
package api
import (
"time"
)
// Time represents a Garmin Connect time value
type Time time.Time
// IsZero checks if the time is zero value
func (t Time) IsZero() bool {
return time.Time(t).IsZero()
}
// After reports whether t is after u
func (t Time) After(u Time) bool {
return time.Time(t).After(time.Time(u))
}
// Format formats the time using the provided layout
func (t Time) Format(layout string) string {
return time.Time(t).Format(layout)
}
// 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
}
// BodyCompositionRequest defines parameters for body composition API requests
type BodyCompositionRequest struct {
StartDate Time `json:"startDate"`
EndDate Time `json:"endDate"`
}

View File

@@ -3,6 +3,7 @@ package api
import (
"context"
"fmt"
"time"
)
// UserProfile represents a Garmin Connect user profile
@@ -20,6 +21,16 @@ type UserProfile struct {
Birthdate string `json:"birthDate"`
}
// UserStats represents fitness statistics for a user
type UserStats struct {
TotalSteps int `json:"totalSteps"`
TotalDistance float64 `json:"totalDistance"` // in meters
TotalCalories int `json:"totalCalories"`
ActiveMinutes int `json:"activeMinutes"`
RestingHR int `json:"restingHeartRate"`
Date time.Time `json:"date"`
}
// GetUserProfile retrieves the user's profile information
func (c *Client) GetUserProfile(ctx context.Context) (*UserProfile, error) {
var profile UserProfile
@@ -36,3 +47,14 @@ func (c *Client) GetUserProfile(ctx context.Context) (*UserProfile, error) {
return &profile, nil
}
// GetUserStats retrieves fitness statistics for a user for a specific date
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)
}
return &stats, nil
}

259
internal/api/user_test.go Normal file
View File

@@ -0,0 +1,259 @@
package api
import (
"context"
"fmt"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestGetUserProfile(t *testing.T) {
// Define test cases
tests := []struct {
name string
mockResponse interface{}
mockStatus int
expected *UserProfile
expectedError string
}{
{
name: "successful profile retrieval",
mockResponse: map[string]interface{}{
"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",
},
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",
},
},
{
name: "profile not found",
mockResponse: map[string]interface{}{
"error": "Profile not found",
},
mockStatus: http.StatusNotFound,
expectedError: "user profile not found",
},
{
name: "invalid response format",
mockResponse: map[string]interface{}{
"invalid": "data",
},
mockStatus: http.StatusOK,
expectedError: "failed to parse user profile",
},
{
name: "server error",
mockResponse: map[string]interface{}{
"error": "Internal server error",
},
mockStatus: http.StatusInternalServerError,
expectedError: "API request failed with status 500",
},
}
// Create test server
mockServer := NewMockServer()
defer mockServer.Close()
// Create client
client := NewClientWithBaseURL(mockServer.URL)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Configure mock server
mockServer.Reset()
mockServer.SetResponse("/userprofile-service/socialProfile", tt.mockStatus, tt.mockResponse)
// Execute test
profile, err := client.GetUserProfile(context.Background())
// Assert results
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
assert.Nil(t, profile)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, profile)
}
})
}
}
// BenchmarkGetUserProfile measures performance of GetUserProfile method
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",
"profileImageUrlLarge": "https://example.com/benchmark.jpg",
"location": "Benchmark City",
"fitnessLevel": "ADVANCED",
"height": 185.0,
"weight": 80.0,
"birthDate": "1990-01-01",
}
mockServer.SetResponse("/userprofile-service/socialProfile", http.StatusOK, mockResponse)
// Create client
client := NewClientWithBaseURL(mockServer.URL)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = client.GetUserProfile(context.Background())
}
}
// BenchmarkGetUserStats measures performance of GetUserStats method
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,
"restingHeartRate": 50,
"date": testDate,
}
path := fmt.Sprintf("/stats-service/stats/daily/%s", now.Format("2006-01-02"))
mockServer.SetResponse(path, http.StatusOK, mockResponse)
// Create client
client := NewClientWithBaseURL(mockServer.URL)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = client.GetUserStats(context.Background(), now)
}
}
func TestGetUserStats(t *testing.T) {
now := time.Now()
testDate := now.Format("2006-01-02")
// Define test cases
tests := []struct {
name string
date time.Time
mockResponse interface{}
mockStatus int
expected *UserStats
expectedError string
}{
{
name: "successful stats retrieval",
date: now,
mockResponse: map[string]interface{}{
"totalSteps": 10000,
"totalDistance": 8500.5,
"totalCalories": 2200,
"activeMinutes": 45,
"restingHeartRate": 55,
"date": testDate,
},
mockStatus: http.StatusOK,
expected: &UserStats{
TotalSteps: 10000,
TotalDistance: 8500.5,
TotalCalories: 2200,
ActiveMinutes: 45,
RestingHR: 55,
Date: now.Truncate(24 * time.Hour), // Date without time component
},
},
{
name: "stats not found for date",
date: now,
mockResponse: map[string]interface{}{
"error": "No stats found",
},
mockStatus: http.StatusNotFound,
expectedError: "failed to get user stats",
},
{
name: "future date error",
date: now.AddDate(0, 0, 1),
mockResponse: map[string]interface{}{
"error": "Date cannot be in the future",
},
mockStatus: http.StatusBadRequest,
expectedError: "API request failed with status 400",
},
{
name: "invalid stats response",
date: now,
mockResponse: map[string]interface{}{
"invalid": "data",
},
mockStatus: http.StatusOK,
expectedError: "failed to parse user stats",
},
}
// Create test server
mockServer := NewMockServer()
defer mockServer.Close()
// Create client
client := NewClientWithBaseURL(mockServer.URL)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Configure mock server
mockServer.Reset()
path := fmt.Sprintf("/stats-service/stats/daily/%s", tt.date.Format("2006-01-02"))
mockServer.SetResponse(path, tt.mockStatus, tt.mockResponse)
// Execute test
stats, err := client.GetUserStats(context.Background(), tt.date)
// Assert results
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
assert.Nil(t, stats)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, stats)
}
})
}
}