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

@@ -10,7 +10,7 @@
- [x] Initial documentation added - [x] Initial documentation added
### Phase 2: Authentication System ### Phase 2: Authentication System
- [x] OAuth1 authentication implemented - [x] OAuth2 authentication with MFA support implemented
- [x] Token storage with file-based system - [x] Token storage with file-based system
- [x] MFA handling support - [x] MFA handling support
- [x] Authentication tests - [x] Authentication tests
@@ -28,6 +28,10 @@
### Phase 5: FIT Handling ### Phase 5: FIT Handling
- [x] Basic FIT decoder implementation - [x] Basic FIT decoder implementation
- [x] Streaming FIT encoder implementation
- io.WriteSeeker interface
- Incremental CRC calculation
- Memory efficient for large files
## How to Run the Application ## How to Run the Application
@@ -87,6 +91,5 @@ docker compose up -d --build
- [x] Added comprehensive tests for session persistence - [x] Added comprehensive tests for session persistence
## Next Steps ## Next Steps
- Create streaming FIT encoder
- Add comprehensive test coverage for all endpoints - Add comprehensive test coverage for all endpoints
- Improve error handling and logging - Improve error handling and logging

View File

@@ -23,7 +23,7 @@
- [x] Add basic README with project overview - [x] Add basic README with project overview
### Phase 2: Authentication Implementation ### Phase 2: Authentication Implementation
- [x] Implement OAuth2 authentication flow - [x] Implement OAuth2 authentication flow with MFA support
- [x] Create token storage interface - [x] Create token storage interface
- [x] Implement session management with auto-refresh - [x] Implement session management with auto-refresh
- [x] Handle MFA authentication - [x] Handle MFA authentication
@@ -71,7 +71,10 @@
- Implemented core encoder with header/CRC - Implemented core encoder with header/CRC
- Added support for activity messages - Added support for activity messages
- [x] Implement weight composition encoding - [x] Implement weight composition encoding
- [ ] Create streaming FIT encoder - [x] Create streaming FIT encoder
- Supports io.WriteSeeker interface
- Implements incremental CRC calculation
- Handles large files with minimal memory
- [x] Add FIT parser - [x] Add FIT parser
### Phase 6: Testing & Quality ### Phase 6: Testing & Quality

84
TODO.md
View File

@@ -1,34 +1,60 @@
# Go GarminConnect Port Implementation Plan # Go-GarminConnect Porting Project - Remaining Tasks
## Phase 1: Setup & Core Structure ## Endpoint Implementation
- [x] Initialize Go module ### Health Data Endpoints
- [x] Create directory structure - [ ] Body composition API endpoint
- [x] Set up CI/CD pipeline basics - [ ] Sleep data retrieval and parsing
- [x] Create basic Docker infrastructure - [ ] Heart rate/HRV/RHR data endpoint
- [x] Add initial documentation - [ ] Stress data API implementation
- [ ] Body battery endpoint
## Phase 2: Authentication System ### User Data Endpoints
- [ ] Implement OAuth2 flow - [ ] User summary endpoint
- [ ] Create token storage interface - [ ] Daily statistics API
- [ ] Add MFA handling - [ ] Goals/badges endpoint implementation
- [ ] Write authentication tests - [ ] Hydration data endpoint
- [ ] Respiration data API
## Phase 3: API Client Core ### Activity Endpoints
- [ ] Define Client struct - [ ] Activity type filtering
- [ ] Implement request/response handling - [ ] Activity comment functionality
- [ ] Add error handling - [ ] Activity like/unlike feature
- [ ] Setup logging - [ ] Activity sharing options
- [ ] Implement rate limiting
## Phase 4: Endpoint Implementation ## FIT File Handling
- [ ] Port user profile endpoint - [ ] Complete weight composition encoding
- [ ] Port activities endpoints - [ ] Implement all-day stress FIT encoding
- [ ] Port health data endpoints - [ ] Add HRV data to FIT export
- [ ] Implement pagination handling - [ ] Validate FIT compatibility with Garmin devices
- [ ] Add response validation - [ ] Optimize FIT file parsing performance
## Phase 5: FIT Handling ## Testing & Quality Assurance
- [x] Create FIT decoder - [ ] Implement table-driven tests for all endpoints
- [ ] Implement FIT encoder - [ ] Create mock server for isolated testing
- [ ] Add FIT file tests - [ ] Add golden file tests for FIT validation
- [ ] Integrate with activity endpoints - [ ] Complete performance benchmarks
- [ ] Integrate static analysis (golangci-lint)
- [ ] Implement code coverage reporting
- [ ] Add stress/load testing scenarios
## Documentation & Examples
- [ ] Complete GoDoc coverage for all packages
- [ ] Create usage examples for all API endpoints
- [ ] Build CLI demonstration application
- [ ] Port Python examples to Go equivalents
- [ ] Update README with comprehensive documentation
- [ ] Create migration guide from Python library
## Infrastructure & Optimization
- [ ] Implement connection pooling
- [ ] Complete rate limiting mechanism
- [ ] Optimize session management
- [ ] Add automatic token refresh tests
- [ ] Implement response caching
- [ ] Add circuit breaker pattern for API calls
## Project Management
- [ ] Prioritize health data endpoints (critical path)
- [ ] Create GitHub project board for tracking
- [ ] Set up milestone tracking
- [ ] Assign priority labels (P0, P1, P2)

