diff --git a/.rubocop.yml b/.rubocop.yml index f2e1db64..eb925220 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -37,14 +37,11 @@ Style/MultilineBlockChain: RSpec/MultipleExpectations: Enabled: false +RSpec/VerifiedDoubles: + Enabled: false + RSpec/SpecFilePathFormat: - Exclude: - - "spec/strava/version_spec.rb" - - "spec/strava/oauth/client_spec.rb" - - "spec/strava/oauth/config_spec.rb" - - "spec/strava/models/activities/ride_spec.rb" - - "spec/strava/models/activities/swim_spec.rb" - - "spec/strava/models/activities/run_spec.rb" + Enabled: false RSpec/ExampleLength: Enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 90aa0beb..e744cbce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ * [#105](https://github.com/dblock/strava-ruby-client/pull/105): Adds `total_elevation_gain`, `total_elevation_loss` and formatted helpers to `Strava::Models::Stream`, computed from altitude stream data - [@dblock](https://github.com/dblock). * [#106](https://github.com/dblock/strava-ruby-client/pull/106): Adds test coverage reporting with [coveralls.io](https://coveralls.io) - [@dblock](https://github.com/dblock). * [#108](https://github.com/dblock/strava-ruby-client/pull/108): Adds an integration test that verifies the real Strava API endpoint is reachable, run in CI on every push and pull request via `rake spec:integration` - [@dblock](https://github.com/dblock). +* [#107](https://github.com/dblock/strava-ruby-client/pull/107): Fixes `explore_segments` and `star_segment` raising `UncaughtThrowError` instead of `ArgumentError` for missing required arguments - [@dblock](https://github.com/dblock). +* [#107](https://github.com/dblock/strava-ruby-client/pull/107): Fixes `start_date_local` to always derive the timezone offset from the difference between `start_date` and `start_date_local`, since Strava's `timezone` property does not account for daylight saving time - [@dblock](https://github.com/dblock). * Your contribution here. ### 3.0.0 (2025/10/24) diff --git a/lib/strava/api/endpoints/segments.rb b/lib/strava/api/endpoints/segments.rb index f2cd5a87..c5119b5c 100644 --- a/lib/strava/api/endpoints/segments.rb +++ b/lib/strava/api/endpoints/segments.rb @@ -35,7 +35,8 @@ module Segments # @see https://developers.strava.com/docs/reference/#api-Segments-exploreSegments # def explore_segments(options = {}) - throw ArgumentError.new('Required argument :bounds missing') if options[:bounds].nil? + raise ArgumentError, 'Required argument :bounds missing' if options[:bounds].nil? + bounds = options[:bounds] bounds = bounds.map(&:to_s).join(',') if bounds.is_a?(Array) get('segments/explore', options.merge(bounds: bounds))['segments'].map do |row| @@ -109,7 +110,8 @@ def segment(id_or_options, options = {}) # def star_segment(id_or_options, options = {}) id, options = parse_args(id_or_options, options) - throw ArgumentError.new('Required argument :starred missing') if options[:starred].nil? + raise ArgumentError, 'Required argument :starred missing' if options[:starred].nil? + Strava::Models::DetailedSegment.new(put("segments/#{id}/starred", options)) end end diff --git a/lib/strava/models/mixins/start_date_local.rb b/lib/strava/models/mixins/start_date_local.rb index 6d866fa1..f991c0d1 100644 --- a/lib/strava/models/mixins/start_date_local.rb +++ b/lib/strava/models/mixins/start_date_local.rb @@ -7,9 +7,10 @@ module Mixins # Provides local start date/time with timezone handling. # # This mixin adds the start_date_local property and handles proper timezone - # conversion. It attempts to use the 'timezone' property if available, or - # calculates the timezone offset from the difference between start_date - # (UTC) and start_date_local. + # conversion. The offset is calculated from the difference between start_date + # (UTC) and start_date_local, which correctly accounts for daylight saving + # time (unlike Strava's 'timezone' property, which only reflects the zone's + # standard GMT offset). # # This is particularly important for activities that cross timezone boundaries # or for displaying times in the athlete's local timezone. @@ -28,16 +29,14 @@ module StartDateLocal # # Returns the start date/time in the local timezone. # - # Constructs a Time object with the proper timezone offset based on - # the 'timezone' property or calculated from the difference between - # start_date and start_date_local. + # Constructs a Time object with the offset calculated from the difference + # between start_date and start_date_local. # # @return [Time] Start date/time with local timezone # def start_date_local extracted_datetime = self['start_date_local'] - # Some Strava objects do not contain a timezone property (e.g., Lap) - timezone_shift = conditional_timezone(extracted_datetime) + timezone_shift = calculate_timezone(extracted_datetime) ::Time.new(extracted_datetime.year, extracted_datetime.month, extracted_datetime.day, @@ -52,46 +51,26 @@ def start_date_local # # Determines the timezone offset for the start date. # - # If a 'timezone' property exists, parses it to extract the offset. - # Otherwise, calculates the offset from the difference between - # start_date (UTC) and start_date_local. + # Calculates the offset from the difference between start_date (UTC) + # and start_date_local. This is used instead of Strava's 'timezone' + # property, which reflects the zone's standard GMT offset and does + # not account for daylight saving time. # # @param extracted_datetime [Time] The parsed start_date_local value # @return [String] Timezone offset string (e.g., "-05:00") # # @api private # - def conditional_timezone(extracted_datetime) - if key?(:timezone) - if timezone.include?('+') - timezone_shift_string('+') - elsif timezone.include?('-') - timezone_shift_string('-') - else - raise ArgumentError 'No operator of timezone correction detectable!' - end - else - calculate_timezone(extracted_datetime) - end - end - def calculate_timezone(extracted_datetime) if start_date == extracted_datetime timezone_diff_shift_string(0, '-') elsif extracted_datetime < start_date timezone_diff_shift_string((extracted_datetime - start_date), '-') - elsif extracted_datetime > start_date - timezone_diff_shift_string((extracted_datetime - start_date), '+') else - raise ArgumentError 'No operator of timezone correction detectable!' + timezone_diff_shift_string((extracted_datetime - start_date), '+') end end - def timezone_shift_string(operator) - diff_hours = timezone.split(operator).last.to_i - "#{operator}#{format_int_leading_zero(diff_hours)}:00" - end - def timezone_diff_shift_string(time_diff, operator) diff_hours = (time_diff.abs / 3600).to_i "#{operator}#{format_int_leading_zero(diff_hours)}:00" diff --git a/lib/strava/web/client.rb b/lib/strava/web/client.rb index 6ae43c65..9e34e384 100644 --- a/lib/strava/web/client.rb +++ b/lib/strava/web/client.rb @@ -99,10 +99,12 @@ def config # def parse_args(id_or_options, options = {}) if id_or_options.is_a?(Hash) - throw ArgumentError.new('Required argument :id missing') if id_or_options[:id].nil? + raise ArgumentError, 'Required argument :id missing' if id_or_options[:id].nil? + [id_or_options[:id], id_or_options.except(:id)] else - throw ArgumentError.new('Required argument :id missing') if id_or_options.nil? + raise ArgumentError, 'Required argument :id missing' if id_or_options.nil? + [id_or_options, options] end end diff --git a/spec/fixtures/strava/client/star_segment.yml b/spec/fixtures/strava/client/star_segment.yml new file mode 100644 index 00000000..edacfd86 --- /dev/null +++ b/spec/fixtures/strava/client/star_segment.yml @@ -0,0 +1,83 @@ +--- +http_interactions: +- request: + method: put + uri: https://www.strava.com/api/v3/segments/1109718/starred + body: + encoding: UTF-8 + string: starred=true + headers: + Authorization: + - Bearer access-token + Accept: + - application/json; charset=utf-8 + User-Agent: + - Strava Ruby Client/3.1.0 + Content-Type: + - application/x-www-form-urlencoded + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Date: + - Tue, 25 Aug 2026 02:06:53 GMT + X-Envoy-Upstream-Service-Time: + - '350' + Server: + - istio-envoy + Status: + - 200 OK + X-Ratelimit-Usage: + - '1,21' + X-Ratelimit-Limit: + - '200,2000' + Vary: + - Accept, Origin + Cache-Control: + - max-age=0, private, must-revalidate + Referrer-Policy: + - strict-origin-when-cross-origin + X-Permitted-Cross-Domain-Policies: + - none + X-Xss-Protection: + - 1; mode=block + X-Request-Id: + - 6486c9a8-fc8e-4109-adad-b811dd2143a7 + X-Readratelimit-Limit: + - '100,1000' + X-Download-Options: + - noopen + Etag: + - W/"b96a93afa7f36f3fd6b5fdfd51ac79c2" + X-Frame-Options: + - DENY + X-Readratelimit-Usage: + - '1,21' + X-Content-Type-Options: + - nosniff + X-Cache: + - Miss from cloudfront + Via: + - 1.1 4e1c4d133adc8d8214916eeaddd7af66.cloudfront.net (CloudFront) + X-Amz-Cf-Pop: + - JFK52-P3 + X-Amz-Cf-Id: + - _Rp1izZOTF1pDk2k6slWjJT26IoblY6asQYeglw8P0yedrNzqQMcKQ== + body: + encoding: UTF-8 + string: '{"id":1109718,"resource_state":3,"name":"E 14th St Climb","activity_type":"Run","distance":419.58,"average_grade":-0.8,"maximum_grade":-0.6,"elevation_high":7.1,"elevation_low":3.8,"start_latlng":[40.73554842732847,-73.98271151818335],"end_latlng":[40.73427177965641,-73.97865023463964],"elevation_profile":null,"elevation_profiles":null,"climb_category":0,"city":"New + York","state":"NY","country":"United States","private":false,"hazardous":false,"starred":true,"pr_time":116,"athlete_pr_effort":{"id":41494197089,"activity_id":1655832792,"activity_id_str":"1655832792","elapsed_time":116,"distance":419.58,"start_date":"2018-06-22T12:42:43Z","start_date_local":"2018-06-22T08:42:43Z","is_kom":false},"starred_date":"2025-10-19T16:17:16Z","created_at":"2012-03-19T22:00:20Z","updated_at":"2021-05-15T08:02:38Z","total_elevation_gain":40.2,"map":{"id":"s1109718","polyline":"cdswF~vpbMd@eBJS^QTUf@u@LK\\cADc@FUFMZ[BIOcCQ[[QGAISzAoE","resource_state":3},"effort_count":4497,"athlete_count":1329,"star_count":3,"athlete_segment_stats":{"pr_elapsed_time":116,"pr_date":"2018-06-22","pr_visibility":"everyone","pr_activity_id":1655832792,"pr_activity_id_str":"1655832792","pr_activity_visibility":"everyone","effort_count":5},"xoms":{"kom":"1:09","qom":"1:31","overall":"1:09","destination":{"href":"strava://segments/1109718/leaderboard","type":"overall","name":"All-Time"}},"local_legend":{"athlete_id":149514149,"athlete_id_str":"149514149","title":"Alonzo + Rodriguez","profile":"https://dgalywyr863hv.cloudfront.net/pictures/athletes/149514149/33600350/3/large.jpg","effort_description":"13 + efforts in the last 90 days","effort_count":"13","effort_counts":{"overall":"13 + efforts","female":"11 efforts"},"destination":"strava://segments/1109718/local_legend?categories%5B%5D=overall"}}' + recorded_at: Tue, 25 Aug 2026 02:06:53 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/strava/api/client/endpoints/activities/activity_spec.rb b/spec/strava/api/client/endpoints/activities/activity_spec.rb index 35db197b..a2ee5afa 100644 --- a/spec/strava/api/client/endpoints/activities/activity_spec.rb +++ b/spec/strava/api/client/endpoints/activities/activity_spec.rb @@ -114,16 +114,22 @@ expect(split_metric).to be_a Strava::Models::Split expect(split_metric.distance).to eq 1001.6 expect(split_metric.distance_in_meters).to eq 1001.6 + expect(split_metric.distance_in_feet).to eq 3286.089344 expect(split_metric.distance_in_miles).to eq 0.622364192 expect(split_metric.elapsed_time).to eq 314 expect(split_metric.pace_per_kilometer_s).to eq '5m13s/km' expect(split_metric.pace_per_mile_s).to eq '8m24s/mi' + expect(split_metric.pace_s).to eq '5m13s/km' expect(split_metric.average_speed_kilometer_per_hour_s).to eq '11.5km/h' expect(split_metric.average_speed_miles_per_hour_s).to eq '7.1mph' + expect(split_metric.average_speed_meters_per_second).to eq 3.19 expect(split_metric.elevation_difference).to eq 15.6 expect(split_metric.elevation_difference).to eq 15.6 expect(split_metric.elevation_difference_in_feet).to eq 51.181104 expect(split_metric.elevation_difference_in_meters).to eq 15.6 + expect(split_metric.elevation_difference_in_meters_s).to eq '15.6m' + expect(split_metric.elevation_difference_in_feet_s).to eq '51.2ft' + expect(split_metric.elevation_difference_s).to eq '15.6m' expect(split_metric.moving_time).to eq 314 expect(split_metric.split).to eq 1 expect(split_metric.average_speed).to eq 3.19 diff --git a/spec/strava/api/client/endpoints/routes/route_spec.rb b/spec/strava/api/client/endpoints/routes/route_spec.rb index b1e83a13..023b88aa 100644 --- a/spec/strava/api/client/endpoints/routes/route_spec.rb +++ b/spec/strava/api/client/endpoints/routes/route_spec.rb @@ -9,10 +9,15 @@ expect(route).to be_a Strava::Models::Route expect(route.id).to eq 16_341_573 expect(route.athlete).to be_a Strava::Models::SummaryAthlete + expect(route.athlete.name).to eq 'Daniel Doubrovkine' expect(route.name).to eq 'Lower Manhattan Loop' expect(route.description).to eq 'My usual long run when I am too lazy to go to Central Park.' expect(route.elevation_gain).to eq 117.25346822039764 expect(route.elevation_gain_s).to eq '117.3m' + expect(route.elevation_gain_in_feet).to eq 384.68986867620936 + expect(route.elevation_gain_in_meters).to eq 117.25346822039764 + expect(route.elevation_gain_in_meters_s).to eq '117.3m' + expect(route.elevation_gain_in_feet_s).to eq '384.7ft' expect(route.map).to be_a Strava::Models::Map expect(route.private).to be false expect(route.resource_state).to eq 3 diff --git a/spec/strava/api/client/endpoints/segments/explore_segments_spec.rb b/spec/strava/api/client/endpoints/segments/explore_segments_spec.rb index b99a0969..2d5c04b0 100644 --- a/spec/strava/api/client/endpoints/segments/explore_segments_spec.rb +++ b/spec/strava/api/client/endpoints/segments/explore_segments_spec.rb @@ -20,4 +20,8 @@ expect(segment.points).to eq '}qa}Eb`~}Pp@FdA^RJt@nARPVHb@Bb@JFLALS^?`@THv@OT?VLNR\\x@FFL?LI' # TODO: polyline expect(segment.starred).to be false end + + it 'raises an error when :bounds is missing' do + expect { client.explore_segments }.to raise_error ArgumentError, 'Required argument :bounds missing' + end end diff --git a/spec/strava/api/client/endpoints/segments/star_segment_spec.rb b/spec/strava/api/client/endpoints/segments/star_segment_spec.rb new file mode 100644 index 00000000..9f2c63a5 --- /dev/null +++ b/spec/strava/api/client/endpoints/segments/star_segment_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Strava::Api::Client#star_segment', vcr: { cassette_name: 'client/star_segment' } do + include_context 'with API client' + it 'stars a segment' do + segment = client.star_segment(id: 1_109_718, starred: true) + expect(segment).to be_a Strava::Models::DetailedSegment + expect(segment.starred).to be true + end + + it 'stars a segment by id' do + segment = client.star_segment(1_109_718, starred: true) + expect(segment).to be_a Strava::Models::DetailedSegment + expect(segment.starred).to be true + end + + it 'raises an error when :starred is missing' do + expect { client.star_segment(id: 1_109_718) }.to raise_error ArgumentError, 'Required argument :starred missing' + end +end diff --git a/spec/strava/api/client_spec.rb b/spec/strava/api/client_spec.rb index e0c14c07..f56af5e5 100644 --- a/spec/strava/api/client_spec.rb +++ b/spec/strava/api/client_spec.rb @@ -11,6 +11,29 @@ it_behaves_like 'web client' + describe '.configure' do + after do + described_class.config.access_token = nil + end + + it 'yields the config when a block is given' do + described_class.configure do |config| + config.access_token = 'token' + end + expect(described_class.config.access_token).to eq 'token' + end + + it 'returns the config when no block is given' do + expect(described_class.configure).to eq Strava::Api::Config + end + end + + describe '.config' do + it 'returns the config' do + expect(described_class.config).to eq Strava::Api::Config + end + end + context 'with errors' do it 'handles authorization errors', vcr: { cassette_name: 'client/authorization_error' } do expect { client.activity(id: 1_946_417_534) }.to raise_error Strava::Errors::Fault, /Authorization Error/ diff --git a/spec/strava/api/pagination_spec.rb b/spec/strava/api/pagination_spec.rb new file mode 100644 index 00000000..0a3c87c1 --- /dev/null +++ b/spec/strava/api/pagination_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Strava::Api::Pagination do + let(:web_response) { double('web_response', http_response: double('http_response')) } + let(:collection) { %w[a b c] } + let(:pagination) { described_class.new(collection, web_response) } + + describe '#collection' do + it 'returns the underlying collection' do + expect(pagination.collection).to eq collection + end + end + + describe '#size' do + it 'delegates to the collection' do + expect(pagination.size).to eq 3 + end + end + + describe '#each' do + it 'iterates over the collection' do + expect { |b| pagination.each(&b) }.to yield_successive_args('a', 'b', 'c') + end + + it 'returns the collection when no block is given' do + expect(pagination.each).to eq collection + end + end + + describe 'method_missing' do + it 'delegates unknown methods to the collection' do + expect(pagination.first).to eq 'a' + expect(pagination.last).to eq 'c' + end + end + + describe 'respond_to?' do + it 'returns true for methods supported by the collection' do + expect(pagination.respond_to?(:first)).to be true + end + + it 'returns false for methods not supported by the collection' do + expect(pagination.respond_to?(:not_a_real_method)).to be false + end + end +end diff --git a/spec/strava/api/ratelimit_spec.rb b/spec/strava/api/ratelimit_spec.rb new file mode 100644 index 00000000..7953e386 --- /dev/null +++ b/spec/strava/api/ratelimit_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Strava::Api::Ratelimit do + let(:headers) { {} } + let(:response) { double('response', headers: headers, body: nil) } + let(:ratelimit) { described_class.new(response) } + + context 'without ratelimit headers' do + it 'is not limited' do + expect(ratelimit.limit?).to be false + end + + it 'has nil fifteen_minutes' do + expect(ratelimit.fifteen_minutes).to be_nil + end + + it 'has nil total_day' do + expect(ratelimit.total_day).to be_nil + end + + it 'has nil fifteen_minutes_usage' do + expect(ratelimit.fifteen_minutes_usage).to be_nil + end + + it 'has nil total_day_usage' do + expect(ratelimit.total_day_usage).to be_nil + end + + it 'has nil fifteen_minutes_remaining' do + expect(ratelimit.fifteen_minutes_remaining).to be_nil + end + + it 'has nil total_day_remaining' do + expect(ratelimit.total_day_remaining).to be_nil + end + + it 'is not exceeded' do + expect(ratelimit.exceeded?).to be false + expect(ratelimit.exceeded).to be false + end + + it 'returns an empty hash' do + expect(ratelimit.to_h).to eq({}) + end + + it 'returns an empty string' do + expect(ratelimit.to_s).to eq '' + end + end + + context 'with ratelimit headers under the limit' do + let(:headers) { { 'x-ratelimit-limit' => '600,30000', 'x-ratelimit-usage' => '10,100' } } + + it 'is limited' do + expect(ratelimit.limit?).to be true + end + + it 'returns fifteen_minutes' do + expect(ratelimit.fifteen_minutes).to eq 600 + end + + it 'returns total_day' do + expect(ratelimit.total_day).to eq 30_000 + end + + it 'returns fifteen_minutes_usage' do + expect(ratelimit.fifteen_minutes_usage).to eq 10 + end + + it 'returns total_day_usage' do + expect(ratelimit.total_day_usage).to eq 100 + end + + it 'returns fifteen_minutes_remaining' do + expect(ratelimit.fifteen_minutes_remaining).to eq 590 + end + + it 'returns total_day_remaining' do + expect(ratelimit.total_day_remaining).to eq 29_900 + end + + it 'is not exceeded' do + expect(ratelimit.exceeded?).to be false + expect(ratelimit.exceeded).to be_nil + end + + it 'returns a populated hash' do + expect(ratelimit.to_h).to eq( + limit: '600,30000', + usage: '10,100', + total_day: 30_000, + total_day_usage: 100, + total_day_remaining: 29_900, + fifteen_minutes: 600, + fifteen_minutes_usage: 10, + fifteen_minutes_remaining: 590 + ) + end + + it 'returns a formatted string' do + expect(ratelimit.to_s).to include('limit: 600,30000') + end + end + + context 'when the fifteen minute limit is exceeded' do + let(:headers) { { 'x-ratelimit-limit' => '600,30000', 'x-ratelimit-usage' => '600,100' } } + + it 'is exceeded' do + expect(ratelimit.exceeded?).to be true + expect(ratelimit.exceeded).to eq(fifteen_minutes_remaining: 0) + end + end + + context 'when the total day limit is exceeded' do + let(:headers) { { 'x-ratelimit-limit' => '600,30000', 'x-ratelimit-usage' => '10,30000' } } + + it 'is exceeded' do + expect(ratelimit.exceeded?).to be true + expect(ratelimit.exceeded).to eq(total_day_remaining: 0) + end + end +end diff --git a/spec/strava/models/mixins/average_speed_spec.rb b/spec/strava/models/mixins/average_speed_spec.rb new file mode 100644 index 00000000..f9a0e035 --- /dev/null +++ b/spec/strava/models/mixins/average_speed_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Strava::Models::Split do + describe '#pace_per_kilometer_s' do + it 'rounds seconds up to the next minute when seconds round to 60' do + split = described_class.new('average_speed' => 0.463) + expect(split.pace_per_kilometer_s).to eq '36m00s/km' + end + end +end diff --git a/spec/strava/models/mixins/sport_type_spec.rb b/spec/strava/models/mixins/sport_type_spec.rb new file mode 100644 index 00000000..8e55821b --- /dev/null +++ b/spec/strava/models/mixins/sport_type_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Strava::Models::DetailedActivity do + describe '#sport_type_emoji' do + { + 'AlpineSki' => '⛷️', + 'BackcountrySki' => '🎿️', + 'Golf' => '🏌️', + 'Hike' => '🥾', + 'IceSkate' => '⛸', + 'InlineSkate' => "\u{1F6FC}", + 'MountainBikeRide' => '🚵', + 'EMountainBikeRide' => '🚵', + 'Ride' => '🚴', + 'EBikeRide' => '🚴', + 'VirtualRide' => '🚴', + 'GravelRide' => '🚴', + 'RockClimbing' => '🧗', + 'Rowing' => '🚣', + 'Run' => '🏃', + 'VirtualRun' => '🏃', + 'TrailRun' => '🏃', + 'Sail' => '⛵️', + 'Skateboard' => '🛹', + 'Snowboard' => '🏂', + 'Soccer' => '⚽️', + 'Surfing' => '🏄', + 'Swim' => '🏊', + 'Walk' => '🚶', + 'WeightTraining' => '🏋️', + 'Wheelchair' => '♿', + 'Yoga' => '🧘' + }.each do |sport_type, emoji| + it "returns #{emoji.inspect} for #{sport_type}" do + activity = described_class.new('sport_type' => sport_type) + expect(activity.sport_type_emoji).to eq emoji + end + end + + it 'returns nil for an unknown sport type' do + activity = described_class.new('sport_type' => 'SomethingElse') + expect(activity.sport_type_emoji).to be_nil + end + end +end diff --git a/spec/strava/models/mixins/start_date_local_spec.rb b/spec/strava/models/mixins/start_date_local_spec.rb new file mode 100644 index 00000000..41d6491f --- /dev/null +++ b/spec/strava/models/mixins/start_date_local_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Strava::Models::DetailedActivity do + describe '#start_date_local' do + context 'when start_date and start_date_local are the same' do + let(:activity) do + described_class.new( + 'start_date' => '2024-01-15T14:30:00Z', + 'start_date_local' => '2024-01-15T14:30:00Z' + ) + end + + it 'returns the local time with no offset' do + expect(activity.start_date_local.utc_offset).to eq 0 + end + end + + context 'when start_date_local is behind start_date' do + let(:activity) do + described_class.new( + 'start_date' => '2024-01-15T14:30:00Z', + 'start_date_local' => '2024-01-15T09:30:00Z' + ) + end + + it 'returns the local time with a negative offset' do + expect(activity.start_date_local.utc_offset).to eq(-5 * 3600) + end + end + + context 'when start_date_local is ahead of start_date' do + let(:activity) do + described_class.new( + 'start_date' => '2024-01-15T14:30:00Z', + 'start_date_local' => '2024-01-15T19:30:00Z' + ) + end + + it 'returns the local time with a positive offset' do + expect(activity.start_date_local.utc_offset).to eq 5 * 3600 + end + end + + context 'when a timezone property is present' do + let(:activity) do + described_class.new( + 'start_date' => '2024-01-15T14:30:00Z', + 'start_date_local' => '2024-01-15T09:30:00Z', + 'timezone' => '(GMT-05:00) America/New_York' + ) + end + + it 'still derives the offset from the difference between start_date and start_date_local' do + expect(activity.start_date_local.utc_offset).to eq(-5 * 3600) + end + end + end +end diff --git a/spec/strava/web/client_spec.rb b/spec/strava/web/client_spec.rb index c802ea61..58f0b68d 100644 --- a/spec/strava/web/client_spec.rb +++ b/spec/strava/web/client_spec.rb @@ -5,9 +5,48 @@ describe Strava::Web::Client do let(:client) { described_class.new } + before do + Strava::Web::Config.reset + end + + describe '.configure' do + after do + described_class.config.user_agent = "Strava Ruby Client/#{Strava::VERSION}" + end + + it 'yields the config when a block is given' do + described_class.configure do |config| + config.user_agent = 'my-app' + end + expect(described_class.config.user_agent).to eq 'my-app' + end + + it 'returns the config when no block is given' do + expect(described_class.configure).to eq Strava::Web::Config + end + end + describe '#endpoint' do it 'is not implemented' do expect { client.endpoint }.to raise_error NotImplementedError end end + + describe '#parse_args' do + it 'returns id and options from a hash' do + expect(client.parse_args(id: '12345', per_page: 10)).to eq(['12345', { per_page: 10 }]) + end + + it 'returns id and options when given separately' do + expect(client.parse_args('12345', per_page: 10)).to eq(['12345', { per_page: 10 }]) + end + + it 'raises an error when :id is missing from a hash' do + expect { client.parse_args(per_page: 10) }.to raise_error ArgumentError, 'Required argument :id missing' + end + + it 'raises an error when id_or_options is nil' do + expect { client.parse_args(nil) }.to raise_error ArgumentError, 'Required argument :id missing' + end + end end diff --git a/spec/strava/web/raise_response_error_spec.rb b/spec/strava/web/raise_response_error_spec.rb new file mode 100644 index 00000000..c06d4a9e --- /dev/null +++ b/spec/strava/web/raise_response_error_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Strava::Web::RaiseResponseError do + let(:middleware) { described_class.new } + let(:env) { Struct.new(:status, :response_headers, :body).new(407, {}, {}) } + + describe '#on_complete' do + it 'raises Faraday::ConnectionFailed for a 407 proxy authentication error' do + expect { middleware.on_complete(env) }.to raise_error( + Faraday::ConnectionFailed, '407 "Proxy Authentication Required "' + ) + end + end +end diff --git a/spec/strava/web/response_spec.rb b/spec/strava/web/response_spec.rb new file mode 100644 index 00000000..5b39ebf3 --- /dev/null +++ b/spec/strava/web/response_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +FakeHttpResponse = Struct.new(:body) + +describe Strava::Web::Response do + let(:http_response) { FakeHttpResponse.new(body) } + + describe '#method_missing' do + context 'when the response body is a Hash' do + let(:body) { { 'foo' => 'bar' } } + + it 'delegates to the response body' do + response = described_class.new(http_response) + expect(response['foo']).to eq 'bar' + end + end + + context 'when the response body is an Array' do + let(:body) { [{ 'foo' => 'bar' }] } + + it 'delegates to the response body' do + response = described_class.new(http_response) + expect(response.first['foo']).to eq 'bar' + end + end + + context 'when the response body is neither a Hash nor an Array' do + let(:body) { FakeHttpResponse.new('nested-string') } + + it 'raises NoMethodError' do + response = described_class.new(http_response) + expect { response.foo }.to raise_error NoMethodError + end + end + end +end diff --git a/spec/strava/webhooks/models/challenge_spec.rb b/spec/strava/webhooks/models/challenge_spec.rb new file mode 100644 index 00000000..a5bd8af0 --- /dev/null +++ b/spec/strava/webhooks/models/challenge_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Strava::Webhooks::Models::Challenge do + describe '#response' do + it 'returns a hash with the challenge value' do + challenge = described_class.new('hub.mode' => 'subscribe', 'hub.verify_token' => 'token', 'hub.challenge' => 'abc123') + expect(challenge.mode).to eq 'subscribe' + expect(challenge.verify_token).to eq 'token' + expect(challenge.response).to eq('hub.challenge' => 'abc123') + end + end +end