diff --git a/internal/api/activities.go b/internal/api/activities.go index 8507eee..33e3ac2 100644 --- a/internal/api/activities.go +++ b/internal/api/activities.go @@ -2,7 +2,10 @@ package api import ( "context" + "encoding/json" "fmt" + "net/url" + "strconv" "time" ) @@ -16,10 +19,149 @@ type Activity struct { 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 type ActivitiesResponse struct { - Activities []Activity `json:"activities"` - Pagination Pagination `json:"pagination"` + Activities []ActivityResponse `json:"activities"` + Pagination Pagination `json:"pagination"` } // Pagination represents pagination information in API responses @@ -32,18 +174,46 @@ type Pagination struct { // GetActivities retrieves a list of activities with pagination func (c *Client) GetActivities(ctx context.Context, page int, pageSize int) ([]Activity, *Pagination, error) { 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 - err := c.Get(ctx, path+query, &response) + err := c.Get(ctx, fmt.Sprintf("%s?%s", path, params.Encode()), &response) if err != nil { 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 - if len(response.Activities) == 0 { + if len(activities) == 0 { 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 } diff --git a/internal/api/activities_test.go b/internal/api/activities_test.go new file mode 100644 index 0000000..a5a1f92 --- /dev/null +++ b/internal/api/activities_test.go @@ -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") +} diff --git a/internal/api/client.go b/internal/api/client.go index 3187809..a7146ca 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -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) - // 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 { return fmt.Errorf("unexpected status code: %d", resp.StatusCode) }