66
cmd/garmin-cli/main.go Normal file
View File

@@ -0,0 +1,66 @@
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/sstent/go-garminconnect/internal/api"
"github.com/sstent/go-garminconnect/internal/auth"
)
func main() {
// Verify required environment variables
if os.Getenv("GARMIN_USERNAME") == "" || os.Getenv("GARMIN_PASSWORD") == "" {
fmt.Println("GARMIN_USERNAME and GARMIN_PASSWORD must be set")
os.Exit(1)
}
// Set up authentication client
client := auth.NewAuthClient()
token, err := client.Authenticate(
context.Background(),
os.Getenv("GARMIN_USERNAME"),
os.Getenv("GARMIN_PASSWORD"),
"", // MFA token if needed
)
if err != nil {
fmt.Printf("Authentication failed: %v\n", err)
os.Exit(1)
}
// Create API client
apiClient, err := api.NewClient(token.AccessToken)
if err != nil {
fmt.Printf("Failed to create API client: %v\n", err)
os.Exit(1)
}
// Parse date range (default: last 7 days)
endDate := time.Now()
startDate := endDate.AddDate(0, 0, -7)
// Get body composition data
composition, err := apiClient.GetBodyComposition(context.Background(), api.BodyCompositionRequest{
StartDate: api.Time(startDate),
EndDate: api.Time(endDate),
})
if err != nil {
fmt.Printf("Failed to get body composition: %v\n", err)
os.Exit(1)
}
// Print results
fmt.Println("Body Composition Data:")
fmt.Println("Date\t\tBone Mass\tMuscle Mass\tBody Fat\tHydration")
for _, entry := range composition {
fmt.Printf("%s\t%.1fg\t\t%.1fg\t\t%.1f%%\t\t%.1f%%\n",
time.Time(entry.Timestamp).Format("2006-01-02"),
entry.BoneMass,
entry.MuscleMass,
entry.BodyFat,
entry.Hydration,
)
}
}

View File

@@ -1,38 +1,50 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"github.com/sstent/go-garminconnect/internal/auth" "github.com/sstent/go-garminconnect/internal/auth"
"github.com/sstent/go-garminconnect/internal/api"
) )
func main() { func main() {
// Get consumer key and secret from environment // Get credentials from environment
consumerKey := os.Getenv("GARMIN_CONSUMER_KEY") username := os.Getenv("GARMIN_USERNAME")
consumerSecret := os.Getenv("GARMIN_CONSUMER_SECRET") password := os.Getenv("GARMIN_PASSWORD")
if consumerKey == "" || consumerSecret == "" { if username == "" || password == "" {
fmt.Println("GARMIN_CONSUMER_KEY and GARMIN_CONSUMER_SECRET must be set") fmt.Println("GARMIN_USERNAME and GARMIN_PASSWORD must be set")
os.Exit(1) os.Exit(1)
} }
// Configure authentication // Create authentication client
oauthConfig := &auth.OAuthConfig{ authClient := auth.NewAuthClient()
ConsumerKey: consumerKey,
ConsumerSecret: consumerSecret, // Authenticate with credentials
token, err := authClient.Authenticate(context.Background(), username, password, "")
if err != nil {
fmt.Printf("Authentication failed: %v\n", err)
os.Exit(1)
} }
// Set up token storage // API client not currently used in this simple server
tokenStorage := auth.NewFileStorage() // It's created here for demonstration purposes only
_, err = api.NewClient(token.AccessToken)
if err != nil {
fmt.Printf("Failed to create API client: %v\n", err)
os.Exit(1)
}
// Create HTTP server // Create HTTP server
http.HandleFunc("/", homeHandler) http.HandleFunc("/", homeHandler)
http.HandleFunc("/login", loginHandler(oauthConfig, tokenStorage))
http.HandleFunc("/callback", callbackHandler(oauthConfig, tokenStorage))
http.HandleFunc("/mfa", auth.MFAHandler)
http.HandleFunc("/health", healthHandler) http.HandleFunc("/health", healthHandler)
// For demonstration purposes, print API client status
// This line was removed because baseURL is unexported
// fmt.Printf("API client initialized for %s\n", apiClient.baseURL)
fmt.Println("Server listening on :8080") fmt.Println("Server listening on :8080")
http.ListenAndServe(":8080", nil) http.ListenAndServe(":8080", nil)
} }
@@ -42,27 +54,13 @@ func homeHandler(w http.ResponseWriter, r *http.Request) {
<html> <html>
<body> <body>
<h1>Go GarminConnect Client</h1> <h1>Go GarminConnect Client</h1>
<a href="/login">Login with Garmin</a> <p>Authentication successful! API client ready.</p>
</body> </body>
</html> </html>
`)) `))
} }
func loginHandler(config *auth.OAuthConfig, storage auth.TokenStorage) http.HandlerFunc { // Removed OAuth handlers since we're using credentials-based auth
return func(w http.ResponseWriter, r *http.Request) {
auth.Authenticate(w, r, config, storage)
}
}
func callbackHandler(config *auth.OAuthConfig, storage auth.TokenStorage) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// In a real app, we'd retrieve the request secret from session storage
// For now, we'll use a placeholder
requestSecret := "placeholder-secret"
auth.Callback(w, r, config, storage, requestSecret)
}
}
func healthHandler(w http.ResponseWriter, r *http.Request) { func healthHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)

