mirror of
https://github.com/sstent/go-garminconnect.git
synced 2026-02-14 03:12:09 +00:00
tests passing
This commit is contained in:
@@ -2,7 +2,10 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,10 +19,149 @@ type Activity struct {
|
|||||||
Distance float64 `json:"distance"`
|
Distance float64 `json:"distance"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActivityDetail represents comprehensive activity data
|
||||||
|
type ActivityDetail struct {
|
||||||
|
Activity
|
||||||
|
Calories float64 `json:"calories"`
|
||||||
|
AverageHR int `json:"averageHR"`
|
||||||
|
MaxHR int `json:"maxHR"`
|
||||||
|
AverageTemp float64 `json:"averageTemperature"`
|
||||||
|
ElevationGain float64 `json:"elevationGain"`
|
||||||
|
ElevationLoss float64 `json:"elevationLoss"`
|
||||||
|
Weather Weather `json:"weather"`
|
||||||
|
Gear Gear `json:"gear"`
|
||||||
|
GPSTracks []GPSTrackPoint `json:"gpsTracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// garminTime implements custom JSON unmarshaling for Garmin's time format
|
||||||
|
type garminTime struct {
|
||||||
|
time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const garminTimeLayout = "2006-01-02T15:04:05"
|
||||||
|
|
||||||
|
func (gt *garminTime) UnmarshalJSON(data []byte) error {
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t, err := time.Parse(garminTimeLayout, s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
gt.Time = t
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivityResponse is used for JSON unmarshaling with custom time handling
|
||||||
|
type ActivityResponse struct {
|
||||||
|
ActivityID int64 `json:"activityId"`
|
||||||
|
Name string `json:"activityName"`
|
||||||
|
Type string `json:"activityType"`
|
||||||
|
StartTime garminTime `json:"startTimeLocal"`
|
||||||
|
Duration float64 `json:"duration"`
|
||||||
|
Distance float64 `json:"distance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivityDetailResponse is used for JSON unmarshaling with custom time handling
|
||||||
|
type ActivityDetailResponse struct {
|
||||||
|
ActivityResponse
|
||||||
|
Calories float64 `json:"calories"`
|
||||||
|
AverageHR int `json:"averageHR"`
|
||||||
|
MaxHR int `json:"maxHR"`
|
||||||
|
AverageTemp float64 `json:"averageTemperature"`
|
||||||
|
ElevationGain float64 `json:"elevationGain"`
|
||||||
|
ElevationLoss float64 `json:"elevationLoss"`
|
||||||
|
Weather Weather `json:"weather"`
|
||||||
|
Gear Gear `json:"gear"`
|
||||||
|
GPSTracks []GPSTrackPoint `json:"gpsTracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to ActivityDetail
|
||||||
|
func (adr *ActivityDetailResponse) ToActivityDetail() ActivityDetail {
|
||||||
|
return ActivityDetail{
|
||||||
|
Activity: Activity{
|
||||||
|
ActivityID: adr.ActivityID,
|
||||||
|
Name: adr.Name,
|
||||||
|
Type: adr.Type,
|
||||||
|
StartTime: adr.StartTime.Time,
|
||||||
|
Duration: adr.Duration,
|
||||||
|
Distance: adr.Distance,
|
||||||
|
},
|
||||||
|
Calories: adr.Calories,
|
||||||
|
AverageHR: adr.AverageHR,
|
||||||
|
MaxHR: adr.MaxHR,
|
||||||
|
AverageTemp: adr.AverageTemp,
|
||||||
|
ElevationGain: adr.ElevationGain,
|
||||||
|
ElevationLoss: adr.ElevationLoss,
|
||||||
|
Weather: adr.Weather,
|
||||||
|
Gear: adr.Gear,
|
||||||
|
GPSTracks: adr.GPSTracks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to Activity
|
||||||
|
func (ar *ActivityResponse) ToActivity() Activity {
|
||||||
|
return Activity{
|
||||||
|
ActivityID: ar.ActivityID,
|
||||||
|
Name: ar.Name,
|
||||||
|
Type: ar.Type,
|
||||||
|
StartTime: ar.StartTime.Time,
|
||||||
|
Duration: ar.Duration,
|
||||||
|
Distance: ar.Distance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weather contains weather conditions during activity
|
||||||
|
type Weather struct {
|
||||||
|
Condition string `json:"condition"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
Humidity float64 `json:"humidity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gear represents equipment used in activity
|
||||||
|
type Gear struct {
|
||||||
|
ID string `json:"gearId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPSTrackPoint contains geo coordinates
|
||||||
|
type GPSTrackPoint struct {
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
Ele float64 `json:"ele"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gtp *GPSTrackPoint) UnmarshalJSON(data []byte) error {
|
||||||
|
type Alias GPSTrackPoint
|
||||||
|
aux := &struct {
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
*Alias
|
||||||
|
}{
|
||||||
|
Alias: (*Alias)(gtp),
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &aux); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if aux.Timestamp != "" {
|
||||||
|
t, err := time.Parse(garminTimeLayout, aux.Timestamp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
gtp.Timestamp = t
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivitiesResponse represents the response from the activities endpoint
|
||||||
// ActivitiesResponse represents the response from the activities endpoint
|
// ActivitiesResponse represents the response from the activities endpoint
|
||||||
type ActivitiesResponse struct {
|
type ActivitiesResponse struct {
|
||||||
Activities []Activity `json:"activities"`
|
Activities []ActivityResponse `json:"activities"`
|
||||||
Pagination Pagination `json:"pagination"`
|
Pagination Pagination `json:"pagination"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pagination represents pagination information in API responses
|
// Pagination represents pagination information in API responses
|
||||||
@@ -32,18 +174,46 @@ type Pagination struct {
|
|||||||
// GetActivities retrieves a list of activities with pagination
|
// GetActivities retrieves a list of activities with pagination
|
||||||
func (c *Client) GetActivities(ctx context.Context, page int, pageSize int) ([]Activity, *Pagination, error) {
|
func (c *Client) GetActivities(ctx context.Context, page int, pageSize int) ([]Activity, *Pagination, error) {
|
||||||
path := "/activitylist-service/activities/search"
|
path := "/activitylist-service/activities/search"
|
||||||
query := fmt.Sprintf("?page=%d&pageSize=%d", page, pageSize)
|
params := url.Values{}
|
||||||
|
params.Add("page", strconv.Itoa(page))
|
||||||
|
params.Add("pageSize", strconv.Itoa(pageSize))
|
||||||
|
|
||||||
var response ActivitiesResponse
|
var response ActivitiesResponse
|
||||||
err := c.Get(ctx, path+query, &response)
|
err := c.Get(ctx, fmt.Sprintf("%s?%s", path, params.Encode()), &response)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to get activities: %w", err)
|
return nil, nil, fmt.Errorf("failed to get activities: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert response to Activity slice
|
||||||
|
activities := make([]Activity, len(response.Activities))
|
||||||
|
for i, ar := range response.Activities {
|
||||||
|
activities[i] = ar.ToActivity()
|
||||||
|
}
|
||||||
|
|
||||||
// Validate we received some activities
|
// Validate we received some activities
|
||||||
if len(response.Activities) == 0 {
|
if len(activities) == 0 {
|
||||||
return nil, nil, fmt.Errorf("no activities found")
|
return nil, nil, fmt.Errorf("no activities found")
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.Activities, &response.Pagination, nil
|
return activities, &response.Pagination, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActivityDetails retrieves comprehensive data for a specific activity
|
||||||
|
func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (*ActivityDetail, error) {
|
||||||
|
path := fmt.Sprintf("/activity-service/activity/%d", activityID)
|
||||||
|
|
||||||
|
var response ActivityDetailResponse
|
||||||
|
err := c.Get(ctx, path, &response)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get activity details: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activityDetail := response.ToActivityDetail()
|
||||||
|
|
||||||
|
// Validate we received activity data
|
||||||
|
if activityDetail.ActivityID == 0 {
|
||||||
|
return nil, fmt.Errorf("no activity found for ID %d", activityID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &activityDetail, nil
|
||||||
}
|
}
|
||||||
|
|||||||
157
internal/api/activities_test.go
Normal file
157
internal/api/activities_test.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetActivities(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
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"pageSize": 10,
|
||||||
|
"totalCount": 1,
|
||||||
|
"page": 1
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer testServer.Close()
|
||||||
|
|
||||||
|
// Create client with mock server URL
|
||||||
|
client, err := NewClient(testServer.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute test
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -89,7 +89,10 @@ func (c *Client) doRequest(ctx context.Context, method, path string, body io.Rea
|
|||||||
|
|
||||||
c.logger.Debugf("Response status: %s", resp.Status)
|
c.logger.Debugf("Response status: %s", resp.Status)
|
||||||
|
|
||||||
// Handle non-200 responses
|
// Handle specific status codes
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return fmt.Errorf("resource not found")
|
||||||
|
}
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user