mirror of
https://github.com/sstent/go-garminconnect.git
synced 2026-01-25 16:42:32 +00:00
sync
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
- [x] Initial documentation added
|
||||
|
||||
### Phase 2: Authentication System
|
||||
- [x] OAuth1 authentication implemented
|
||||
- [x] OAuth2 authentication with MFA support implemented
|
||||
- [x] Token storage with file-based system
|
||||
- [x] MFA handling support
|
||||
- [x] Authentication tests
|
||||
@@ -28,6 +28,10 @@
|
||||
|
||||
### Phase 5: FIT Handling
|
||||
- [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
|
||||
|
||||
@@ -87,6 +91,5 @@ docker compose up -d --build
|
||||
- [x] Added comprehensive tests for session persistence
|
||||
|
||||
## Next Steps
|
||||
- Create streaming FIT encoder
|
||||
- Add comprehensive test coverage for all endpoints
|
||||
- Improve error handling and logging
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
- [x] Add basic README with project overview
|
||||
|
||||
### Phase 2: Authentication Implementation
|
||||
- [x] Implement OAuth2 authentication flow
|
||||
- [x] Implement OAuth2 authentication flow with MFA support
|
||||
- [x] Create token storage interface
|
||||
- [x] Implement session management with auto-refresh
|
||||
- [x] Handle MFA authentication
|
||||
@@ -71,7 +71,10 @@
|
||||
- Implemented core encoder with header/CRC
|
||||
- Added support for activity messages
|
||||
- [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
|
||||
|
||||
### Phase 6: Testing & Quality
|
||||
|
||||
84
TODO.md
84
TODO.md
@@ -1,34 +1,60 @@
|
||||
# Go GarminConnect Port Implementation Plan
|
||||
# Go-GarminConnect Porting Project - Remaining Tasks
|
||||
|
||||
## Phase 1: Setup & Core Structure
|
||||
- [x] Initialize Go module
|
||||
- [x] Create directory structure
|
||||
- [x] Set up CI/CD pipeline basics
|
||||
- [x] Create basic Docker infrastructure
|
||||
- [x] Add initial documentation
|
||||
## Endpoint Implementation
|
||||
### Health Data Endpoints
|
||||
- [ ] Body composition API endpoint
|
||||
- [ ] Sleep data retrieval and parsing
|
||||
- [ ] Heart rate/HRV/RHR data endpoint
|
||||
- [ ] Stress data API implementation
|
||||
- [ ] Body battery endpoint
|
||||
|
||||
## Phase 2: Authentication System
|
||||
- [ ] Implement OAuth2 flow
|
||||
- [ ] Create token storage interface
|
||||
- [ ] Add MFA handling
|
||||
- [ ] Write authentication tests
|
||||
### User Data Endpoints
|
||||
- [ ] User summary endpoint
|
||||
- [ ] Daily statistics API
|
||||
- [ ] Goals/badges endpoint implementation
|
||||
- [ ] Hydration data endpoint
|
||||
- [ ] Respiration data API
|
||||
|
||||
## Phase 3: API Client Core
|
||||
- [ ] Define Client struct
|
||||
- [ ] Implement request/response handling
|
||||
- [ ] Add error handling
|
||||
- [ ] Setup logging
|
||||
- [ ] Implement rate limiting
|
||||
### Activity Endpoints
|
||||
- [ ] Activity type filtering
|
||||
- [ ] Activity comment functionality
|
||||
- [ ] Activity like/unlike feature
|
||||
- [ ] Activity sharing options
|
||||
|
||||
## Phase 4: Endpoint Implementation
|
||||
- [ ] Port user profile endpoint
|
||||
- [ ] Port activities endpoints
|
||||
- [ ] Port health data endpoints
|
||||
- [ ] Implement pagination handling
|
||||
- [ ] Add response validation
|
||||
## FIT File Handling
|
||||
- [ ] Complete weight composition encoding
|
||||
- [ ] Implement all-day stress FIT encoding
|
||||
- [ ] Add HRV data to FIT export
|
||||
- [ ] Validate FIT compatibility with Garmin devices
|
||||
- [ ] Optimize FIT file parsing performance
|
||||
|
||||
## Phase 5: FIT Handling
|
||||
- [x] Create FIT decoder
|
||||
- [ ] Implement FIT encoder
|
||||
- [ ] Add FIT file tests
|
||||
- [ ] Integrate with activity endpoints
|
||||
## Testing & Quality Assurance
|
||||
- [ ] Implement table-driven tests for all endpoints
|
||||
- [ ] Create mock server for isolated testing
|
||||
- [ ] Add golden file tests for FIT validation
|
||||
- [ ] 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
66
cmd/garmin-cli/main.go
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
58
cmd/main.go
58
cmd/main.go
@@ -1,38 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/sstent/go-garminconnect/internal/auth"
|
||||
"github.com/sstent/go-garminconnect/internal/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Get consumer key and secret from environment
|
||||
consumerKey := os.Getenv("GARMIN_CONSUMER_KEY")
|
||||
consumerSecret := os.Getenv("GARMIN_CONSUMER_SECRET")
|
||||
if consumerKey == "" || consumerSecret == "" {
|
||||
fmt.Println("GARMIN_CONSUMER_KEY and GARMIN_CONSUMER_SECRET must be set")
|
||||
// Get credentials from environment
|
||||
username := os.Getenv("GARMIN_USERNAME")
|
||||
password := os.Getenv("GARMIN_PASSWORD")
|
||||
if username == "" || password == "" {
|
||||
fmt.Println("GARMIN_USERNAME and GARMIN_PASSWORD must be set")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Configure authentication
|
||||
oauthConfig := &auth.OAuthConfig{
|
||||
ConsumerKey: consumerKey,
|
||||
ConsumerSecret: consumerSecret,
|
||||
// Create authentication client
|
||||
authClient := auth.NewAuthClient()
|
||||
|
||||
// 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
|
||||
tokenStorage := auth.NewFileStorage()
|
||||
// API client not currently used in this simple server
|
||||
// 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
|
||||
http.HandleFunc("/", homeHandler)
|
||||
http.HandleFunc("/login", loginHandler(oauthConfig, tokenStorage))
|
||||
http.HandleFunc("/callback", callbackHandler(oauthConfig, tokenStorage))
|
||||
http.HandleFunc("/mfa", auth.MFAHandler)
|
||||
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")
|
||||
http.ListenAndServe(":8080", nil)
|
||||
}
|
||||
@@ -42,27 +54,13 @@ func homeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
<html>
|
||||
<body>
|
||||
<h1>Go GarminConnect Client</h1>
|
||||
<a href="/login">Login with Garmin</a>
|
||||
<p>Authentication successful! API client ready.</p>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
}
|
||||
|
||||
func loginHandler(config *auth.OAuthConfig, storage auth.TokenStorage) http.HandlerFunc {
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Removed OAuth handlers since we're using credentials-based auth
|
||||
|
||||
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -10,6 +10,15 @@ services:
|
||||
networks:
|
||||
- garmin-net
|
||||
|
||||
test:
|
||||
image: golang:1.25
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ../:/app
|
||||
command: go test ./...
|
||||
networks:
|
||||
- garmin-net
|
||||
|
||||
networks:
|
||||
garmin-net:
|
||||
driver: bridge
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
35
internal/api/bodycomposition.go
Normal file
35
internal/api/bodycomposition.go
Normal 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
|
||||
}
|
||||
104
internal/api/bodycomposition_test.go
Normal file
104
internal/api/bodycomposition_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{}) {}
|
||||
|
||||
@@ -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
70
internal/api/health.go
Normal 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
319
internal/api/health_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
46
internal/api/mock_handlers.go
Normal file
46
internal/api/mock_handlers.go
Normal 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
|
||||
}
|
||||
189
internal/api/mock_server_test.go
Normal file
189
internal/api/mock_server_test.go
Normal 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
38
internal/api/types.go
Normal 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"`
|
||||
}
|
||||
@@ -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
259
internal/api/user_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,92 +1,148 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/dghubble/oauth1"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OAuthConfig holds OAuth1 configuration for Garmin Connect
|
||||
type OAuthConfig struct {
|
||||
ConsumerKey string
|
||||
ConsumerSecret string
|
||||
// Authenticate handles Garmin Connect authentication with MFA support
|
||||
func (c *AuthClient) Authenticate(ctx context.Context, username, password, mfaToken string) (*Token, error) {
|
||||
// Create login form data
|
||||
data := url.Values{}
|
||||
data.Set("username", username)
|
||||
data.Set("password", password)
|
||||
data.Set("embed", "false")
|
||||
data.Set("rememberme", "on")
|
||||
|
||||
// Create login request
|
||||
loginURL := fmt.Sprintf("%s%s", c.BaseURL, c.LoginPath)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", loginURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create login 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 login request
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login request failed: %w", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// Validate MFA token
|
||||
if mfaToken == "" {
|
||||
return nil, errors.New("MFA required but no token provided")
|
||||
}
|
||||
|
||||
// Create MFA verification request
|
||||
mfaData := url.Values{}
|
||||
mfaData.Set("token", mfaResponse.MFAToken)
|
||||
mfaData.Set("rememberme", "on")
|
||||
mfaData.Set("mfaCode", mfaToken)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", loginURL, strings.NewReader(mfaData.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create MFA 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 MFA request
|
||||
resp, err = c.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MFA request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
// Handle non-200 responses
|
||||
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
|
||||
}
|
||||
|
||||
// TokenStorage defines the interface for storing and retrieving OAuth tokens
|
||||
type TokenStorage interface {
|
||||
GetToken() (*oauth1.Token, error)
|
||||
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 {
|
||||
http.Error(w, "Failed to get request token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Save request token secret temporarily (for callback)
|
||||
// In a real application, you'd store this in a session
|
||||
|
||||
// Redirect to authorization URL
|
||||
authURL, err := oauthConfig.AuthorizationURL(requestToken)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get authorization URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, authURL.String(), http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// Callback handles OAuth1 callback
|
||||
func Callback(w http.ResponseWriter, r *http.Request, config *OAuthConfig, storage TokenStorage, requestSecret string) {
|
||||
// Get request token and verifier from query params
|
||||
requestToken := r.URL.Query().Get("oauth_token")
|
||||
verifier := r.URL.Query().Get("oauth_verifier")
|
||||
|
||||
// Create OAuth1 config
|
||||
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 {
|
||||
http.Error(w, "Failed to get access token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create token and save
|
||||
token := &oauth1.Token{
|
||||
Token: accessToken,
|
||||
TokenSecret: accessSecret,
|
||||
}
|
||||
|
||||
err = storage.SaveToken(token)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to save token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Authentication successful!"))
|
||||
// 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
309
internal/auth/auth_test.go
Normal 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
26
internal/auth/client.go
Normal 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
12
internal/auth/types.go
Normal 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"`
|
||||
}
|
||||
@@ -1,121 +1,135 @@
|
||||
package fit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"time"
|
||||
"io"
|
||||
)
|
||||
|
||||
// FitBaseType represents FIT base type definitions
|
||||
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
|
||||
// FitEncoder encodes FIT activity files using streaming writes with optimized CRC calculation
|
||||
type FitEncoder struct {
|
||||
buf bytes.Buffer
|
||||
w io.WriteSeeker
|
||||
crc uint16
|
||||
dataSize int
|
||||
headerSize int
|
||||
activityDefined bool
|
||||
startPos int64 // position after header
|
||||
}
|
||||
|
||||
const (
|
||||
FitHeaderSize = 12
|
||||
FileTypeActivity = 4
|
||||
GarminEpochOffset = 631065600 // UTC 00:00 Dec 31 1989
|
||||
)
|
||||
// NewFitEncoder creates a new streaming FIT encoder
|
||||
func NewFitEncoder(w io.WriteSeeker) (*FitEncoder, error) {
|
||||
encoder := &FitEncoder{
|
||||
w: w,
|
||||
crc: 0,
|
||||
dataSize: 0,
|
||||
headerSize: 14, // Standard header size with CRC
|
||||
}
|
||||
|
||||
// NewFitEncoder creates a new FIT encoder
|
||||
func NewFitEncoder() *FitEncoder {
|
||||
e := &FitEncoder{headerSize: FitHeaderSize}
|
||||
e.writeHeader(0) // Initial header with 0 data size
|
||||
return e
|
||||
}
|
||||
// Get current position for header
|
||||
var err error
|
||||
if encoder.startPos, err = w.Seek(0, io.SeekCurrent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// writeHeader writes the FIT file header
|
||||
func (e *FitEncoder) writeHeader(dataSize int) {
|
||||
e.buf.Reset()
|
||||
// Write header placeholder
|
||||
header := []byte{
|
||||
byte(e.headerSize), // Header size
|
||||
16, // Protocol version
|
||||
0, 0, 0, 108, // Profile version (108.0)
|
||||
14, // Header size
|
||||
0x10, // Protocol version
|
||||
0x00, 0x2D, // Profile version (little endian 45)
|
||||
0x00, 0x00, 0x00, 0x00, // Data size (4 bytes, will be updated later)
|
||||
'.', 'F', 'I', 'T', // ".FIT" data type
|
||||
0x00, 0x00, // Header CRC (will be calculated later)
|
||||
}
|
||||
e.buf.Write(header)
|
||||
|
||||
// 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
|
||||
func (e *FitEncoder) AddActivity(activity FitActivity) error {
|
||||
// TODO: Implement activity message encoding
|
||||
return nil
|
||||
}
|
||||
|
||||
// Encode returns the encoded FIT file bytes
|
||||
func (e *FitEncoder) Encode() ([]byte, error) {
|
||||
dataSize := e.buf.Len() - e.headerSize
|
||||
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)
|
||||
// Write header and calculate CRC
|
||||
if _, err := w.Write(header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
crcBytes := make([]byte, 2)
|
||||
binary.LittleEndian.PutUint16(crcBytes, crc)
|
||||
e.buf.Write(crcBytes)
|
||||
encoder.updateCRC(header)
|
||||
|
||||
return encoder, nil
|
||||
}
|
||||
|
||||
// calcCRC calculates FIT CRC
|
||||
func calcCRC(crc uint16, byteVal byte) uint16 {
|
||||
table := [...]uint16{
|
||||
// updateCRC calculates CRC-16 checksum without hash/crc16 dependency
|
||||
func (e *FitEncoder) updateCRC(data []byte) {
|
||||
crcTable := [...]uint16{
|
||||
0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401,
|
||||
0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400,
|
||||
}
|
||||
tmp := table[crc & 0xF]
|
||||
crc = (crc >> 4) & 0x0FFF
|
||||
crc = crc ^ tmp ^ table[byteVal & 0xF]
|
||||
|
||||
currentCRC := e.crc
|
||||
for _, b := range data {
|
||||
// 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
|
||||
}
|
||||
|
||||
// Write writes activity data in chunks
|
||||
func (e *FitEncoder) Write(p []byte) (int, error) {
|
||||
n, err := e.w.Write(p)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
|
||||
e.updateCRC(p)
|
||||
e.dataSize += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Close finalizes the FIT file
|
||||
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
|
||||
}
|
||||
|
||||
tmp = table[crc & 0xF]
|
||||
crc = (crc >> 4) & 0x0FFF
|
||||
return crc ^ tmp ^ table[(byteVal >> 4) & 0xF]
|
||||
}
|
||||
// Calculate header CRC with clean state
|
||||
e.crc = 0
|
||||
e.updateCRC(header)
|
||||
headerCRC := e.crc
|
||||
|
||||
// timestamp converts Go time to FIT timestamp
|
||||
func timestamp(t time.Time) uint32 {
|
||||
return uint32(t.Unix() - GarminEpochOffset)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
// FitActivity represents basic activity data for FIT encoding
|
||||
type FitActivity struct {
|
||||
Name string
|
||||
Type string
|
||||
StartTime time.Time
|
||||
Duration time.Duration
|
||||
Distance float32 // in meters
|
||||
// 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
21
internal/fit/validator.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user