View File

@@ -10,6 +10,15 @@ services:
networks: networks:
- garmin-net - garmin-net
test:
image: golang:1.25
working_dir: /app
volumes:
- ../:/app
command: go test ./...
networks:
- garmin-net
networks: networks:
garmin-net: garmin-net:
driver: bridge driver: bridge

View File

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

View File

@@ -2,156 +2,237 @@ package api
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"net/http/httptest" "strconv"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "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 // Create mock server
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mockServer := NewMockServer()
// Accept both escaped and unescaped versions defer mockServer.Close()
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
}
],
"pagination": {
"pageSize": 10,
"totalCount": 1,
"page": 1
}
}`))
}))
defer testServer.Close()
// Create client with mock server URL // Create client with mock server URL
client, err := NewClient(testServer.URL, nil) client, err := NewClient(mockServer.URL(), nil)
if err != nil { if err != nil {
t.Fatalf("failed to create client: %v", err) t.Fatalf("failed to create client: %v", err)
} }
// Execute test 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) activities, pagination, err := client.GetActivities(context.Background(), 1, 10)
// Validate results
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, activities, 1) assert.Len(t, activities, 1)
assert.Equal(t, int64(123), activities[0].ActivityID) assert.Equal(t, int64(1), activities[0].ActivityID)
assert.Equal(t, "Morning Run", activities[0].Name) assert.Equal(t, "Morning Run", activities[0].Name)
assert.Equal(t, 1, pagination.Page) assert.Equal(t, 1, pagination.Page)
assert.Equal(t, 10, pagination.PageSize) 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, name: "GetActivityDetailsSuccess",
"lon": -122.4194, description: "Test successful activity details retrieval",
"ele": 10, testFunc: func(t *testing.T, client *Client) {
"timestamp": "2023-07-15T08:00:00" activity, err := client.GetActivityDetails(context.Background(), 1)
}
]
}`))
}))
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.NoError(t, err)
assert.Equal(t, int64(123), activity.ActivityID) assert.Equal(t, int64(1), activity.ActivityID)
assert.Equal(t, "Morning Run", activity.Name) assert.Equal(t, "Mock Activity", activity.Name)
assert.Equal(t, 145, activity.AverageHR) assert.Equal(t, 150, activity.AverageHR)
assert.Equal(t, 720.0, activity.Calories) assert.Equal(t, "RUNNING", activity.Type)
assert.Equal(t, "SUNNY", activity.Weather.Condition) },
assert.Equal(t, "Running Shoes", activity.Gear.Name) },
assert.Len(t, activity.GPSTracks, 1) {
} name: "GetActivitiesServerError",
description: "Test server error handling for activity list",
func TestGetActivities_ErrorHandling(t *testing.T) { testFunc: func(t *testing.T, client *Client) {
// Create mock server that returns error mockServer.SetActivitiesHandler(func(w http.ResponseWriter, r *http.Request) {
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
})) })
defer testServer.Close() _, _, err := client.GetActivities(context.Background(), 1, 10)
// 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.Error(t, err)
assert.Contains(t, err.Error(), "failed to get activities") assert.Contains(t, err.Error(), "failed to get activities")
} },
},
func TestGetActivityDetails_NotFound(t *testing.T) { {
// Create mock server that returns 404 name: "GetActivityDetailsNotFound",
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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) w.WriteHeader(http.StatusNotFound)
})) })
defer testServer.Close() _, err := client.GetActivityDetails(context.Background(), 999)
// 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.Error(t, err)
assert.Contains(t, err.Error(), "resource not found") 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),
})
}
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())
}
})
}
} }

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" "golang.org/x/time/rate"
) )
const BaseURL = "https://connect.garmin.com/modern/proxy"
// Client handles communication with the Garmin Connect API // Client handles communication with the Garmin Connect API
type Client struct { type Client struct {
baseURL *url.URL baseURL *url.URL
httpClient *http.Client httpClient *http.Client
limiter *rate.Limiter limiter *rate.Limiter
logger Logger logger Logger
Gear *GearService token string
} }
// NewClient creates a new API client // NewClient creates a new API client
func NewClient(baseURL string, httpClient *http.Client) (*Client, error) { func NewClient(token string) (*Client, error) {
u, err := url.Parse(baseURL) u, err := url.Parse(BaseURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid base URL: %w", err) return nil, fmt.Errorf("invalid base URL: %w", err)
} }
if httpClient == nil { httpClient := &http.Client{
httpClient = http.DefaultClient Timeout: 30 * time.Second,
} }
return &Client{ return &Client{
@@ -37,7 +39,7 @@ func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
httpClient: httpClient, httpClient: httpClient,
limiter: rate.NewLimiter(rate.Every(time.Second/10), 10), // 10 requests per second limiter: rate.NewLimiter(rate.Every(time.Second/10), 10), // 10 requests per second
logger: &stdLogger{}, logger: &stdLogger{},
Gear: &GearService{}, token: token,
}, nil }, nil
} }
@@ -51,6 +53,67 @@ func (c *Client) SetRateLimit(interval time.Duration, burst int) {
c.limiter = rate.NewLimiter(rate.Every(interval), burst) 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 // Get performs a GET request
func (c *Client) Get(ctx context.Context, path string, v interface{}) error { func (c *Client) Get(ctx context.Context, path string, v interface{}) error {
return c.doRequest(ctx, http.MethodGet, path, nil, v) 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) 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 { // Logger defines the logging interface
// 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
type Logger interface { type Logger interface {
Debugf(format string, args ...interface{}) Debugf(format string, args ...interface{})
Infof(format string, args ...interface{}) Infof(format string, args ...interface{})
Errorf(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{} type stdLogger struct{}
func (l *stdLogger) Debugf(format string, args ...interface{}) {} func (l *stdLogger) Debugf(format string, args ...interface{}) {}

View File

@@ -1,9 +1,8 @@
package api package api
import ( import (
"encoding/json" "context"
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"strconv" "strconv"
"time" "time"
@@ -30,17 +29,12 @@ type GearActivity struct {
Distance float64 `json:"distance"` // Distance in meters Distance float64 `json:"distance"` // Distance in meters
} }
// GetGearStats retrieves statistics for a specific gear item by its UUID. // 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(ctx context.Context, gearUUID string) (GearStats, error) {
func (c *Client) GetGearStats(gearUUID string) (GearStats, error) { endpoint := fmt.Sprintf("/gear-service/stats/%s", gearUUID)
endpoint := "gear-service/stats/" + gearUUID
req, err := c.newRequest(http.MethodGet, endpoint, nil)
if err != nil {
return GearStats{}, err
}
var stats GearStats var stats GearStats
_, err = c.do(req, &stats) err := c.Get(ctx, endpoint, &stats)
if err != nil { if err != nil {
return GearStats{}, err return GearStats{}, err
} }
@@ -48,36 +42,23 @@ func (c *Client) GetGearStats(gearUUID string) (GearStats, error) {
return stats, nil return stats, nil
} }
// GetGearActivities retrieves paginated activities associated with a gear item. // GetGearActivities retrieves paginated activities associated with a gear item
// start: pagination start index func (c *Client) GetGearActivities(ctx context.Context, gearUUID string, start, limit int) ([]GearActivity, error) {
// limit: maximum number of results to return endpoint := fmt.Sprintf("/gear-service/activities/%s", gearUUID)
// 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
params := url.Values{} params := url.Values{}
params.Add("start", strconv.Itoa(start)) params.Add("start", strconv.Itoa(start))
params.Add("limit", strconv.Itoa(limit)) params.Add("limit", strconv.Itoa(limit))
req, err := c.newRequest(http.MethodGet, endpoint+"?"+params.Encode(), nil) u := c.baseURL.ResolveReference(&url.URL{
if err != nil { Path: endpoint,
return nil, err RawQuery: params.Encode(),
} })
var activities []GearActivity var activities []GearActivity
_, err = c.do(req, &activities) err := c.Get(ctx, u.String(), &activities)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return activities, nil 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 ( import (
"context" "context"
"fmt" "fmt"
"time"
) )
// UserProfile represents a Garmin Connect user profile // UserProfile represents a Garmin Connect user profile
@@ -20,6 +21,16 @@ type UserProfile struct {
Birthdate string `json:"birthDate"` 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 // GetUserProfile retrieves the user's profile information
func (c *Client) GetUserProfile(ctx context.Context) (*UserProfile, error) { func (c *Client) GetUserProfile(ctx context.Context) (*UserProfile, error) {
var profile UserProfile var profile UserProfile
@@ -36,3 +47,14 @@ func (c *Client) GetUserProfile(ctx context.Context) (*UserProfile, error) {
return &profile, nil 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)
}
})
}
}

View File

@@ -1,92 +1,148 @@
package auth package auth
import ( import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http" "net/http"
"net/url"
"github.com/dghubble/oauth1" "strings"
"time"
) )
// OAuthConfig holds OAuth1 configuration for Garmin Connect // Authenticate handles Garmin Connect authentication with MFA support
type OAuthConfig struct { func (c *AuthClient) Authenticate(ctx context.Context, username, password, mfaToken string) (*Token, error) {
ConsumerKey string // Create login form data
ConsumerSecret string data := url.Values{}
} data.Set("username", username)
data.Set("password", password)
data.Set("embed", "false")
data.Set("rememberme", "on")
// TokenStorage defines the interface for storing and retrieving OAuth tokens // Create login request
type TokenStorage interface { loginURL := fmt.Sprintf("%s%s", c.BaseURL, c.LoginPath)
GetToken() (*oauth1.Token, error) req, err := http.NewRequestWithContext(ctx, "POST", loginURL, strings.NewReader(data.Encode()))
SaveToken(*oauth1.Token) error
}
// Authenticate initiates the OAuth1 authentication flow
func Authenticate(w http.ResponseWriter, r *http.Request, config *OAuthConfig, storage TokenStorage) {
// Create OAuth1 config
oauthConfig := oauth1.Config{
ConsumerKey: config.ConsumerKey,
ConsumerSecret: config.ConsumerSecret,
CallbackURL: "http://localhost:8080/callback",
Endpoint: oauth1.Endpoint{
RequestTokenURL: "https://connect.garmin.com/oauth-service/oauth/request_token",
AuthorizeURL: "https://connect.garmin.com/oauth-service/oauth/authorize",
AccessTokenURL: "https://connect.garmin.com/oauth-service/oauth/access_token",
},
}
// Get request token
requestToken, _, err := oauthConfig.RequestToken()
if err != nil { if err != nil {
http.Error(w, "Failed to get request token", http.StatusInternalServerError) return nil, fmt.Errorf("failed to create login request: %w", err)
return
} }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; go-garminconnect/1.0)")
// Save request token secret temporarily (for callback) // Send login request
// In a real application, you'd store this in a session resp, err := c.Client.Do(req)
// Redirect to authorization URL
authURL, err := oauthConfig.AuthorizationURL(requestToken)
if err != nil { if err != nil {
http.Error(w, "Failed to get authorization URL", http.StatusInternalServerError) return nil, fmt.Errorf("login request failed: %w", err)
return }
defer resp.Body.Close()
// Check if MFA is required
if resp.StatusCode == http.StatusUnauthorized {
// Parse MFA response
var mfaResponse struct {
MFAToken string `json:"mfaToken"`
}
if err := json.NewDecoder(resp.Body).Decode(&mfaResponse); err != nil {
return nil, fmt.Errorf("failed to parse MFA response: %w", err)
} }
http.Redirect(w, r, authURL.String(), http.StatusTemporaryRedirect) // Validate MFA token
if mfaToken == "" {
return nil, errors.New("MFA required but no token provided")
} }
// Callback handles OAuth1 callback // Create MFA verification request
func Callback(w http.ResponseWriter, r *http.Request, config *OAuthConfig, storage TokenStorage, requestSecret string) { mfaData := url.Values{}
// Get request token and verifier from query params mfaData.Set("token", mfaResponse.MFAToken)
requestToken := r.URL.Query().Get("oauth_token") mfaData.Set("rememberme", "on")
verifier := r.URL.Query().Get("oauth_verifier") mfaData.Set("mfaCode", mfaToken)
// Create OAuth1 config req, err := http.NewRequestWithContext(ctx, "POST", loginURL, strings.NewReader(mfaData.Encode()))
oauthConfig := oauth1.Config{
ConsumerKey: config.ConsumerKey,
ConsumerSecret: config.ConsumerSecret,
Endpoint: oauth1.Endpoint{
RequestTokenURL: "https://connect.garmin.com/oauth-service/oauth/request_token",
AccessTokenURL: "https://connect.garmin.com/oauth-service/oauth/access_token",
},
}
// Get access token
accessToken, accessSecret, err := oauthConfig.AccessToken(requestToken, requestSecret, verifier)
if err != nil { if err != nil {
http.Error(w, "Failed to get access token", http.StatusInternalServerError) return nil, fmt.Errorf("failed to create MFA request: %w", err)
return
} }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; go-garminconnect/1.0)")
// Create token and save // Send MFA request
token := &oauth1.Token{ resp, err = c.Client.Do(req)
Token: accessToken,
TokenSecret: accessSecret,
}
err = storage.SaveToken(token)
if err != nil { if err != nil {
http.Error(w, "Failed to save token", http.StatusInternalServerError) return nil, fmt.Errorf("MFA request failed: %w", err)
return }
defer resp.Body.Close()
} }
w.WriteHeader(http.StatusOK) // Handle non-200 responses
w.Write([]byte("Authentication successful!")) if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("authentication failed: %d\n%s", resp.StatusCode, body)
}
// Parse response cookies to get tokens
var token Token
cookies := resp.Cookies()
for _, cookie := range cookies {
if cookie.Name == "access_token" {
token.AccessToken = cookie.Value
} else if cookie.Name == "refresh_token" {
token.RefreshToken = cookie.Value
}
}
// Validate tokens
if token.AccessToken == "" || token.RefreshToken == "" {
return nil, errors.New("tokens not found in authentication response")
}
// Set expiration time
token.Expiry = time.Now().Add(time.Duration(3600) * time.Second)
token.ExpiresIn = 3600
token.TokenType = "Bearer"
return &token, nil
}
// RefreshToken exchanges a refresh token for a new access token
func (c *AuthClient) RefreshToken(ctx context.Context, token *Token) (*Token, error) {
// Create token refresh data
data := url.Values{}
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", token.RefreshToken)
// Create refresh token request
req, err := http.NewRequestWithContext(ctx, "POST", c.TokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; go-garminconnect/1.0)")
// Send refresh token request
resp, err := c.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("token refresh failed: %w", err)
}
defer resp.Body.Close()
// Handle non-200 responses
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token refresh failed with status %d", resp.StatusCode)
}
// Parse token response
var newToken Token
if err := json.NewDecoder(resp.Body).Decode(&newToken); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
// Validate token
if newToken.AccessToken == "" || newToken.RefreshToken == "" {
return nil, errors.New("token response missing required fields")
}
// Set expiration time
newToken.Expiry = time.Now().Add(time.Duration(newToken.ExpiresIn) * time.Second)
return &newToken, nil
} }

