From 09ced1f9837e1246ccaa30d2e4ab712966516354 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 06:19:54 +0000 Subject: [PATCH 1/7] Add Strava segment service skeleton Agent-Logs-Url: https://github.com/bzimmer/activity/sessions/ccc18ea0-b8c4-430f-80bc-55da6436f2da Co-authored-by: bzimmer <12852+bzimmer@users.noreply.github.com> --- strava/segment.go | 68 +++++++++++++++++++++ strava/segment_test.go | 114 +++++++++++++++++++++++++++++++++++ strava/strava.go | 2 + strava/testdata/segment.json | 52 ++++++++++++++++ 4 files changed, 236 insertions(+) create mode 100644 strava/segment.go create mode 100644 strava/segment_test.go create mode 100644 strava/testdata/segment.json diff --git a/strava/segment.go b/strava/segment.go new file mode 100644 index 0000000..f0bc428 --- /dev/null +++ b/strava/segment.go @@ -0,0 +1,68 @@ +package strava + +import ( + "context" + "fmt" + "net/http" + + "github.com/bzimmer/activity" +) + +// SegmentService is the API for segment endpoints. +type SegmentService service + +type segmentPaginator struct { + segments []*Segment + service SegmentService +} + +func (p *segmentPaginator) PageSize() int { + return PageSize +} + +func (p *segmentPaginator) Count() int { + return len(p.segments) +} + +func (p *segmentPaginator) Do(ctx context.Context, spec activity.Pagination) (int, error) { + uri := fmt.Sprintf("segments/starred?page=%d&per_page=%d", spec.Start, spec.Count) + req, err := p.service.client.newAPIRequest(ctx, http.MethodGet, uri, nil) + if err != nil { + return 0, err + } + var segs []*Segment + err = p.service.client.do(req, &segs) + if err != nil { + return 0, err + } + if spec.Total > 0 && len(p.segments)+len(segs) > spec.Total { + segs = segs[:spec.Total-len(p.segments)] + } + p.segments = append(p.segments, segs...) + return len(segs), nil +} + +// Segments returns a page of starred segments for the authenticated athlete. +func (s *SegmentService) Segments(ctx context.Context, spec activity.Pagination) ([]*Segment, error) { + p := &segmentPaginator{service: *s, segments: make([]*Segment, 0)} + err := activity.Paginate(ctx, p, spec) + if err != nil { + return nil, err + } + return p.segments, nil +} + +// Segment returns a segment. +func (s *SegmentService) Segment(ctx context.Context, segmentID int64) (*Segment, error) { + uri := fmt.Sprintf("segments/%d", segmentID) + req, err := s.client.newAPIRequest(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + seg := &Segment{} + err = s.client.do(req, &seg) + if err != nil { + return nil, err + } + return seg, nil +} diff --git a/strava/segment_test.go b/strava/segment_test.go new file mode 100644 index 0000000..4c1b4fa --- /dev/null +++ b/strava/segment_test.go @@ -0,0 +1,114 @@ +package strava_test + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/bzimmer/activity" + "github.com/bzimmer/activity/strava" +) + +func TestSegment(t *testing.T) { + t.Parallel() + a := assert.New(t) + + tests := []struct { + name string + before func(mux *http.ServeMux) + after func(segment *strava.Segment, err error) + }{ + { + name: "valid segment", + before: func(mux *http.ServeMux) { + mux.HandleFunc("/segments/229781", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "testdata/segment.json") + }) + }, + after: func(segment *strava.Segment, err error) { + a.NoError(err) + a.NotNil(segment) + a.Equal(229781, segment.ID) + a.Equal("Hawk Hill", segment.Name) + }, + }, + { + name: "invalid segment", + before: func(_ *http.ServeMux) {}, + after: func(_ *strava.Segment, err error) { + a.Error(err) + }, + }, + } + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + client, svr := newClientMust(tt.before) + defer svr.Close() + tt.after(client.Segment.Segment(context.TODO(), 229781)) + }) + } +} + +func TestSegments(t *testing.T) { + t.Parallel() + a := assert.New(t) + + tests := []struct { + name string + pagination activity.Pagination + after func(segments []*strava.Segment, err error) + }{ + { + name: "test total, start, and count", + pagination: activity.Pagination{Total: 127, Start: 0, Count: 1}, + after: func(segments []*strava.Segment, err error) { + a.NoError(err) + a.NotNil(segments) + a.Equal(127, len(segments)) + }, + }, + { + name: "test total and start", + pagination: activity.Pagination{Total: 234, Start: 0}, + after: func(segments []*strava.Segment, err error) { + a.NoError(err) + a.NotNil(segments) + a.Equal(234, len(segments)) + }, + }, + { + name: "test total and start less than PageSize", + pagination: activity.Pagination{Total: 27, Start: 0}, + after: func(segments []*strava.Segment, err error) { + a.NoError(err) + a.NotNil(segments) + a.Equal(27, len(segments)) + }, + }, + { + name: "negative test", + pagination: activity.Pagination{Total: -1}, + after: func(segments []*strava.Segment, err error) { + a.Error(err) + a.Nil(segments) + }, + }, + } + for i := range tests { + tt := tests[i] + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + client, svr := newClientMust(func(mux *http.ServeMux) { + mux.Handle("/segments/starred", &ManyHandler{ + Filename: "testdata/segment.json", + }) + }) + defer svr.Close() + tt.after(client.Segment.Segments(context.TODO(), tt.pagination)) + }) + } +} diff --git a/strava/strava.go b/strava/strava.go index 92f270a..3f0609b 100644 --- a/strava/strava.go +++ b/strava/strava.go @@ -40,6 +40,7 @@ type Client struct { Auth *AuthService Route *RouteService + Segment *SegmentService Webhook *WebhookService Athlete *AthleteService Activity *ActivityService @@ -67,6 +68,7 @@ func withServices() Option { return func(c *Client) error { c.Auth = &AuthService{client: c} c.Route = &RouteService{client: c} + c.Segment = &SegmentService{client: c} c.Webhook = &WebhookService{client: c} c.Athlete = &AthleteService{client: c} c.Activity = &ActivityService{client: c} diff --git a/strava/testdata/segment.json b/strava/testdata/segment.json new file mode 100644 index 0000000..d17b892 --- /dev/null +++ b/strava/testdata/segment.json @@ -0,0 +1,52 @@ +{ + "id": 229781, + "resource_state": 3, + "name": "Hawk Hill", + "activity_type": "Ride", + "distance": 1674.0, + "average_grade": 5.7, + "maximum_grade": 8.6, + "elevation_high": 139.5, + "elevation_low": 44.0, + "start_latlng": [ + 37.833111, + -122.483435 + ], + "end_latlng": [ + 37.840369, + -122.484489 + ], + "climb_category": 1, + "city": "Sausalito", + "state": "California", + "country": "United States", + "private": false, + "hazardous": false, + "starred": true, + "created_at": "2012-01-01T00:00:00Z", + "updated_at": "2012-01-01T00:00:00Z", + "total_elevation_gain": 95.5, + "map": { + "id": "s229781", + "summary_polyline": "a~l~Fjk~uOwHJy@P", + "resource_state": 3 + }, + "effort_count": 123456, + "athlete_count": 78910, + "star_count": 4567, + "athlete_pr_effort": { + "distance": 1674.0, + "start_date_local": "2018-06-01T07:00:00Z", + "activity_id": 123456789, + "elapsed_time": 360, + "is_kom": false, + "id": 987654, + "start_date": "2018-06-01T14:00:00Z" + }, + "athlete_segment_stats": { + "pr_elapsed_time": 360, + "pr_date": "2018-06-01T14:00:00Z", + "effort_count": 42, + "pr_activity_id": 123456789 + } +} From d4bbd7aeca5f9c01927cdfa16cfb66b0a960035f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 06:54:25 +0000 Subject: [PATCH 2/7] Rename Strava segment methods to segment efforts Agent-Logs-Url: https://github.com/bzimmer/activity/sessions/c93188a7-223c-4a89-a4f2-2eec088db77d Co-authored-by: bzimmer <12852+bzimmer@users.noreply.github.com> --- strava/segment.go | 34 +++++++------- strava/segment_test.go | 58 ++++++++++++------------ strava/testdata/segment.json | 52 --------------------- strava/testdata/segment_effort.json | 70 +++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 97 deletions(-) delete mode 100644 strava/testdata/segment.json create mode 100644 strava/testdata/segment_effort.json diff --git a/strava/segment.go b/strava/segment.go index f0bc428..0e5ea0e 100644 --- a/strava/segment.go +++ b/strava/segment.go @@ -8,12 +8,12 @@ import ( "github.com/bzimmer/activity" ) -// SegmentService is the API for segment endpoints. +// SegmentService is the API for segment effort endpoints. type SegmentService service type segmentPaginator struct { - segments []*Segment - service SegmentService + segmentEfforts []*SegmentEffort + service SegmentService } func (p *segmentPaginator) PageSize() int { @@ -21,45 +21,45 @@ func (p *segmentPaginator) PageSize() int { } func (p *segmentPaginator) Count() int { - return len(p.segments) + return len(p.segmentEfforts) } func (p *segmentPaginator) Do(ctx context.Context, spec activity.Pagination) (int, error) { - uri := fmt.Sprintf("segments/starred?page=%d&per_page=%d", spec.Start, spec.Count) + uri := fmt.Sprintf("segment_efforts?page=%d&per_page=%d", spec.Start, spec.Count) req, err := p.service.client.newAPIRequest(ctx, http.MethodGet, uri, nil) if err != nil { return 0, err } - var segs []*Segment + var segs []*SegmentEffort err = p.service.client.do(req, &segs) if err != nil { return 0, err } - if spec.Total > 0 && len(p.segments)+len(segs) > spec.Total { - segs = segs[:spec.Total-len(p.segments)] + if spec.Total > 0 && len(p.segmentEfforts)+len(segs) > spec.Total { + segs = segs[:spec.Total-len(p.segmentEfforts)] } - p.segments = append(p.segments, segs...) + p.segmentEfforts = append(p.segmentEfforts, segs...) return len(segs), nil } -// Segments returns a page of starred segments for the authenticated athlete. -func (s *SegmentService) Segments(ctx context.Context, spec activity.Pagination) ([]*Segment, error) { - p := &segmentPaginator{service: *s, segments: make([]*Segment, 0)} +// SegmentEfforts returns a page of segment efforts for the authenticated athlete. +func (s *SegmentService) SegmentEfforts(ctx context.Context, spec activity.Pagination) ([]*SegmentEffort, error) { + p := &segmentPaginator{service: *s, segmentEfforts: make([]*SegmentEffort, 0)} err := activity.Paginate(ctx, p, spec) if err != nil { return nil, err } - return p.segments, nil + return p.segmentEfforts, nil } -// Segment returns a segment. -func (s *SegmentService) Segment(ctx context.Context, segmentID int64) (*Segment, error) { - uri := fmt.Sprintf("segments/%d", segmentID) +// SegmentEffort returns a segment effort. +func (s *SegmentService) SegmentEffort(ctx context.Context, segmentEffortID int64) (*SegmentEffort, error) { + uri := fmt.Sprintf("segment_efforts/%d", segmentEffortID) req, err := s.client.newAPIRequest(ctx, http.MethodGet, uri, nil) if err != nil { return nil, err } - seg := &Segment{} + seg := &SegmentEffort{} err = s.client.do(req, &seg) if err != nil { return nil, err diff --git a/strava/segment_test.go b/strava/segment_test.go index 4c1b4fa..2334581 100644 --- a/strava/segment_test.go +++ b/strava/segment_test.go @@ -11,33 +11,35 @@ import ( "github.com/bzimmer/activity/strava" ) -func TestSegment(t *testing.T) { +func TestSegmentEffort(t *testing.T) { t.Parallel() a := assert.New(t) tests := []struct { name string before func(mux *http.ServeMux) - after func(segment *strava.Segment, err error) + after func(segmentEffort *strava.SegmentEffort, err error) }{ { - name: "valid segment", + name: "valid segment effort", before: func(mux *http.ServeMux) { - mux.HandleFunc("/segments/229781", func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "testdata/segment.json") + mux.HandleFunc("/segment_efforts/229781", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "testdata/segment_effort.json") }) }, - after: func(segment *strava.Segment, err error) { + after: func(segmentEffort *strava.SegmentEffort, err error) { a.NoError(err) - a.NotNil(segment) - a.Equal(229781, segment.ID) - a.Equal("Hawk Hill", segment.Name) + a.NotNil(segmentEffort) + a.Equal(int64(229781), segmentEffort.ID) + a.Equal("Hawk Hill Effort", segmentEffort.Name) + a.NotNil(segmentEffort.Segment) + a.Equal(229781, segmentEffort.Segment.ID) }, }, { - name: "invalid segment", + name: "invalid segment effort", before: func(_ *http.ServeMux) {}, - after: func(_ *strava.Segment, err error) { + after: func(_ *strava.SegmentEffort, err error) { a.Error(err) }, }, @@ -48,53 +50,53 @@ func TestSegment(t *testing.T) { t.Parallel() client, svr := newClientMust(tt.before) defer svr.Close() - tt.after(client.Segment.Segment(context.TODO(), 229781)) + tt.after(client.Segment.SegmentEffort(context.TODO(), 229781)) }) } } -func TestSegments(t *testing.T) { +func TestSegmentEfforts(t *testing.T) { t.Parallel() a := assert.New(t) tests := []struct { name string pagination activity.Pagination - after func(segments []*strava.Segment, err error) + after func(segmentEfforts []*strava.SegmentEffort, err error) }{ { name: "test total, start, and count", pagination: activity.Pagination{Total: 127, Start: 0, Count: 1}, - after: func(segments []*strava.Segment, err error) { + after: func(segmentEfforts []*strava.SegmentEffort, err error) { a.NoError(err) - a.NotNil(segments) - a.Equal(127, len(segments)) + a.NotNil(segmentEfforts) + a.Equal(127, len(segmentEfforts)) }, }, { name: "test total and start", pagination: activity.Pagination{Total: 234, Start: 0}, - after: func(segments []*strava.Segment, err error) { + after: func(segmentEfforts []*strava.SegmentEffort, err error) { a.NoError(err) - a.NotNil(segments) - a.Equal(234, len(segments)) + a.NotNil(segmentEfforts) + a.Equal(234, len(segmentEfforts)) }, }, { name: "test total and start less than PageSize", pagination: activity.Pagination{Total: 27, Start: 0}, - after: func(segments []*strava.Segment, err error) { + after: func(segmentEfforts []*strava.SegmentEffort, err error) { a.NoError(err) - a.NotNil(segments) - a.Equal(27, len(segments)) + a.NotNil(segmentEfforts) + a.Equal(27, len(segmentEfforts)) }, }, { name: "negative test", pagination: activity.Pagination{Total: -1}, - after: func(segments []*strava.Segment, err error) { + after: func(segmentEfforts []*strava.SegmentEffort, err error) { a.Error(err) - a.Nil(segments) + a.Nil(segmentEfforts) }, }, } @@ -103,12 +105,12 @@ func TestSegments(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() client, svr := newClientMust(func(mux *http.ServeMux) { - mux.Handle("/segments/starred", &ManyHandler{ - Filename: "testdata/segment.json", + mux.Handle("/segment_efforts", &ManyHandler{ + Filename: "testdata/segment_effort.json", }) }) defer svr.Close() - tt.after(client.Segment.Segments(context.TODO(), tt.pagination)) + tt.after(client.Segment.SegmentEfforts(context.TODO(), tt.pagination)) }) } } diff --git a/strava/testdata/segment.json b/strava/testdata/segment.json deleted file mode 100644 index d17b892..0000000 --- a/strava/testdata/segment.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "id": 229781, - "resource_state": 3, - "name": "Hawk Hill", - "activity_type": "Ride", - "distance": 1674.0, - "average_grade": 5.7, - "maximum_grade": 8.6, - "elevation_high": 139.5, - "elevation_low": 44.0, - "start_latlng": [ - 37.833111, - -122.483435 - ], - "end_latlng": [ - 37.840369, - -122.484489 - ], - "climb_category": 1, - "city": "Sausalito", - "state": "California", - "country": "United States", - "private": false, - "hazardous": false, - "starred": true, - "created_at": "2012-01-01T00:00:00Z", - "updated_at": "2012-01-01T00:00:00Z", - "total_elevation_gain": 95.5, - "map": { - "id": "s229781", - "summary_polyline": "a~l~Fjk~uOwHJy@P", - "resource_state": 3 - }, - "effort_count": 123456, - "athlete_count": 78910, - "star_count": 4567, - "athlete_pr_effort": { - "distance": 1674.0, - "start_date_local": "2018-06-01T07:00:00Z", - "activity_id": 123456789, - "elapsed_time": 360, - "is_kom": false, - "id": 987654, - "start_date": "2018-06-01T14:00:00Z" - }, - "athlete_segment_stats": { - "pr_elapsed_time": 360, - "pr_date": "2018-06-01T14:00:00Z", - "effort_count": 42, - "pr_activity_id": 123456789 - } -} diff --git a/strava/testdata/segment_effort.json b/strava/testdata/segment_effort.json new file mode 100644 index 0000000..e95c01d --- /dev/null +++ b/strava/testdata/segment_effort.json @@ -0,0 +1,70 @@ +{ + "id": 229781, + "resource_state": 2, + "name": "Hawk Hill Effort", + "activity": { + "id": 123456789, + "resource_state": 1 + }, + "athlete": { + "id": 1122, + "resource_state": 1 + }, + "elapsed_time": 360, + "moving_time": 355, + "start_date": "2018-06-01T14:00:00Z", + "start_date_local": "2018-06-01T07:00:00Z", + "distance": 1674.0, + "start_index": 42, + "end_index": 314, + "average_cadence": 87.5, + "device_watts": false, + "average_watts": 278.4, + "segment": { + "id": 229781, + "resource_state": 3, + "name": "Hawk Hill", + "activity_type": "Ride", + "distance": 1674.0, + "average_grade": 5.7, + "maximum_grade": 8.6, + "elevation_high": 139.5, + "elevation_low": 44.0, + "start_latlng": [ + 37.833111, + -122.483435 + ], + "end_latlng": [ + 37.840369, + -122.484489 + ], + "climb_category": 1, + "city": "Sausalito", + "state": "California", + "country": "United States", + "private": false, + "hazardous": false, + "starred": true, + "created_at": "2012-01-01T00:00:00Z", + "updated_at": "2012-01-01T00:00:00Z", + "total_elevation_gain": 95.5, + "map": { + "id": "s229781", + "summary_polyline": "a~l~Fjk~uOwHJy@P", + "resource_state": 3 + }, + "effort_count": 123456, + "athlete_count": 78910, + "star_count": 4567 + }, + "kom_rank": 0, + "pr_rank": 1, + "achievements": [ + { + "rank": 1, + "type": "pr", + "type_id": 3 + } + ], + "hidden": false +} From a53f772625194d3f23a963b9e20be1bfd8e167f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 06:59:31 +0000 Subject: [PATCH 3/7] Upgrade Go patch and fix lint config Agent-Logs-Url: https://github.com/bzimmer/activity/sessions/c93188a7-223c-4a89-a4f2-2eec088db77d Co-authored-by: bzimmer <12852+bzimmer@users.noreply.github.com> --- .golangci.yml | 29 ++++++++++++++--------------- go.mod | 2 +- strava/encoding.go | 8 +++++--- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 0753303..3c9cbd4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -23,7 +23,7 @@ linters: - gocritic - gocyclo - gomoddirectives - - gomodguard + - gomodguard_v2 - goprintffuncname - gosec - govet @@ -101,21 +101,20 @@ linters: paramsOnly: false underef: skipRecvDeref: false - gomodguard: + gomodguard_v2: blocked: - modules: - - github.com/golang/protobuf: - recommendations: - - google.golang.org/protobuf - reason: see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules - - github.com/satori/go.uuid: - recommendations: - - github.com/google/uuid - reason: satori's package is not maintained - - github.com/gofrs/uuid: - recommendations: - - github.com/google/uuid - reason: gofrs' package is not go module + - module: github.com/golang/protobuf + recommendations: + - google.golang.org/protobuf + reason: see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules + - module: github.com/satori/go.uuid + recommendations: + - github.com/google/uuid + reason: satori's package is not maintained + - module: github.com/gofrs/uuid + recommendations: + - github.com/google/uuid + reason: gofrs' package is not go module govet: disable: - fieldalignment diff --git a/go.mod b/go.mod index 2e16bea..21c9556 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/bzimmer/activity -go 1.26.2 +go 1.26.3 require ( github.com/bzimmer/httpwares v0.1.3 diff --git a/strava/encoding.go b/strava/encoding.go index 1f45df1..a7ec853 100644 --- a/strava/encoding.go +++ b/strava/encoding.go @@ -15,6 +15,8 @@ import ( var _ activity.GPXEncoder = (*Route)(nil) var _ activity.GPXEncoder = (*Activity)(nil) +const gpxVersion = "1.1" + func polylineToLineString(polylines ...string) (*geom.LineString, error) { const n = 2 var coords []float64 @@ -58,7 +60,7 @@ func (a *Activity) GPX() (*gpx.GPX, error) { }, } x := &gpx.GPX{ - Version: "1.1", + Version: gpxVersion, Trk: []*gpx.TrkType{trk}, } return x, nil @@ -81,7 +83,7 @@ func (r *Route) GPX() (*gpx.GPX, error) { }, } x := &gpx.GPX{ - Version: "1.1", + Version: gpxVersion, Rte: []*gpx.RteType{rte}, } return x, nil @@ -106,7 +108,7 @@ func (a *Activity) toGPXFromStreams() (*gpx.GPX, error) { } } x := &gpx.GPX{ - Version: "1.1", + Version: gpxVersion, Trk: []*gpx.TrkType{ { Name: a.Name, From 8eafe4db3cdbcc42ccde53d9313b9548d6755c0c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 07:08:00 +0000 Subject: [PATCH 4/7] Add segment effort invalid URL coverage tests Agent-Logs-Url: https://github.com/bzimmer/activity/sessions/523d3fdb-30e1-4560-9060-7d8e5b89b7a2 Co-authored-by: bzimmer <12852+bzimmer@users.noreply.github.com> --- strava/segment_test.go | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/strava/segment_test.go b/strava/segment_test.go index 2334581..6d3b5fd 100644 --- a/strava/segment_test.go +++ b/strava/segment_test.go @@ -18,6 +18,7 @@ func TestSegmentEffort(t *testing.T) { tests := []struct { name string before func(mux *http.ServeMux) + opts []strava.Option after func(segmentEffort *strava.SegmentEffort, err error) }{ { @@ -43,12 +44,20 @@ func TestSegmentEffort(t *testing.T) { a.Error(err) }, }, + { + name: "invalid base url", + before: func(_ *http.ServeMux) {}, + opts: []strava.Option{strava.WithBaseURL("://bad-url")}, + after: func(_ *strava.SegmentEffort, err error) { + a.Error(err) + }, + }, } for i := range tests { tt := tests[i] t.Run(tt.name, func(t *testing.T) { t.Parallel() - client, svr := newClientMust(tt.before) + client, svr := newClientMust(tt.before, tt.opts...) defer svr.Close() tt.after(client.Segment.SegmentEffort(context.TODO(), 229781)) }) @@ -62,6 +71,7 @@ func TestSegmentEfforts(t *testing.T) { tests := []struct { name string pagination activity.Pagination + opts []strava.Option after func(segmentEfforts []*strava.SegmentEffort, err error) }{ { @@ -99,6 +109,15 @@ func TestSegmentEfforts(t *testing.T) { a.Nil(segmentEfforts) }, }, + { + name: "invalid base url", + pagination: activity.Pagination{Total: 1}, + opts: []strava.Option{strava.WithBaseURL("://bad-url")}, + after: func(segmentEfforts []*strava.SegmentEffort, err error) { + a.Error(err) + a.Nil(segmentEfforts) + }, + }, } for i := range tests { tt := tests[i] @@ -108,7 +127,7 @@ func TestSegmentEfforts(t *testing.T) { mux.Handle("/segment_efforts", &ManyHandler{ Filename: "testdata/segment_effort.json", }) - }) + }, tt.opts...) defer svr.Close() tt.after(client.Segment.SegmentEfforts(context.TODO(), tt.pagination)) }) From 77764fc0cb6f33de8b1286d48190b644b4d5a23f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 08:14:34 +0000 Subject: [PATCH 5/7] Add SegmentEffortsIter with full tests Agent-Logs-Url: https://github.com/bzimmer/activity/sessions/b9bedb45-f923-4550-bb40-edcfde0d87fb Co-authored-by: bzimmer <12852+bzimmer@users.noreply.github.com> --- strava/segment.go | 17 ++++++++++++ strava/segment_test.go | 61 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/strava/segment.go b/strava/segment.go index 0e5ea0e..4e5f28a 100644 --- a/strava/segment.go +++ b/strava/segment.go @@ -11,6 +11,9 @@ import ( // SegmentService is the API for segment effort endpoints. type SegmentService service +// SegmentEffortIterFunc is called for each segment effort in the results. +type SegmentEffortIterFunc func(*SegmentEffort) (bool, error) + type segmentPaginator struct { segmentEfforts []*SegmentEffort service SegmentService @@ -66,3 +69,17 @@ func (s *SegmentService) SegmentEffort(ctx context.Context, segmentEffortID int6 } return seg, nil } + +// SegmentEffortsIter executes the iter function over segment effort results. +func SegmentEffortsIter(segmentEfforts []*SegmentEffort, iter SegmentEffortIterFunc) error { + for _, segmentEffort := range segmentEfforts { + ok, err := iter(segmentEffort) + if err != nil { + return err + } + if !ok { + return nil + } + } + return nil +} diff --git a/strava/segment_test.go b/strava/segment_test.go index 6d3b5fd..1c784c7 100644 --- a/strava/segment_test.go +++ b/strava/segment_test.go @@ -2,6 +2,7 @@ package strava_test import ( "context" + "errors" "net/http" "testing" @@ -118,12 +119,26 @@ func TestSegmentEfforts(t *testing.T) { a.Nil(segmentEfforts) }, }, + { + name: "invalid response", + pagination: activity.Pagination{Total: 1}, + after: func(segmentEfforts []*strava.SegmentEffort, err error) { + a.Error(err) + a.Nil(segmentEfforts) + }, + }, } for i := range tests { tt := tests[i] t.Run(tt.name, func(t *testing.T) { t.Parallel() client, svr := newClientMust(func(mux *http.ServeMux) { + if tt.name == "invalid response" { + mux.HandleFunc("/segment_efforts", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("{")) + }) + return + } mux.Handle("/segment_efforts", &ManyHandler{ Filename: "testdata/segment_effort.json", }) @@ -133,3 +148,49 @@ func TestSegmentEfforts(t *testing.T) { }) } } + +func TestSegmentEffortsIter(t *testing.T) { + t.Parallel() + + t.Run("all", func(t *testing.T) { + t.Parallel() + a := assert.New(t) + segmentEfforts := []*strava.SegmentEffort{{ID: 1}, {ID: 2}} + var ids []int64 + + err := strava.SegmentEffortsIter(segmentEfforts, func(segmentEffort *strava.SegmentEffort) (bool, error) { + ids = append(ids, segmentEffort.ID) + return true, nil + }) + + a.NoError(err) + a.Equal([]int64{1, 2}, ids) + }) + + t.Run("stop early", func(t *testing.T) { + t.Parallel() + a := assert.New(t) + segmentEfforts := []*strava.SegmentEffort{{ID: 1}, {ID: 2}} + count := 0 + + err := strava.SegmentEffortsIter(segmentEfforts, func(_ *strava.SegmentEffort) (bool, error) { + count++ + return false, nil + }) + + a.NoError(err) + a.Equal(1, count) + }) + + t.Run("iter error", func(t *testing.T) { + t.Parallel() + a := assert.New(t) + want := errors.New("iter error") + + err := strava.SegmentEffortsIter([]*strava.SegmentEffort{{ID: 1}}, func(_ *strava.SegmentEffort) (bool, error) { + return true, want + }) + + a.ErrorIs(err, want) + }) +} From f2eefff68097918370e66340b6775b1820db1458 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 11:58:56 +0000 Subject: [PATCH 6/7] Add date range options to SegmentEfforts Agent-Logs-Url: https://github.com/bzimmer/activity/sessions/408adeab-1457-497c-aca4-85f52b5e20cc Co-authored-by: bzimmer <12852+bzimmer@users.noreply.github.com> --- strava/segment.go | 22 +++++++++++++++++++--- strava/segment_test.go | 41 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/strava/segment.go b/strava/segment.go index 4e5f28a..2b09f31 100644 --- a/strava/segment.go +++ b/strava/segment.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "net/url" "github.com/bzimmer/activity" ) @@ -17,6 +18,7 @@ type SegmentEffortIterFunc func(*SegmentEffort) (bool, error) type segmentPaginator struct { segmentEfforts []*SegmentEffort service SegmentService + options []APIOption } func (p *segmentPaginator) PageSize() int { @@ -28,7 +30,18 @@ func (p *segmentPaginator) Count() int { } func (p *segmentPaginator) Do(ctx context.Context, spec activity.Pagination) (int, error) { - uri := fmt.Sprintf("segment_efforts?page=%d&per_page=%d", spec.Start, spec.Count) + v := make(url.Values) + v.Set("page", fmt.Sprintf("%d", spec.Start)) + v.Set("per_page", fmt.Sprintf("%d", spec.Count)) + for _, opt := range p.options { + if opt == nil { + continue + } + if err := opt(v); err != nil { + return 0, err + } + } + uri := fmt.Sprintf("segment_efforts?%s", v.Encode()) req, err := p.service.client.newAPIRequest(ctx, http.MethodGet, uri, nil) if err != nil { return 0, err @@ -46,8 +59,11 @@ func (p *segmentPaginator) Do(ctx context.Context, spec activity.Pagination) (in } // SegmentEfforts returns a page of segment efforts for the authenticated athlete. -func (s *SegmentService) SegmentEfforts(ctx context.Context, spec activity.Pagination) ([]*SegmentEffort, error) { - p := &segmentPaginator{service: *s, segmentEfforts: make([]*SegmentEffort, 0)} +// +// The returned segment efforts can be filtered by date using WithDateRange(before, after). +func (s *SegmentService) SegmentEfforts( + ctx context.Context, spec activity.Pagination, opts ...APIOption) ([]*SegmentEffort, error) { + p := &segmentPaginator{service: *s, segmentEfforts: make([]*SegmentEffort, 0), options: opts} err := activity.Paginate(ctx, p, spec) if err != nil { return nil, err diff --git a/strava/segment_test.go b/strava/segment_test.go index 1c784c7..d4f4a35 100644 --- a/strava/segment_test.go +++ b/strava/segment_test.go @@ -4,7 +4,9 @@ import ( "context" "errors" "net/http" + "net/url" "testing" + "time" "github.com/stretchr/testify/assert" @@ -73,6 +75,7 @@ func TestSegmentEfforts(t *testing.T) { name string pagination activity.Pagination opts []strava.Option + opt strava.APIOption after func(segmentEfforts []*strava.SegmentEffort, err error) }{ { @@ -127,6 +130,42 @@ func TestSegmentEfforts(t *testing.T) { a.Nil(segmentEfforts) }, }, + { + name: "zero dates", + opt: strava.WithDateRange(time.Time{}, time.Time{}), + pagination: activity.Pagination{Total: 2}, + after: func(segmentEfforts []*strava.SegmentEffort, err error) { + a.NoError(err) + a.NotNil(segmentEfforts) + a.Equal(2, len(segmentEfforts)) + }, + }, + { + name: "before and after", + opt: func() strava.APIOption { + before := time.Now() + after := before.Add(time.Hour * time.Duration(-24*7)) + return strava.WithDateRange(before, after) + }(), + pagination: activity.Pagination{Total: 2}, + after: func(segmentEfforts []*strava.SegmentEffort, err error) { + a.NoError(err) + a.NotNil(segmentEfforts) + a.Equal(2, len(segmentEfforts)) + }, + }, + { + name: "error in option", + opt: func(url.Values) error { + return errors.New("error in option") + }, + pagination: activity.Pagination{Total: 2}, + after: func(segmentEfforts []*strava.SegmentEffort, err error) { + a.Error(err) + a.Nil(segmentEfforts) + a.Contains(err.Error(), "error in option") + }, + }, } for i := range tests { tt := tests[i] @@ -144,7 +183,7 @@ func TestSegmentEfforts(t *testing.T) { }) }, tt.opts...) defer svr.Close() - tt.after(client.Segment.SegmentEfforts(context.TODO(), tt.pagination)) + tt.after(client.Segment.SegmentEfforts(context.TODO(), tt.pagination, tt.opt)) }) } } From 23b9c8ac9d94668d111076b96ba5fe7a62c46885 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 12:44:05 +0000 Subject: [PATCH 7/7] Move WithDateRange to strava core Agent-Logs-Url: https://github.com/bzimmer/activity/sessions/45c0cbe1-768e-45f3-996b-21cc4798ea3a Co-authored-by: bzimmer <12852+bzimmer@users.noreply.github.com> --- strava/activity.go | 19 ------------------- strava/strava.go | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/strava/activity.go b/strava/activity.go index 9231e3e..a3bdd5f 100644 --- a/strava/activity.go +++ b/strava/activity.go @@ -12,7 +12,6 @@ import ( "net/url" "regexp" "strings" - "time" "golang.org/x/sync/errgroup" @@ -28,24 +27,6 @@ type ActivityIterFunc func(*Activity) (bool, error) // fileNameRE allowable characters var fileNameRE = regexp.MustCompile("[A-Za-z0-9-]+") -// WithDateRange sets the before and after date range -func WithDateRange(before, after time.Time) APIOption { - return func(v url.Values) error { - if !before.IsZero() && !after.IsZero() { - if after.After(before) { - return errors.New("invalid date range") - } - } - if !before.IsZero() { - v.Set("before", fmt.Sprintf("%d", before.Unix())) - } - if !after.IsZero() { - v.Set("after", fmt.Sprintf("%d", after.Unix())) - } - return nil - } -} - type channelPaginator struct { count int options []APIOption diff --git a/strava/strava.go b/strava/strava.go index 3f0609b..327cf79 100644 --- a/strava/strava.go +++ b/strava/strava.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "time" "golang.org/x/oauth2" @@ -24,6 +25,24 @@ const ( // APIOption for configuring API requests type APIOption func(url.Values) error +// WithDateRange sets the before and after date range. +func WithDateRange(before, after time.Time) APIOption { + return func(v url.Values) error { + if !before.IsZero() && !after.IsZero() { + if after.After(before) { + return errors.New("invalid date range") + } + } + if !before.IsZero() { + v.Set("before", fmt.Sprintf("%d", before.Unix())) + } + if !after.IsZero() { + v.Set("after", fmt.Sprintf("%d", after.Unix())) + } + return nil + } +} + // Endpoint is Strava's OAuth 2.0 endpoint func Endpoint() oauth2.Endpoint { return oauth2.Endpoint{ //nolint:gosec // not a secret