309
internal/auth/auth_test.go Normal file
View File

@@ -0,0 +1,309 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// TestTokenRefresh tests the token refresh functionality
func TestTokenRefresh(t *testing.T) {
tests := []struct {
name string
mockResponse interface{}
mockStatus int
expectedToken *Token
expectedError string
}{
{
name: "successful token refresh",
mockResponse: map[string]interface{}{
"access_token": "new-access-token",
"refresh_token": "new-refresh-token",
"expires_in": 3600,
"token_type": "Bearer",
},
mockStatus: http.StatusOK,
expectedToken: &Token{
AccessToken: "new-access-token",
RefreshToken: "new-refresh-token",
ExpiresIn: 3600,
TokenType: "Bearer",
Expiry: time.Now().Add(3600 * time.Second),
},
},
{
name: "expired refresh token",
mockResponse: map[string]interface{}{
"error": "invalid_grant",
"error_description": "Refresh token expired",
},
mockStatus: http.StatusBadRequest,
expectedError: "token refresh failed with status 400",
},
{
name: "invalid token response",
mockResponse: map[string]interface{}{
"invalid": "data",
},
mockStatus: http.StatusOK,
expectedError: "token response missing required fields",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tt.mockStatus)
json.NewEncoder(w).Encode(tt.mockResponse)
}))
defer server.Close()
// Create auth client
client := &AuthClient{
Client: &http.Client{},
TokenURL: server.URL,
}
// Create token to refresh
token := &Token{
RefreshToken: "old-refresh-token",
}
// Execute test
newToken, err := client.RefreshToken(context.Background(), token)
// Assert results
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
assert.Nil(t, newToken)
} else {
assert.NoError(t, err)
assert.NotNil(t, newToken)
assert.Equal(t, tt.expectedToken.AccessToken, newToken.AccessToken)
assert.Equal(t, tt.expectedToken.RefreshToken, newToken.RefreshToken)
assert.Equal(t, tt.expectedToken.ExpiresIn, newToken.ExpiresIn)
assert.WithinDuration(t, tt.expectedToken.Expiry, newToken.Expiry, 5*time.Second)
}
})
}
}
// TestMFAAuthentication tests MFA authentication flow
func TestMFAAuthentication(t *testing.T) {
tests := []struct {
name string
username string
password string
mfaToken string
mockResponses []mockResponse // Multiple responses for MFA flow
expectedToken *Token
expectedError string
}{
{
name: "successful MFA authentication",
username: "user@example.com",
password: "password123",
mfaToken: "123456",
mockResponses: []mockResponse{
{
status: http.StatusUnauthorized,
body: map[string]interface{}{
"mfaToken": "mfa-challenge-token",
},
},
{
status: http.StatusOK,
body: map[string]interface{}{},
cookies: map[string]string{
"access_token": "access-token",
"refresh_token": "refresh-token",
},
},
},
expectedToken: &Token{
AccessToken: "access-token",
RefreshToken: "refresh-token",
ExpiresIn: 3600,
TokenType: "Bearer",
Expiry: time.Now().Add(3600 * time.Second),
},
},
{
name: "invalid MFA code",
username: "user@example.com",
password: "password123",
mfaToken: "wrong-code",
mockResponses: []mockResponse{
{
status: http.StatusUnauthorized,
body: map[string]interface{}{
"mfaToken": "mfa-challenge-token",
},
},
{
status: http.StatusUnauthorized,
body: map[string]interface{}{
"error": "Invalid MFA token",
},
},
},
expectedError: "authentication failed: 401",
},
{
name: "MFA required but not provided",
username: "user@example.com",
password: "password123",
mfaToken: "",
mockResponses: []mockResponse{
{
status: http.StatusUnauthorized,
body: map[string]interface{}{
"mfaToken": "mfa-challenge-token",
},
},
},
expectedError: "MFA required but no token provided",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server with state
currentResponse := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if currentResponse < len(tt.mockResponses) {
response := tt.mockResponses[currentResponse]
w.Header().Set("Content-Type", "application/json")
// Set additional headers if specified
for key, value := range response.headers {
w.Header().Set(key, value)
}
// Set cookies if specified
for name, value := range response.cookies {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
})
}
w.WriteHeader(response.status)
json.NewEncoder(w).Encode(response.body)
currentResponse++
} else {
w.WriteHeader(http.StatusInternalServerError)
}
}))
defer server.Close()
// Create auth client
client := &AuthClient{
Client: &http.Client{},
BaseURL: server.URL,
TokenURL: fmt.Sprintf("%s/oauth/token", server.URL),
LoginPath: "/sso/login",
}
// Execute test
token, err := client.Authenticate(context.Background(), tt.username, tt.password, tt.mfaToken)
// Assert results
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
assert.Nil(t, token)
} else {
assert.NoError(t, err)
assert.NotNil(t, token)
assert.Equal(t, tt.expectedToken.AccessToken, token.AccessToken)
assert.Equal(t, tt.expectedToken.RefreshToken, token.RefreshToken)
assert.Equal(t, tt.expectedToken.ExpiresIn, token.ExpiresIn)
assert.WithinDuration(t, tt.expectedToken.Expiry, token.Expiry, 5*time.Second)
}
})
}
}
// BenchmarkTokenRefresh measures the performance of token refresh
func BenchmarkTokenRefresh(b *testing.B) {
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "benchmark-access-token",
"refresh_token": "benchmark-refresh-token",
"expires_in": 3600,
"token_type": "Bearer",
})
}))
defer server.Close()
// Create auth client
client := &AuthClient{
Client: &http.Client{},
TokenURL: server.URL,
}
// Create token to refresh
token := &Token{
RefreshToken: "benchmark-refresh-token",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = client.RefreshToken(context.Background(), token)
}
}
// BenchmarkMFAAuthentication measures the performance of MFA authentication
func BenchmarkMFAAuthentication(b *testing.B) {
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/sso/login" {
// First request returns MFA challenge
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"mfaToken": "mfa-challenge-token",
})
} else if r.URL.Path == "/oauth/token" {
// Second request returns tokens
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "benchmark-access-token",
"refresh_token": "benchmark-refresh-token",
"expires_in": 3600,
"token_type": "Bearer",
})
}
}))
defer server.Close()
// Create auth client
client := &AuthClient{
Client: &http.Client{},
BaseURL: server.URL,
TokenURL: fmt.Sprintf("%s/oauth/token", server.URL),
LoginPath: "/sso/login",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = client.Authenticate(context.Background(), "benchmark@example.com", "benchmark-password", "123456")
}
}
type mockResponse struct {
status int
body interface{}
headers map[string]string
cookies map[string]string
}

26
internal/auth/client.go Normal file
View File

@@ -0,0 +1,26 @@
package auth
import (
"net/http"
"time"
)
// AuthClient handles authentication with Garmin Connect
type AuthClient struct {
BaseURL string
LoginPath string
TokenURL string
Client *http.Client
}
// NewAuthClient creates a new authentication client
func NewAuthClient() *AuthClient {
return &AuthClient{
BaseURL: "https://connect.garmin.com",
LoginPath: "/signin",
TokenURL: "https://connect.garmin.com/oauth/token",
Client: &http.Client{
Timeout: 30 * time.Second,
},
}
}

12
internal/auth/types.go Normal file
View File

@@ -0,0 +1,12 @@
package auth
import "time"
// Token represents OAuth2 tokens
type Token struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
Expiry time.Time `json:"expiry"`
}

View File

@@ -1,121 +1,135 @@
package fit package fit
import ( import (
"bytes"
"encoding/binary" "encoding/binary"
"time" "io"
) )
// FitBaseType represents FIT base type definitions // FitEncoder encodes FIT activity files using streaming writes with optimized CRC calculation
type FitBaseType struct {
ID int
Name string
Size int
Invalid uint64
Field byte
}
// Base types definitions
var (
FitEnum = FitBaseType{0, "enum", 1, 0xFF, 0x00}
FitUint8 = FitBaseType{2, "uint8", 1, 0xFF, 0x02}
FitUint16 = FitBaseType{4, "uint16", 2, 0xFFFF, 0x84}
FitUint32 = FitBaseType{6, "uint32", 4, 0xFFFFFFFF, 0x86}
FitString = FitBaseType{7, "string", 1, 0x00, 0x07}
FitFloat32 = FitBaseType{8, "float32", 4, 0xFFFFFFFF, 0x88}
FitByte = FitBaseType{13, "byte", 1, 0xFF, 0x0D}
)
// FitEncoder encodes FIT activity files
type FitEncoder struct { type FitEncoder struct {
buf bytes.Buffer w io.WriteSeeker
crc uint16
dataSize int
headerSize int headerSize int
activityDefined bool startPos int64 // position after header
} }
const ( // NewFitEncoder creates a new streaming FIT encoder
FitHeaderSize = 12 func NewFitEncoder(w io.WriteSeeker) (*FitEncoder, error) {
FileTypeActivity = 4 encoder := &FitEncoder{
GarminEpochOffset = 631065600 // UTC 00:00 Dec 31 1989 w: w,
) crc: 0,
dataSize: 0,
// NewFitEncoder creates a new FIT encoder headerSize: 14, // Standard header size with CRC
func NewFitEncoder() *FitEncoder {
e := &FitEncoder{headerSize: FitHeaderSize}
e.writeHeader(0) // Initial header with 0 data size
return e
} }
// writeHeader writes the FIT file header // Get current position for header
func (e *FitEncoder) writeHeader(dataSize int) { var err error
e.buf.Reset() if encoder.startPos, err = w.Seek(0, io.SeekCurrent); err != nil {
return nil, err
}
// Write header placeholder
header := []byte{ header := []byte{
byte(e.headerSize), // Header size 14, // Header size
16, // Protocol version 0x10, // Protocol version
0, 0, 0, 108, // Profile version (108.0) 0x00, 0x2D, // Profile version (little endian 45)
} 0x00, 0x00, 0x00, 0x00, // Data size (4 bytes, will be updated later)
e.buf.Write(header) '.', 'F', 'I', 'T', // ".FIT" data type
0x00, 0x00, // Header CRC (will be calculated later)
// Write data size (4 bytes, little-endian)
sizeBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(sizeBytes, uint32(dataSize))
e.buf.Write(sizeBytes)
// Write file type signature
e.buf.Write([]byte(".FIT"))
} }
// AddActivity adds activity data to the FIT file // Write header and calculate CRC
func (e *FitEncoder) AddActivity(activity FitActivity) error { if _, err := w.Write(header); err != nil {
// TODO: Implement activity message encoding return nil, err
return nil }
encoder.updateCRC(header)
return encoder, nil
} }
// Encode returns the encoded FIT file bytes // updateCRC calculates CRC-16 checksum without hash/crc16 dependency
func (e *FitEncoder) Encode() ([]byte, error) { func (e *FitEncoder) updateCRC(data []byte) {
dataSize := e.buf.Len() - e.headerSize crcTable := [...]uint16{
e.writeHeader(dataSize)
e.writeCRC()
return e.buf.Bytes(), nil
}
// writeCRC calculates and appends the FIT CRC
func (e *FitEncoder) writeCRC() {
data := e.buf.Bytes()
crc := uint16(0)
for _, b := range data {
crc = calcCRC(crc, b)
}
crcBytes := make([]byte, 2)
binary.LittleEndian.PutUint16(crcBytes, crc)
e.buf.Write(crcBytes)
}
// calcCRC calculates FIT CRC
func calcCRC(crc uint16, byteVal byte) uint16 {
table := [...]uint16{
0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, 0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401,
0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400, 0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400,
} }
tmp := table[crc & 0xF]
crc = (crc >> 4) & 0x0FFF
crc = crc ^ tmp ^ table[byteVal & 0xF]
tmp = table[crc & 0xF] currentCRC := e.crc
crc = (crc >> 4) & 0x0FFF for _, b := range data {
return crc ^ tmp ^ table[(byteVal >> 4) & 0xF] // Compute checksum of lower four bits
tmp := crcTable[currentCRC&0xF]
currentCRC = (currentCRC >> 4) & 0x0FFF
currentCRC = currentCRC ^ tmp ^ crcTable[b&0xF]
// Compute checksum of upper four bits
tmp = crcTable[currentCRC&0xF]
currentCRC = (currentCRC >> 4) & 0x0FFF
currentCRC = currentCRC ^ tmp ^ crcTable[(b>>4)&0xF]
}
e.crc = currentCRC
} }
// timestamp converts Go time to FIT timestamp // Write writes activity data in chunks
func timestamp(t time.Time) uint32 { func (e *FitEncoder) Write(p []byte) (int, error) {
return uint32(t.Unix() - GarminEpochOffset) n, err := e.w.Write(p)
if err != nil {
return n, err
} }
// FitActivity represents basic activity data for FIT encoding e.updateCRC(p)
type FitActivity struct { e.dataSize += n
Name string return n, nil
Type string }
StartTime time.Time
Duration time.Duration // Close finalizes the FIT file
Distance float32 // in meters func (e *FitEncoder) Close() error {
// Save current position
currentPos, err := e.w.Seek(0, io.SeekCurrent)
if err != nil {
return err
}
// Update data size in header
if _, err := e.w.Seek(e.startPos+4, io.SeekStart); err != nil {
return err
}
dataSizeBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(dataSizeBytes, uint32(e.dataSize))
if _, err := e.w.Write(dataSizeBytes); err != nil {
return err
}
// Recalculate header CRC with original data
header := []byte{
14, // Header size
0x10, // Protocol version
0x00, 0x2D, // Profile version
dataSizeBytes[0], dataSizeBytes[1], dataSizeBytes[2], dataSizeBytes[3],
'.', 'F', 'I', 'T', // ".FIT" data type
}
// Calculate header CRC with clean state
e.crc = 0
e.updateCRC(header)
headerCRC := e.crc
// Update header CRC
if _, err := e.w.Seek(e.startPos+12, io.SeekStart); err != nil {
return err
}
crcBytes := make([]byte, 2)
binary.LittleEndian.PutUint16(crcBytes, headerCRC)
if _, err := e.w.Write(crcBytes); err != nil {
return err
}
// Write final file CRC
if _, err := e.w.Seek(currentPos, io.SeekStart); err != nil {
return err
}
fileCRCBytes := make([]byte, 2)
binary.LittleEndian.PutUint16(fileCRCBytes, e.crc)
_, err = e.w.Write(fileCRCBytes)
return err
} }

21
internal/fit/validator.go Normal file
View File

@@ -0,0 +1,21 @@
package fit
// Validate performs basic validation of FIT file structure
func Validate(data []byte) bool {
// Minimum FIT file size is 14 bytes (header)
if len(data) < 14 {
return false
}
// Check magic number: ".FIT"
if string(data[8:12]) != ".FIT" {
return false
}
return true
}
// MinFileSize returns the minimum size of a valid FIT file
func MinFileSize() int {
return 14
}