From 9a52c5a7476da0b3c7aebe8c797948556add2fa8 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 12:15:28 -0300 Subject: [PATCH 01/15] feat: add strava_accounts, activity source tracking, and run HR columns --- Gemfile | 1 + Gemfile.lock | 12 ++++++++++ .../20260711151338_create_strava_accounts.rb | 18 ++++++++++++++ ...20260711151340_add_source_to_activities.rb | 9 +++++++ db/migrate/20260711151341_add_hr_to_runs.rb | 6 +++++ db/schema.rb | 24 ++++++++++++++++++- test/test_helper.rb | 2 ++ 7 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260711151338_create_strava_accounts.rb create mode 100644 db/migrate/20260711151340_add_source_to_activities.rb create mode 100644 db/migrate/20260711151341_add_hr_to_runs.rb diff --git a/Gemfile b/Gemfile index dd9ee78..e8ce798 100644 --- a/Gemfile +++ b/Gemfile @@ -103,6 +103,7 @@ group :test do # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] gem "capybara" gem "selenium-webdriver" + gem "webmock" end gem "rails-i18n", "~> 8.1" diff --git a/Gemfile.lock b/Gemfile.lock index cc3c7d5..b248dc6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -137,6 +137,9 @@ GEM unaccent (~> 0.3) country_select (11.0.0) countries (> 6.0, < 9.0) + crack (1.0.1) + bigdecimal + rexml crass (1.0.7) csv (3.3.5) date (3.5.1) @@ -165,6 +168,7 @@ GEM csv nokogiri (~> 1.7) rake + hashdiff (1.2.1) highline (3.1.2) reline httparty (0.24.2) @@ -470,6 +474,10 @@ GEM actionview (>= 8.0.0) bindex (>= 0.4.0) railties (>= 8.0.0) + webmock (3.26.2) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket (1.2.11) websocket-driver (0.8.2) base64 @@ -531,6 +539,7 @@ DEPENDENCIES tzinfo-data view_component web-console + webmock CHECKSUMS action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 @@ -571,6 +580,7 @@ CHECKSUMS connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a countries (8.1.0) sha256=4d6b318b8e906f1f769d5c021c13a418d33e917dc96ceb625a91d8e7ab2d192e country_select (11.0.0) sha256=0ab0a385fa70eadc71473af4b21b9b4d09f75660cc1c282a5bf6ec873719204b + crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 @@ -590,6 +600,7 @@ CHECKSUMS fugit (1.12.2) sha256=643f2bf28db263bd400cbf8e0dd8b76b2c9b94bdb130e12d2394de04d9c20e5e globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 gpx (1.2.2) sha256=78a1766381c9a6286b83495ecf08bb7362d4990c4f88fd7500925123fde4e51a + hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 highline (3.1.2) sha256=67cbd34d19f6ef11a7ee1d82ffab5d36dfd5b3be861f450fc1716c7125f4bb4a httparty (0.24.2) sha256=8fca6a54aa0c4aa4303a0fd33e5e2156175d6a5334f714263b458abd7fda9c38 i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 @@ -705,6 +716,7 @@ CHECKSUMS useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 view_component (4.12.0) sha256=b9a5979d1c43eba2aa21a06a86f661aab3990104855f2909ef7c3779acdcdcb4 web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90 websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 diff --git a/db/migrate/20260711151338_create_strava_accounts.rb b/db/migrate/20260711151338_create_strava_accounts.rb new file mode 100644 index 0000000..02827e0 --- /dev/null +++ b/db/migrate/20260711151338_create_strava_accounts.rb @@ -0,0 +1,18 @@ +class CreateStravaAccounts < ActiveRecord::Migration[8.1] + def change + create_table :strava_accounts do |t| + t.references :user, null: false, foreign_key: true, index: { unique: true } + t.bigint :strava_athlete_id, null: false + t.string :access_token, null: false + t.string :refresh_token, null: false + t.datetime :token_expires_at, null: false + t.string :scope + t.datetime :last_synced_at + t.string :sync_status, null: false, default: "idle" + t.string :last_sync_error + + t.timestamps + end + add_index :strava_accounts, :strava_athlete_id, unique: true + end +end diff --git a/db/migrate/20260711151340_add_source_to_activities.rb b/db/migrate/20260711151340_add_source_to_activities.rb new file mode 100644 index 0000000..6ca951b --- /dev/null +++ b/db/migrate/20260711151340_add_source_to_activities.rb @@ -0,0 +1,9 @@ +class AddSourceToActivities < ActiveRecord::Migration[8.1] + def change + add_column :activities, :source, :string, null: false, default: "manual" + add_column :activities, :external_id, :string + add_index :activities, [ :profile_id, :source, :external_id ], + unique: true, where: "external_id IS NOT NULL", + name: "index_activities_on_profile_source_external" + end +end diff --git a/db/migrate/20260711151341_add_hr_to_runs.rb b/db/migrate/20260711151341_add_hr_to_runs.rb new file mode 100644 index 0000000..cdc9a9d --- /dev/null +++ b/db/migrate/20260711151341_add_hr_to_runs.rb @@ -0,0 +1,6 @@ +class AddHrToRuns < ActiveRecord::Migration[8.1] + def change + add_column :runs, :hr_avg, :decimal, precision: 5, scale: 1 + add_column :runs, :hr_peak, :decimal, precision: 5, scale: 1 + end +end diff --git a/db/schema.rb b/db/schema.rb index 3e5d64d..b66592d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_21_201058) do +ActiveRecord::Schema[8.1].define(version: 2026_07_11_151341) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -47,6 +47,7 @@ t.string "actable_type", null: false t.decimal "age_grade", precision: 8, scale: 2 t.datetime "created_at", null: false + t.string "external_id" t.decimal "gpx_distance", precision: 8, scale: 2 t.decimal "gpx_elevation", precision: 8, scale: 1 t.string "gpx_parse_error" @@ -55,10 +56,12 @@ t.bigint "profile_id", null: false t.boolean "public", default: true, null: false t.boolean "show_performance_stats", default: true, null: false + t.string "source", default: "manual", null: false t.datetime "started_at", null: false t.datetime "updated_at", null: false t.decimal "vo2_max", precision: 8, scale: 2 t.index ["actable_type", "actable_id"], name: "index_activities_on_actable" + t.index ["profile_id", "source", "external_id"], name: "index_activities_on_profile_source_external", unique: true, where: "(external_id IS NOT NULL)" t.index ["profile_id"], name: "index_activities_on_profile_id" end @@ -128,6 +131,8 @@ t.decimal "distance", null: false t.integer "duration", null: false t.decimal "elevation" + t.decimal "hr_avg", precision: 5, scale: 1 + t.decimal "hr_peak", precision: 5, scale: 1 t.decimal "temperature" t.string "temperature_unit" t.datetime "updated_at", null: false @@ -285,6 +290,22 @@ t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true end + create_table "strava_accounts", force: :cascade do |t| + t.string "access_token", null: false + t.datetime "created_at", null: false + t.string "last_sync_error" + t.datetime "last_synced_at" + t.string "refresh_token", null: false + t.string "scope" + t.bigint "strava_athlete_id", null: false + t.string "sync_status", default: "idle", null: false + t.datetime "token_expires_at", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["strava_athlete_id"], name: "index_strava_accounts_on_strava_athlete_id", unique: true + t.index ["user_id"], name: "index_strava_accounts_on_user_id", unique: true + end + create_table "trackpoints", force: :cascade do |t| t.bigint "activity_id", null: false t.datetime "created_at", null: false @@ -330,5 +351,6 @@ add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "strava_accounts", "users" add_foreign_key "trackpoints", "activities", on_delete: :cascade end diff --git a/test/test_helper.rb b/test/test_helper.rb index 68307c9..19ff220 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -2,6 +2,8 @@ require_relative "../config/environment" require "rails/test_help" require_relative "test_helpers/session_test_helper" +require "webmock/minitest" +WebMock.disable_net_connect!(allow_localhost: true) module ActiveSupport class TestCase From cb1ffb145264d1b9e74cbd113cb566cd7788107b Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 12:24:14 -0300 Subject: [PATCH 02/15] feat: add StravaAccount model with encrypted tokens --- app/models/strava_account.rb | 25 +++++++++++++ app/models/user.rb | 1 + test/models/strava_account_test.rb | 60 ++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 app/models/strava_account.rb create mode 100644 test/models/strava_account_test.rb diff --git a/app/models/strava_account.rb b/app/models/strava_account.rb new file mode 100644 index 0000000..a9f6322 --- /dev/null +++ b/app/models/strava_account.rb @@ -0,0 +1,25 @@ +class StravaAccount < ApplicationRecord + belongs_to :user + + encrypts :access_token + encrypts :refresh_token + + validates :strava_athlete_id, presence: true, uniqueness: true + validates :access_token, :refresh_token, :token_expires_at, presence: true + + def token_expired? + token_expires_at <= 60.seconds.from_now + end + + def syncing! + update!(sync_status: "syncing") + end + + def sync_finished! + update!(sync_status: "idle", last_synced_at: Time.current, last_sync_error: nil) + end + + def sync_failed!(message) + update!(sync_status: "error", last_sync_error: message.to_s.truncate(255)) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index a30945d..3318ecd 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,6 +2,7 @@ class User < ApplicationRecord has_secure_password has_many :sessions, dependent: :destroy has_one :profile, dependent: :destroy + has_one :strava_account, dependent: :destroy has_many :redeemed_invite_codes, class_name: "InviteCode", foreign_key: :used_by_id, dependent: :nullify normalizes :email_address, with: ->(e) { e.strip.downcase } diff --git a/test/models/strava_account_test.rb b/test/models/strava_account_test.rb new file mode 100644 index 0000000..2d956ed --- /dev/null +++ b/test/models/strava_account_test.rb @@ -0,0 +1,60 @@ +require "test_helper" + +class StravaAccountTest < ActiveSupport::TestCase + setup do + @account = StravaAccount.new( + user: users(:one), + strava_athlete_id: 12_345, + access_token: "at-secret", + refresh_token: "rt-secret", + token_expires_at: 2.hours.from_now + ) + end + + test "valid with required attributes" do + assert @account.valid? + end + + test "requires strava_athlete_id and tokens" do + account = StravaAccount.new(user: users(:one)) + assert_not account.valid? + assert account.errors[:strava_athlete_id].any? + assert account.errors[:access_token].any? + assert account.errors[:refresh_token].any? + end + + test "encrypts tokens at rest" do + @account.save! + raw = StravaAccount.connection.select_value( + "SELECT access_token FROM strava_accounts WHERE id = #{@account.id}" + ) + assert_not_equal "at-secret", raw + assert_equal "at-secret", @account.reload.access_token + end + + test "token_expired? respects 60s safety margin" do + @account.token_expires_at = 30.seconds.from_now + assert @account.token_expired? + + @account.token_expires_at = 10.minutes.from_now + assert_not @account.token_expired? + end + + test "syncing! and sync_finished! transition status" do + @account.save! + @account.syncing! + assert_equal "syncing", @account.reload.sync_status + + @account.sync_finished! + assert_equal "idle", @account.reload.sync_status + assert_not_nil @account.last_synced_at + assert_nil @account.last_sync_error + end + + test "sync_failed! records error message" do + @account.save! + @account.sync_failed!("rate limited") + assert_equal "error", @account.reload.sync_status + assert_equal "rate limited", @account.last_sync_error + end +end From 67aa2addf4d636d810a9c3c40bbeac1bd680ba46 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 12:25:09 -0300 Subject: [PATCH 03/15] feat: add Strava API client (oauth + activities) --- app/lib/strava/client.rb | 72 ++++++++++++++++++++++++++++++++++ test/lib/strava/client_test.rb | 66 +++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 app/lib/strava/client.rb create mode 100644 test/lib/strava/client_test.rb diff --git a/app/lib/strava/client.rb b/app/lib/strava/client.rb new file mode 100644 index 0000000..bd40479 --- /dev/null +++ b/app/lib/strava/client.rb @@ -0,0 +1,72 @@ +require "net/http" + +module Strava + class Client + class ApiError < StandardError + attr_reader :status + + def initialize(status, body) + @status = status + super("Strava API error #{status}: #{body.to_s.truncate(200)}") + end + end + + BASE_URL = "https://www.strava.com" + + def initialize(client_id: ENV["STRAVA_CLIENT_ID"], client_secret: ENV["STRAVA_CLIENT_SECRET"]) + @client_id = client_id || Rails.application.credentials.dig(:strava, :client_id) + @client_secret = client_secret || Rails.application.credentials.dig(:strava, :client_secret) + end + + def authorize_url(redirect_uri:) + params = { + client_id: @client_id, redirect_uri: redirect_uri, + response_type: "code", scope: "activity:read_all", approval_prompt: "auto" + } + "#{BASE_URL}/oauth/authorize?#{URI.encode_www_form(params)}" + end + + def exchange_code(code) + post("/oauth/token", client_id: @client_id, client_secret: @client_secret, + code: code, grant_type: "authorization_code") + end + + def refresh_token(refresh_token) + post("/oauth/token", client_id: @client_id, client_secret: @client_secret, + refresh_token: refresh_token, grant_type: "refresh_token") + end + + def activities(access_token, after: nil, page: 1, per_page: 100) + query = { page: page, per_page: per_page } + query[:after] = after if after + get("/api/v3/athlete/activities", access_token, query) + end + + def deauthorize(access_token) + post("/oauth/deauthorize", access_token: access_token) + end + + private + + def post(path, params) + uri = URI("#{BASE_URL}#{path}") + handle(Net::HTTP.post_form(uri, params.transform_keys(&:to_s))) + end + + def get(path, access_token, query) + uri = URI("#{BASE_URL}#{path}") + uri.query = URI.encode_www_form(query) + request = Net::HTTP::Get.new(uri) + request["Authorization"] = "Bearer #{access_token}" + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) } + handle(response) + end + + def handle(response) + raise ApiError.new(response.code.to_i, response.body) unless response.is_a?(Net::HTTPSuccess) + + JSON.parse(response.body) + end + end +end diff --git a/test/lib/strava/client_test.rb b/test/lib/strava/client_test.rb new file mode 100644 index 0000000..ae3ccf7 --- /dev/null +++ b/test/lib/strava/client_test.rb @@ -0,0 +1,66 @@ +require "test_helper" + +class Strava::ClientTest < ActiveSupport::TestCase + setup do + @client = Strava::Client.new(client_id: "123", client_secret: "shh") + end + + test "authorize_url includes client_id, scope and redirect_uri" do + url = @client.authorize_url(redirect_uri: "https://calcpace.app/strava/callback") + + assert_includes url, "https://www.strava.com/oauth/authorize?" + assert_includes url, "client_id=123" + assert_includes url, "scope=activity%3Aread_all" + assert_includes url, "redirect_uri=https%3A%2F%2Fcalcpace.app%2Fstrava%2Fcallback" + end + + test "exchange_code posts to token endpoint and returns parsed response" do + stub_request(:post, "https://www.strava.com/oauth/token") + .with(body: hash_including("code" => "abc", "grant_type" => "authorization_code")) + .to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: { + access_token: "at", refresh_token: "rt", expires_at: 1_900_000_000, + athlete: { id: 555 } + }.to_json) + + result = @client.exchange_code("abc") + assert_equal "at", result["access_token"] + assert_equal 555, result.dig("athlete", "id") + end + + test "refresh_token posts grant_type refresh_token" do + stub_request(:post, "https://www.strava.com/oauth/token") + .with(body: hash_including("grant_type" => "refresh_token", "refresh_token" => "rt-old")) + .to_return(status: 200, headers: { "Content-Type" => "application/json" }, + body: { access_token: "at-new", refresh_token: "rt-new", expires_at: 1_900_000_000 }.to_json) + + result = @client.refresh_token("rt-old") + assert_equal "at-new", result["access_token"] + end + + test "activities sends bearer token and pagination params" do + stub_request(:get, "https://www.strava.com/api/v3/athlete/activities") + .with(headers: { "Authorization" => "Bearer at" }, + query: { "after" => "1000", "page" => "2", "per_page" => "100" }) + .to_return(status: 200, headers: { "Content-Type" => "application/json" }, + body: [ { id: 1, sport_type: "Run" } ].to_json) + + result = @client.activities("at", after: 1000, page: 2) + assert_equal 1, result.first["id"] + end + + test "raises ApiError on non-2xx responses" do + stub_request(:post, "https://www.strava.com/oauth/token") + .to_return(status: 401, body: { message: "Authorization Error" }.to_json) + + error = assert_raises(Strava::Client::ApiError) { @client.exchange_code("bad") } + assert_equal 401, error.status + end + + test "deauthorize posts access token" do + stub_request(:post, "https://www.strava.com/oauth/deauthorize") + .with(body: hash_including("access_token" => "at")) + .to_return(status: 200, body: "{}") + + assert_nothing_raised { @client.deauthorize("at") } + end +end From 978a43b62e9b4903e78fc9c5c429bd2c4c80907c Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 12:28:42 -0300 Subject: [PATCH 04/15] feat: import Strava runs as activities with dedup and sport filter --- app/lib/strava/activity_importer.rb | 57 ++++++++++++++++++ app/models/activity.rb | 6 +- test/lib/strava/activity_importer_test.rb | 72 +++++++++++++++++++++++ 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 app/lib/strava/activity_importer.rb create mode 100644 test/lib/strava/activity_importer_test.rb diff --git a/app/lib/strava/activity_importer.rb b/app/lib/strava/activity_importer.rb new file mode 100644 index 0000000..89aae8d --- /dev/null +++ b/app/lib/strava/activity_importer.rb @@ -0,0 +1,57 @@ +module Strava + class ActivityImporter + RUN_SPORT_TYPES = %w[Run TrailRun].freeze + + def initialize(profile) + @profile = profile + end + + # @param payload [Hash] one activity from GET /athlete/activities + # @return [Activity, :duplicate, :skipped] + def import(payload) + return :skipped unless RUN_SPORT_TYPES.include?(payload["sport_type"]) + + external_id = payload["id"].to_s + return :duplicate if @profile.activities.exists?(source: "strava", external_id: external_id) + + Activity.log( + profile: @profile, + started_at: Time.iso8601(payload["start_date"]), + actable: build_run(payload), + name: payload["name"], + location: location_from(payload), + public: false, + source: "strava", + external_id: external_id + ) + end + + private + + def build_run(payload) + distance_km = payload["distance"].to_f / 1000.0 + duration = payload["moving_time"].to_i + + Run.new( + distance: distance_km, + duration: duration, + elevation: payload["total_elevation_gain"], + hr_avg: payload["average_heartrate"], + hr_peak: payload["max_heartrate"], + vo2max: estimate_vo2max(distance_km, duration) + ) + end + + def estimate_vo2max(distance_km, duration) + return nil if distance_km <= 0 || duration <= 0 + + Calcpace.new.estimate_vo2max(distance_km, duration) + rescue Calcpace::Error + nil + end + + def location_from(payload) + [ payload["location_city"], payload["location_country"] ].compact.join(", ").presence + end + end +end diff --git a/app/models/activity.rb b/app/models/activity.rb index 7262a54..a6ff77e 100644 --- a/app/models/activity.rb +++ b/app/models/activity.rb @@ -41,10 +41,12 @@ class Activity < ApplicationRecord BOOLEAN_TYPE = ActiveModel::Type::Boolean.new - def self.log(profile:, started_at:, actable:, location: nil, name: nil, public: nil) + def self.log(profile:, started_at:, actable:, location: nil, name: nil, public: nil, + source: "manual", external_id: nil) transaction do actable.save! - attrs = { started_at: started_at, actable: actable, location: location, name: name } + attrs = { started_at: started_at, actable: actable, location: location, name: name, + source: source, external_id: external_id } attrs[:public] = BOOLEAN_TYPE.cast(public) unless public.nil? profile.activities.create!(attrs) end diff --git a/test/lib/strava/activity_importer_test.rb b/test/lib/strava/activity_importer_test.rb new file mode 100644 index 0000000..b0c2393 --- /dev/null +++ b/test/lib/strava/activity_importer_test.rb @@ -0,0 +1,72 @@ +require "test_helper" + +class Strava::ActivityImporterTest < ActiveSupport::TestCase + setup do + @profile = profiles(:metric_user) + @payload = { + "id" => 987_654, + "sport_type" => "Run", + "name" => "Morning Run", + "distance" => 10_000.0, + "moving_time" => 2400, + "total_elevation_gain" => 85.0, + "start_date" => "2026-07-01T09:30:00Z", + "average_heartrate" => 158.4, + "max_heartrate" => 181.0, + "location_city" => "Recife", + "location_country" => "Brazil" + } + end + + test "imports a run with mapped fields" do + assert_difference -> { Activity.count } => 1, -> { Run.count } => 1 do + Strava::ActivityImporter.new(@profile).import(@payload) + end + + activity = Activity.find_by!(source: "strava", external_id: "987654") + assert_equal @profile, activity.profile + assert_equal "Morning Run", activity.name + assert_equal Time.utc(2026, 7, 1, 9, 30), activity.started_at + assert_not activity.public + + run = activity.actable + assert_in_delta 10.0, run.distance.to_f, 0.001 # metros → km + assert_equal 2400, run.duration + assert_in_delta 85.0, run.elevation.to_f, 0.1 + assert_in_delta 158.4, run.hr_avg.to_f, 0.1 + assert_in_delta 181.0, run.hr_peak.to_f, 0.1 + assert_not_nil run.vo2max + end + + test "skips duplicates by external_id" do + importer = Strava::ActivityImporter.new(@profile) + importer.import(@payload) + + assert_no_difference "Activity.count" do + result = importer.import(@payload) + assert_equal :duplicate, result + end + end + + test "skips non-run sports" do + assert_no_difference "Activity.count" do + result = Strava::ActivityImporter.new(@profile).import(@payload.merge("sport_type" => "Ride")) + assert_equal :skipped, result + end + end + + test "accepts trail runs" do + assert_difference "Activity.count" do + Strava::ActivityImporter.new(@profile).import(@payload.merge("sport_type" => "TrailRun")) + end + end + + test "tolerates missing optional fields" do + minimal = @payload.except("average_heartrate", "max_heartrate", + "total_elevation_gain", "location_city", "location_country") + + assert_difference "Activity.count" do + Strava::ActivityImporter.new(@profile).import(minimal) + end + end +end From e13b61401910fd9d1deb0181a98b9d97a1d93fbb Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 12:29:47 -0300 Subject: [PATCH 05/15] feat: add StravaSyncJob with pagination, token refresh, and incremental sync --- app/jobs/strava_sync_job.rb | 42 +++++++++++++++++ test/jobs/strava_sync_job_test.rb | 76 +++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 app/jobs/strava_sync_job.rb create mode 100644 test/jobs/strava_sync_job_test.rb diff --git a/app/jobs/strava_sync_job.rb b/app/jobs/strava_sync_job.rb new file mode 100644 index 0000000..4cc7711 --- /dev/null +++ b/app/jobs/strava_sync_job.rb @@ -0,0 +1,42 @@ +class StravaSyncJob < ApplicationJob + queue_as :default + + MAX_PAGES = 20 # 20 × 100 = 2000 atividades por sync; dentro do rate limit + + def perform(strava_account_id) + account = StravaAccount.find_by(id: strava_account_id) + return unless account + + account.syncing! + client = Strava::Client.new + refresh!(account, client) if account.token_expired? + + import_all(account, client) + account.sync_finished! + rescue Strava::Client::ApiError => e + account&.sync_failed!(e.message) + raise + end + + private + + def refresh!(account, client) + data = client.refresh_token(account.refresh_token) + account.update!( + access_token: data["access_token"], + refresh_token: data["refresh_token"], + token_expires_at: Time.zone.at(data["expires_at"]) + ) + end + + def import_all(account, client) + importer = Strava::ActivityImporter.new(account.user.profile) + after = account.last_synced_at&.to_i + + (1..MAX_PAGES).each do |page| + batch = client.activities(account.access_token, after: after, page: page) + batch.each { |payload| importer.import(payload) } + break if batch.size < 100 + end + end +end diff --git a/test/jobs/strava_sync_job_test.rb b/test/jobs/strava_sync_job_test.rb new file mode 100644 index 0000000..c3ef861 --- /dev/null +++ b/test/jobs/strava_sync_job_test.rb @@ -0,0 +1,76 @@ +require "test_helper" + +class StravaSyncJobTest < ActiveJob::TestCase + setup do + @user = users(:one) + @account = StravaAccount.create!( + user: @user, strava_athlete_id: 111, + access_token: "at", refresh_token: "rt", + token_expires_at: 2.hours.from_now + ) + end + + test "imports paginated activities and marks sync finished" do + page1 = Array.new(100) { |i| run_payload(id: i + 1) } + stub_activities_page(1, page1) + stub_activities_page(2, [ run_payload(id: 999) ]) + + StravaSyncJob.perform_now(@account.id) + + assert_equal 101, @user.profile.activities.where(source: "strava").count + assert_equal "idle", @account.reload.sync_status + assert_not_nil @account.last_synced_at + end + + test "refreshes expired token before syncing" do + @account.update!(token_expires_at: 1.minute.ago) + + stub_request(:post, "https://www.strava.com/oauth/token") + .with(body: hash_including("grant_type" => "refresh_token")) + .to_return(status: 200, headers: { "Content-Type" => "application/json" }, + body: { access_token: "at-new", refresh_token: "rt-new", + expires_at: 2.hours.from_now.to_i }.to_json) + stub_activities_page(1, [], token: "at-new") + + StravaSyncJob.perform_now(@account.id) + + assert_equal "at-new", @account.reload.access_token + assert_equal "rt-new", @account.refresh_token + end + + test "records failure and re-raises on API error" do + stub_request(:get, "https://www.strava.com/api/v3/athlete/activities") + .with(query: hash_including({})) + .to_return(status: 429, body: { message: "Rate Limit Exceeded" }.to_json) + + assert_raises(Strava::Client::ApiError) { StravaSyncJob.perform_now(@account.id) } + assert_equal "error", @account.reload.sync_status + assert_match(/429/, @account.last_sync_error) + end + + test "uses last_synced_at as incremental after param" do + @account.update!(last_synced_at: Time.utc(2026, 6, 1)) + + stub = stub_request(:get, "https://www.strava.com/api/v3/athlete/activities") + .with(query: hash_including("after" => Time.utc(2026, 6, 1).to_i.to_s)) + .to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "[]") + + StravaSyncJob.perform_now(@account.id) + assert_requested stub + end + + private + + def run_payload(id:) + { "id" => id, "sport_type" => "Run", "name" => "Run #{id}", + "distance" => 5000.0, "moving_time" => 1500, + "start_date" => "2026-07-01T09:00:00Z" } + end + + def stub_activities_page(page, body, token: "at") + stub_request(:get, "https://www.strava.com/api/v3/athlete/activities") + .with(headers: { "Authorization" => "Bearer #{token}" }, + query: hash_including("page" => page.to_s, "per_page" => "100")) + .to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: body.to_json) + end +end From 0ca768462375ef0c0dea0e5795ef1e4ef0879913 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 12:31:12 -0300 Subject: [PATCH 06/15] feat: add StravaAccountsController for OAuth flow and disconnection --- app/controllers/strava_accounts_controller.rb | 43 ++++++++++++++ config/locales/strava.en.yml | 9 +++ config/routes.rb | 3 + .../strava_accounts_controller_test.rb | 59 +++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 app/controllers/strava_accounts_controller.rb create mode 100644 config/locales/strava.en.yml create mode 100644 test/controllers/strava_accounts_controller_test.rb diff --git a/app/controllers/strava_accounts_controller.rb b/app/controllers/strava_accounts_controller.rb new file mode 100644 index 0000000..b738a81 --- /dev/null +++ b/app/controllers/strava_accounts_controller.rb @@ -0,0 +1,43 @@ +class StravaAccountsController < ApplicationController + before_action :require_authentication + + def create + client = Strava::Client.new + redirect_to client.authorize_url(redirect_uri: callback_strava_account_url), allow_other_host: true + end + + def callback + if params[:error] == "access_denied" + redirect_to account_path, alert: t(".access_denied") + return + end + + client = Strava::Client.new + data = client.exchange_code(params[:code]) + + account = Current.user.build_strava_account( + strava_athlete_id: data.dig("athlete", "id"), + access_token: data["access_token"], + refresh_token: data["refresh_token"], + token_expires_at: Time.zone.at(data["expires_at"]) + ) + + if account.save + StravaSyncJob.perform_later(account.id) + redirect_to account_path, notice: t(".success") + else + redirect_to account_path, alert: t(".already_connected") + end + rescue Strava::Client::ApiError + redirect_to account_path, alert: t(".error") + end + + def destroy + account = Current.user.strava_account + if account + Strava::Client.new.deauthorize(account.access_token) rescue Strava::Client::ApiError + account.destroy + end + redirect_to account_path, notice: t(".disconnected") + end +end diff --git a/config/locales/strava.en.yml b/config/locales/strava.en.yml new file mode 100644 index 0000000..f4cb8e0 --- /dev/null +++ b/config/locales/strava.en.yml @@ -0,0 +1,9 @@ +en: + strava_accounts: + callback: + success: "Strava connected! Syncing runs..." + already_connected: "This Strava account is already connected to another user." + error: "Could not connect to Strava." + access_denied: "You denied access to Strava." + destroy: + disconnected: "Strava disconnected." diff --git a/config/routes.rb b/config/routes.rb index 521f0ea..11b89f9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -35,6 +35,9 @@ patch :update_email, on: :member patch :update_password, on: :member end + resource :strava_account, only: [ :create, :destroy ] do + get :callback, on: :collection + end resources :activities, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do collection do diff --git a/test/controllers/strava_accounts_controller_test.rb b/test/controllers/strava_accounts_controller_test.rb new file mode 100644 index 0000000..41046fb --- /dev/null +++ b/test/controllers/strava_accounts_controller_test.rb @@ -0,0 +1,59 @@ +require "test_helper" + +class StravaAccountsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:one) + sign_in_as(@user) + end + + test "POST create redirects to Strava OAuth url" do + post strava_account_url + assert_response :redirect + assert_match %r{^https://www.strava.com/oauth/authorize}, response.redirect_url + end + + test "GET callback exchanges code, saves account and enqueues job" do + assert_difference "StravaAccount.count", 1 do + assert_enqueued_with(job: StravaSyncJob) do + stub_request(:post, "https://www.strava.com/oauth/token") + .to_return(status: 200, body: { + access_token: "at", refresh_token: "rt", expires_at: 1_900_000_000, + athlete: { id: 999 } + }.to_json) + + get callback_strava_account_url(code: "auth-code") + end + end + + assert_redirected_to account_url + assert_equal "Strava connected! Syncing runs...", flash[:notice] || flash[:alert] # Will fail slightly if missing i18n but we assert flash exists. Actually let's not assert exact text for i18n, just redirected + + account = StravaAccount.last + assert_equal @user, account.user + assert_equal 999, account.strava_athlete_id + end + + test "GET callback redirects with alert on API error" do + stub_request(:post, "https://www.strava.com/oauth/token") + .to_return(status: 400, body: "Bad Request") + + get callback_strava_account_url(code: "bad-code") + assert_redirected_to account_url + end + + test "DELETE destroy revokes token and destroys account" do + account = StravaAccount.create!( + user: @user, strava_athlete_id: 111, + access_token: "at", refresh_token: "rt", + token_expires_at: 2.hours.from_now + ) + + stub_request(:post, "https://www.strava.com/oauth/deauthorize").to_return(status: 200, body: "{}") + + assert_difference "StravaAccount.count", -1 do + delete strava_account_url + end + + assert_redirected_to account_url + end +end From 299e9861cfee268f0ec8c93d190342fdd84c6147 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 12:31:47 -0300 Subject: [PATCH 07/15] feat: add Strava connect UI and translations --- app/views/accounts/show.html.erb | 28 ++++++++++++++++++++++++++++ config/locales/strava.en.yml | 11 +++++++++++ 2 files changed, 39 insertions(+) diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb index 515be33..9d72b32 100644 --- a/app/views/accounts/show.html.erb +++ b/app/views/accounts/show.html.erb @@ -43,6 +43,34 @@ <% end %> + <%# Strava Integration %> +
+

<%= t("strava_accounts.ui.title") %>

+ <% if account = Current.user.strava_account %> +
+
+

<%= t("strava_accounts.ui.connected") %>

+

+ <% if account.sync_status == "syncing" %> + <%= t("strava_accounts.ui.syncing") %> + <% elsif account.sync_status == "error" %> + <%= t("strava_accounts.ui.sync_error") %>: <%= account.last_sync_error %> + <% else %> + <%= t("strava_accounts.ui.last_synced") %>: <%= account.last_synced_at ? l(account.last_synced_at, format: :short) : t("strava_accounts.ui.never") %> + <% end %> +

+
+ <%= button_to t("strava_accounts.ui.disconnect"), strava_account_path, method: :delete, + class: "text-sm text-red-600 hover:text-red-800 font-medium", + data: { turbo_confirm: t("strava_accounts.ui.disconnect_confirm") } %> +
+ <% else %> +

<%= t("strava_accounts.ui.description") %>

+ <%= button_to t("strava_accounts.ui.connect"), strava_account_path, method: :post, data: { turbo: false }, + class: "inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white rounded-md bg-[#FC4C02] hover:bg-[#E34402] transition-colors focus:outline-none focus:ring-2 focus:ring-[#FC4C02] focus:ring-offset-2" %> + <% end %> +
+ <%# Active sessions %>

<%= t("accounts.show.sessions_section") %>

diff --git a/config/locales/strava.en.yml b/config/locales/strava.en.yml index f4cb8e0..0867107 100644 --- a/config/locales/strava.en.yml +++ b/config/locales/strava.en.yml @@ -7,3 +7,14 @@ en: access_denied: "You denied access to Strava." destroy: disconnected: "Strava disconnected." + ui: + title: "Strava Integration" + connected: "Connected to Strava" + syncing: "Syncing runs now..." + sync_error: "Sync error" + last_synced: "Last synced" + never: "Never" + disconnect: "Disconnect Strava" + disconnect_confirm: "Are you sure? We will stop syncing your runs." + description: "Connect your Strava account to automatically sync your runs and analyze your VO2 max." + connect: "Connect with Strava" From 5648d4a58c66ce547f5a4c9dc5a6cda63427a090 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 13:10:22 -0300 Subject: [PATCH 08/15] feat: add Strava webhooks to auto-sync new activities --- app/controllers/strava_webhooks_controller.rb | 26 +++++++ config/routes.rb | 3 + .../strava_webhooks_controller_test.rb | 77 +++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 app/controllers/strava_webhooks_controller.rb create mode 100644 test/controllers/strava_webhooks_controller_test.rb diff --git a/app/controllers/strava_webhooks_controller.rb b/app/controllers/strava_webhooks_controller.rb new file mode 100644 index 0000000..613c7f1 --- /dev/null +++ b/app/controllers/strava_webhooks_controller.rb @@ -0,0 +1,26 @@ +class StravaWebhooksController < ActionController::API + def verify + verify_token = ENV["STRAVA_VERIFY_TOKEN"] || Rails.application.credentials.dig(:strava, :verify_token) + + if params["hub.mode"] == "subscribe" && params["hub.verify_token"] == verify_token + render json: { "hub.challenge" => params["hub.challenge"] }, status: :ok + else + head :forbidden + end + end + + def receive + # Strava exige que os webhooks respondam com 200 OK dentro de 2 segundos, + # por isso apenas enfileiramos o trabalho e respondemos imediatamente. + + if params[:object_type] == "activity" && params[:aspect_type] == "create" + account = StravaAccount.find_by(strava_athlete_id: params[:owner_id]) + + if account + StravaSyncJob.perform_later(account.id) + end + end + + head :ok + end +end diff --git a/config/routes.rb b/config/routes.rb index 11b89f9..2aba2ba 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,6 +21,9 @@ get "up" => "rails/health#show", as: :rails_health_check + get "webhooks/strava", to: "strava_webhooks#verify" + post "webhooks/strava", to: "strava_webhooks#receive" + scope "(:locale)", locale: /en|pt-BR|es|de|fr|ja|it|nl|ko|sv|pl|no|zh-TW|hu|cs/ do patch "locales/:id", to: "locales#update", as: :update_locale diff --git a/test/controllers/strava_webhooks_controller_test.rb b/test/controllers/strava_webhooks_controller_test.rb new file mode 100644 index 0000000..c8821e4 --- /dev/null +++ b/test/controllers/strava_webhooks_controller_test.rb @@ -0,0 +1,77 @@ +require "test_helper" + +class StravaWebhooksControllerTest < ActionDispatch::IntegrationTest + setup do + @account = StravaAccount.create!( + user: users(:one), + strava_athlete_id: 111, + access_token: "at", + refresh_token: "rt", + token_expires_at: 2.hours.from_now + ) + end + + test "GET verify responds with challenge when token matches" do + ENV["STRAVA_VERIFY_TOKEN"] = "my-secret-token" + + get webhooks_strava_url, params: { + "hub.mode" => "subscribe", + "hub.verify_token" => "my-secret-token", + "hub.challenge" => "15f7d1a91c1f40f8a748fd134752feb3" + } + + assert_response :success + assert_equal "15f7d1a91c1f40f8a748fd134752feb3", JSON.parse(response.body)["hub.challenge"] + end + + test "GET verify responds with forbidden when token mismatches" do + ENV["STRAVA_VERIFY_TOKEN"] = "my-secret-token" + + get webhooks_strava_url, params: { + "hub.mode" => "subscribe", + "hub.verify_token" => "wrong-token", + "hub.challenge" => "15f7d1a91c1f40f8a748fd134752feb3" + } + + assert_response :forbidden + end + + test "POST receive enqueues sync job for activity creation" do + assert_enqueued_with(job: StravaSyncJob, args: [ @account.id ]) do + post webhooks_strava_url, params: { + "object_type" => "activity", + "aspect_type" => "create", + "owner_id" => 111, + "object_id" => 123456 + }, as: :json + end + + assert_response :success + end + + test "POST receive ignores other aspect types but returns 200" do + assert_no_enqueued_jobs do + post webhooks_strava_url, params: { + "object_type" => "activity", + "aspect_type" => "update", + "owner_id" => 111, + "object_id" => 123456 + }, as: :json + end + + assert_response :success + end + + test "POST receive ignores unknown athletes but returns 200" do + assert_no_enqueued_jobs do + post webhooks_strava_url, params: { + "object_type" => "activity", + "aspect_type" => "create", + "owner_id" => 999, + "object_id" => 123456 + }, as: :json + end + + assert_response :success + end +end From 344635ed7f9b9412aacd4c982db6aebcf54a844c Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 16:23:33 -0300 Subject: [PATCH 09/15] feat: include Walk activities in Strava sync --- app/lib/strava/activity_importer.rb | 2 +- test/lib/strava/activity_importer_test.rb | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/lib/strava/activity_importer.rb b/app/lib/strava/activity_importer.rb index 89aae8d..9f03045 100644 --- a/app/lib/strava/activity_importer.rb +++ b/app/lib/strava/activity_importer.rb @@ -1,6 +1,6 @@ module Strava class ActivityImporter - RUN_SPORT_TYPES = %w[Run TrailRun].freeze + RUN_SPORT_TYPES = %w[Run TrailRun Walk].freeze def initialize(profile) @profile = profile diff --git a/test/lib/strava/activity_importer_test.rb b/test/lib/strava/activity_importer_test.rb index b0c2393..816df8b 100644 --- a/test/lib/strava/activity_importer_test.rb +++ b/test/lib/strava/activity_importer_test.rb @@ -61,6 +61,12 @@ class Strava::ActivityImporterTest < ActiveSupport::TestCase end end + test "accepts walks" do + assert_difference "Activity.count" do + Strava::ActivityImporter.new(@profile).import(@payload.merge("sport_type" => "Walk")) + end + end + test "tolerates missing optional fields" do minimal = @payload.except("average_heartrate", "max_heartrate", "total_elevation_gain", "location_city", "location_country") From 438c8d2857317f6495d0253221811ec4f30a4ec1 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 16:24:29 -0300 Subject: [PATCH 10/15] feat: add verbose logging to Strava sync process --- app/controllers/strava_webhooks_controller.rb | 1 + app/jobs/strava_sync_job.rb | 4 ++++ app/lib/strava/activity_importer.rb | 17 ++++++++++++++--- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/controllers/strava_webhooks_controller.rb b/app/controllers/strava_webhooks_controller.rb index 613c7f1..47527e5 100644 --- a/app/controllers/strava_webhooks_controller.rb +++ b/app/controllers/strava_webhooks_controller.rb @@ -12,6 +12,7 @@ def verify def receive # Strava exige que os webhooks respondam com 200 OK dentro de 2 segundos, # por isso apenas enfileiramos o trabalho e respondemos imediatamente. + Rails.logger.info("[Strava Webhook] Received: aspect=#{params[:aspect_type]}, object=#{params[:object_type]}, owner=#{params[:owner_id]}") if params[:object_type] == "activity" && params[:aspect_type] == "create" account = StravaAccount.find_by(strava_athlete_id: params[:owner_id]) diff --git a/app/jobs/strava_sync_job.rb b/app/jobs/strava_sync_job.rb index 4cc7711..87207d9 100644 --- a/app/jobs/strava_sync_job.rb +++ b/app/jobs/strava_sync_job.rb @@ -32,11 +32,15 @@ def refresh!(account, client) def import_all(account, client) importer = Strava::ActivityImporter.new(account.user.profile) after = account.last_synced_at&.to_i + Rails.logger.info("[StravaSyncJob] Starting sync for account #{account.id} (after: #{after})") (1..MAX_PAGES).each do |page| batch = client.activities(account.access_token, after: after, page: page) + Rails.logger.info("[StravaSyncJob] Fetched #{batch.size} activities from API (page #{page})") + batch.each { |payload| importer.import(payload) } break if batch.size < 100 end + Rails.logger.info("[StravaSyncJob] Sync finished for account #{account.id}") end end diff --git a/app/lib/strava/activity_importer.rb b/app/lib/strava/activity_importer.rb index 9f03045..4f5475e 100644 --- a/app/lib/strava/activity_importer.rb +++ b/app/lib/strava/activity_importer.rb @@ -9,10 +9,18 @@ def initialize(profile) # @param payload [Hash] one activity from GET /athlete/activities # @return [Activity, :duplicate, :skipped] def import(payload) - return :skipped unless RUN_SPORT_TYPES.include?(payload["sport_type"]) - external_id = payload["id"].to_s - return :duplicate if @profile.activities.exists?(source: "strava", external_id: external_id) + sport = payload["sport_type"] + + unless RUN_SPORT_TYPES.include?(sport) + Rails.logger.info("[StravaImporter] Skipped #{external_id} (unsupported sport: #{sport})") + return :skipped + end + + if @profile.activities.exists?(source: "strava", external_id: external_id) + Rails.logger.info("[StravaImporter] Skipped #{external_id} (duplicate)") + return :duplicate + end Activity.log( profile: @profile, @@ -24,6 +32,9 @@ def import(payload) source: "strava", external_id: external_id ) + + Rails.logger.info("[StravaImporter] Successfully imported #{sport} #{external_id}") + :imported end private From 554e2fbbe11203250349654af42403acf8e36c41 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 16:47:12 -0300 Subject: [PATCH 11/15] feat: decode and display Strava map polylines without storing GPS trackpoints --- app/lib/strava/activity_importer.rb | 3 +- app/models/activity.rb | 4 +-- app/views/activities/_map.html.erb | 30 ++++++++++++++++--- ...11194611_add_map_polyline_to_activities.rb | 5 ++++ db/schema.rb | 3 +- 5 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 db/migrate/20260711194611_add_map_polyline_to_activities.rb diff --git a/app/lib/strava/activity_importer.rb b/app/lib/strava/activity_importer.rb index 4f5475e..37e6276 100644 --- a/app/lib/strava/activity_importer.rb +++ b/app/lib/strava/activity_importer.rb @@ -30,7 +30,8 @@ def import(payload) location: location_from(payload), public: false, source: "strava", - external_id: external_id + external_id: external_id, + map_polyline: payload.dig("map", "summary_polyline") ) Rails.logger.info("[StravaImporter] Successfully imported #{sport} #{external_id}") diff --git a/app/models/activity.rb b/app/models/activity.rb index a6ff77e..5384035 100644 --- a/app/models/activity.rb +++ b/app/models/activity.rb @@ -42,11 +42,11 @@ class Activity < ApplicationRecord BOOLEAN_TYPE = ActiveModel::Type::Boolean.new def self.log(profile:, started_at:, actable:, location: nil, name: nil, public: nil, - source: "manual", external_id: nil) + source: "manual", external_id: nil, map_polyline: nil) transaction do actable.save! attrs = { started_at: started_at, actable: actable, location: location, name: name, - source: source, external_id: external_id } + source: source, external_id: external_id, map_polyline: map_polyline } attrs[:public] = BOOLEAN_TYPE.cast(public) unless public.nil? profile.activities.create!(attrs) end diff --git a/app/views/activities/_map.html.erb b/app/views/activities/_map.html.erb index 3588665..fc32783 100644 --- a/app/views/activities/_map.html.erb +++ b/app/views/activities/_map.html.erb @@ -2,7 +2,7 @@
Parsing GPS data… refresh the page in a few seconds to see the map.
-<% elsif activity.trackpoints.any? %> +<% elsif activity.trackpoints.any? || activity.map_polyline.present? %> <% content_for :head do %> @@ -17,9 +17,31 @@ attribution: '© OpenStreetMap contributors', maxZoom: 19 }).addTo(map); - var coords = <%= raw json_escape(activity.trackpoints.ordered.pluck(:lat, :lon).map { |lat, lon| [lat.to_f, lon.to_f] }.to_json) %>; - var polyline = L.polyline(coords, { color: '#059669', weight: 3 }).addTo(map); - map.fitBounds(polyline.getBounds(), { padding: [16, 16] }); + + var coords; + <% if activity.trackpoints.any? %> + coords = <%= raw json_escape(activity.trackpoints.ordered.pluck(:lat, :lon).map { |lat, lon| [lat.to_f, lon.to_f] }.to_json) %>; + <% else %> + var decodePolyline = function(str) { + var idx = 0, lat = 0, lng = 0, coords = [], shift, result, b; + while (idx < str.length) { + b = null; shift = 0; result = 0; + do { b = str.charCodeAt(idx++) - 63; result |= (b & 0x1f) << shift; shift += 5; } while (b >= 0x20); + lat += ((result & 1) ? ~(result >> 1) : (result >> 1)); + shift = 0; result = 0; + do { b = str.charCodeAt(idx++) - 63; result |= (b & 0x1f) << shift; shift += 5; } while (b >= 0x20); + lng += ((result & 1) ? ~(result >> 1) : (result >> 1)); + coords.push([lat / 1e5, lng / 1e5]); + } + return coords; + }; + coords = decodePolyline("<%= j activity.map_polyline %>"); + <% end %> + + if (coords.length > 0) { + var route = L.polyline(coords, { color: '#059669', weight: 3 }).addTo(map); + map.fitBounds(route.getBounds(), { padding: [16, 16] }); + } })(); <% end %> diff --git a/db/migrate/20260711194611_add_map_polyline_to_activities.rb b/db/migrate/20260711194611_add_map_polyline_to_activities.rb new file mode 100644 index 0000000..6dfb7a6 --- /dev/null +++ b/db/migrate/20260711194611_add_map_polyline_to_activities.rb @@ -0,0 +1,5 @@ +class AddMapPolylineToActivities < ActiveRecord::Migration[8.1] + def change + add_column :activities, :map_polyline, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index b66592d..4c71367 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_11_151341) do +ActiveRecord::Schema[8.1].define(version: 2026_07_11_194611) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -52,6 +52,7 @@ t.decimal "gpx_elevation", precision: 8, scale: 1 t.string "gpx_parse_error" t.string "location" + t.text "map_polyline" t.string "name" t.bigint "profile_id", null: false t.boolean "public", default: true, null: false From 1752f0b6af77197f4d45c98d55af42149d5cc8a8 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 21:01:56 -0300 Subject: [PATCH 12/15] fix: provide dummy encryption keys for CI and uncomment master key --- .github/workflows/ci.yml | 4 ++-- config/environments/test.rb | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0a47b6..c4189a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,7 +100,7 @@ jobs: env: RAILS_ENV: test DATABASE_URL: postgres://postgres:postgres@localhost:5432 - # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} # REDIS_URL: redis://localhost:6379/0 run: bin/rails db:test:prepare test @@ -139,7 +139,7 @@ jobs: env: RAILS_ENV: test DATABASE_URL: postgres://postgres:postgres@localhost:5432 - # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} # REDIS_URL: redis://localhost:6379/0 run: bin/rails db:test:prepare test:system diff --git a/config/environments/test.rb b/config/environments/test.rb index c2095b1..de6c56e 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -50,4 +50,8 @@ # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true + # Use dummy keys for ActiveRecord::Encryption in test environment + config.active_record.encryption.primary_key = "test" * 8 + config.active_record.encryption.deterministic_key = "test" * 8 + config.active_record.encryption.key_derivation_salt = "test" * 8 end From 519f6242f08fbf16c9ab1451393c6fe936cfd4e3 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 11 Jul 2026 21:17:07 -0300 Subject: [PATCH 13/15] fix: refactor to 37signals pattern, address PR feedback, and include encrypted credentials --- app/controllers/strava_accounts_controller.rb | 26 +++++++------- app/controllers/strava_webhooks_controller.rb | 3 +- app/jobs/strava_sync_job.rb | 27 ++++++++------- app/lib/strava/activity_importer.rb | 31 ++++++++++------- app/lib/strava/client.rb | 20 ++++++++--- app/models/strava_account.rb | 34 +++++++++++++++++-- config/credentials.yml.enc | 2 +- .../strava_accounts_controller_test.rb | 19 +++++++++-- .../strava_webhooks_controller_test.rb | 16 +++++++++ test/lib/strava/client_test.rb | 5 +-- 10 files changed, 130 insertions(+), 53 deletions(-) diff --git a/app/controllers/strava_accounts_controller.rb b/app/controllers/strava_accounts_controller.rb index b738a81..108cedc 100644 --- a/app/controllers/strava_accounts_controller.rb +++ b/app/controllers/strava_accounts_controller.rb @@ -2,33 +2,31 @@ class StravaAccountsController < ApplicationController before_action :require_authentication def create + state = SecureRandom.hex(16) + session[:strava_oauth_state] = state client = Strava::Client.new - redirect_to client.authorize_url(redirect_uri: callback_strava_account_url), allow_other_host: true + redirect_to client.authorize_url(redirect_uri: callback_strava_account_url, state: state), allow_other_host: true end def callback + if params[:state].blank? || params[:state] != session[:strava_oauth_state] + redirect_to account_path, alert: "Invalid OAuth state. Please try again." + return + end + session.delete(:strava_oauth_state) + if params[:error] == "access_denied" redirect_to account_path, alert: t(".access_denied") return end - client = Strava::Client.new - data = client.exchange_code(params[:code]) - - account = Current.user.build_strava_account( - strava_athlete_id: data.dig("athlete", "id"), - access_token: data["access_token"], - refresh_token: data["refresh_token"], - token_expires_at: Time.zone.at(data["expires_at"]) - ) - - if account.save - StravaSyncJob.perform_later(account.id) + if StravaAccount.connect!(user: Current.user, oauth_code: params[:code]) redirect_to account_path, notice: t(".success") else redirect_to account_path, alert: t(".already_connected") end - rescue Strava::Client::ApiError + rescue Strava::Client::ApiError => e + Rails.logger.error("[Strava] Failed to connect: #{e.message}") redirect_to account_path, alert: t(".error") end diff --git a/app/controllers/strava_webhooks_controller.rb b/app/controllers/strava_webhooks_controller.rb index 47527e5..f613937 100644 --- a/app/controllers/strava_webhooks_controller.rb +++ b/app/controllers/strava_webhooks_controller.rb @@ -1,6 +1,7 @@ class StravaWebhooksController < ActionController::API def verify - verify_token = ENV["STRAVA_VERIFY_TOKEN"] || Rails.application.credentials.dig(:strava, :verify_token) + verify_token = ENV["STRAVA_VERIFY_TOKEN"].presence || Rails.application.credentials.dig(:strava, :verify_token) + return head :forbidden if verify_token.blank? if params["hub.mode"] == "subscribe" && params["hub.verify_token"] == verify_token render json: { "hub.challenge" => params["hub.challenge"] }, status: :ok diff --git a/app/jobs/strava_sync_job.rb b/app/jobs/strava_sync_job.rb index 87207d9..f2f7f06 100644 --- a/app/jobs/strava_sync_job.rb +++ b/app/jobs/strava_sync_job.rb @@ -9,10 +9,10 @@ def perform(strava_account_id) account.syncing! client = Strava::Client.new - refresh!(account, client) if account.token_expired? + account.refresh_token! - import_all(account, client) - account.sync_finished! + last_date = import_all(account, client) + account.sync_finished!(last_date) rescue Strava::Client::ApiError => e account&.sync_failed!(e.message) raise @@ -20,18 +20,10 @@ def perform(strava_account_id) private - def refresh!(account, client) - data = client.refresh_token(account.refresh_token) - account.update!( - access_token: data["access_token"], - refresh_token: data["refresh_token"], - token_expires_at: Time.zone.at(data["expires_at"]) - ) - end - def import_all(account, client) importer = Strava::ActivityImporter.new(account.user.profile) after = account.last_synced_at&.to_i + last_processed_date = nil Rails.logger.info("[StravaSyncJob] Starting sync for account #{account.id} (after: #{after})") (1..MAX_PAGES).each do |page| @@ -39,8 +31,17 @@ def import_all(account, client) Rails.logger.info("[StravaSyncJob] Fetched #{batch.size} activities from API (page #{page})") batch.each { |payload| importer.import(payload) } - break if batch.size < 100 + + if batch.any? + last_processed_date = Time.iso8601(batch.last["start_date"]) + end + + if batch.size < 100 + last_processed_date = Time.current + break + end end Rails.logger.info("[StravaSyncJob] Sync finished for account #{account.id}") + last_processed_date end end diff --git a/app/lib/strava/activity_importer.rb b/app/lib/strava/activity_importer.rb index 37e6276..814e4df 100644 --- a/app/lib/strava/activity_importer.rb +++ b/app/lib/strava/activity_importer.rb @@ -22,20 +22,25 @@ def import(payload) return :duplicate end - Activity.log( - profile: @profile, - started_at: Time.iso8601(payload["start_date"]), - actable: build_run(payload), - name: payload["name"], - location: location_from(payload), - public: false, - source: "strava", - external_id: external_id, - map_polyline: payload.dig("map", "summary_polyline") - ) + begin + Activity.log( + profile: @profile, + started_at: Time.iso8601(payload["start_date"]), + actable: build_run(payload), + name: payload["name"], + location: location_from(payload), + public: false, + source: "strava", + external_id: external_id, + map_polyline: payload.dig("map", "summary_polyline") + ) - Rails.logger.info("[StravaImporter] Successfully imported #{sport} #{external_id}") - :imported + Rails.logger.info("[StravaImporter] Successfully imported #{sport} #{external_id}") + :imported + rescue ActiveRecord::RecordNotUnique + Rails.logger.info("[StravaImporter] Skipped #{external_id} (concurrent duplicate)") + :duplicate + end end private diff --git a/app/lib/strava/client.rb b/app/lib/strava/client.rb index bd40479..f5760a1 100644 --- a/app/lib/strava/client.rb +++ b/app/lib/strava/client.rb @@ -18,10 +18,10 @@ def initialize(client_id: ENV["STRAVA_CLIENT_ID"], client_secret: ENV["STRAVA_CL @client_secret = client_secret || Rails.application.credentials.dig(:strava, :client_secret) end - def authorize_url(redirect_uri:) + def authorize_url(redirect_uri:, state:) params = { client_id: @client_id, redirect_uri: redirect_uri, - response_type: "code", scope: "activity:read_all", approval_prompt: "auto" + response_type: "code", scope: "activity:read_all", approval_prompt: "auto", state: state } "#{BASE_URL}/oauth/authorize?#{URI.encode_www_form(params)}" end @@ -50,7 +50,15 @@ def deauthorize(access_token) def post(path, params) uri = URI("#{BASE_URL}#{path}") - handle(Net::HTTP.post_form(uri, params.transform_keys(&:to_s))) + request = Net::HTTP::Post.new(uri) + request.set_form_data(params.transform_keys(&:to_s)) + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10, write_timeout: 5) do |http| + http.request(request) + end + handle(response) + rescue Timeout::Error, SocketError, SystemCallError => e + raise ApiError.new(503, "Network error: #{e.message}") end def get(path, access_token, query) @@ -59,8 +67,12 @@ def get(path, access_token, query) request = Net::HTTP::Get.new(uri) request["Authorization"] = "Bearer #{access_token}" - response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) } + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10, write_timeout: 5) do |http| + http.request(request) + end handle(response) + rescue Timeout::Error, SocketError, SystemCallError => e + raise ApiError.new(503, "Network error: #{e.message}") end def handle(response) diff --git a/app/models/strava_account.rb b/app/models/strava_account.rb index a9f6322..e729a64 100644 --- a/app/models/strava_account.rb +++ b/app/models/strava_account.rb @@ -11,12 +11,42 @@ def token_expired? token_expires_at <= 60.seconds.from_now end + def refresh_token! + return unless token_expired? + + data = Strava::Client.new.refresh_token(refresh_token) + update!( + access_token: data["access_token"], + refresh_token: data["refresh_token"], + token_expires_at: Time.zone.at(data["expires_at"]) + ) + end + + def self.connect!(user:, oauth_code:) + data = Strava::Client.new.exchange_code(oauth_code) + + account = user.strava_account || user.build_strava_account + account.assign_attributes( + strava_athlete_id: data.dig("athlete", "id"), + access_token: data["access_token"], + refresh_token: data["refresh_token"], + token_expires_at: Time.zone.at(data["expires_at"]) + ) + + if account.save + StravaSyncJob.perform_later(account.id) + true + else + false + end + end + def syncing! update!(sync_status: "syncing") end - def sync_finished! - update!(sync_status: "idle", last_synced_at: Time.current, last_sync_error: nil) + def sync_finished!(date = nil) + update!(sync_status: "idle", last_synced_at: date || Time.current, last_sync_error: nil) end def sync_failed!(message) diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index f17e57a..34e1bd5 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -JGeCwgvCTE3Amv28haL/qrLCcl7gfwEhyfmunBc8k4dZ+HrDmhcTkiUmRfyYT6S/vzfUiLEy6gP1G6V+QVW4sehODu3U2NNUKgik+2PYEqdjARqmTDXpwcQTVRU93749GeNdRj2WmhG+fwR1/b81Q0R9Nt6XY7HJnjhJsQMbwGnyi4aWrf3JPtViwr1yHfy5VLa3sV6+PQYV++WCf8rekjKgyqvbiWlTb4WPqzhTMxTUkYq3eFGbk/ls30dbn2xftchY3m6RMcnTnz/nnCb+W67jcymdzZ0vvGC0L85P+3YzrNcvTa6UmsJleznaG4xpiDEyMChcPQCCv8FH/FoSkyVEGJxQ1+9S44VxpsAUhXBC9fGQrrNFvInI6vpKd+ftbpLbHKc+Gu9Ok9OMBqlmkWIMnI+wKNzZOdJ0aL7FAMwuFhhJfjPAscGlHC3fbBVZyh+s5VKNAV9k1//uIC0ZpvREOyq5fBnqoyvxioQ6xM0kmJkhtry7MhrITLR6LLM4E3x18RYp0MCBtVXcdmODpswNCtv1NPGu15WzgvmSh4YDH12jMhAaa/IHovG4b2VW7LyPjl6pqo/anMNvRXPgEbpO84vOjsn1iJc5W/Fjk1blnFUwg8F8dDH29BGUE9DbZjFR4L8g/xTVQTiG9mMZXjixGyT3eLBPJoVJLMFbZIVCNnKrqIBnmyufgxt7ysIxIDcQSVuCcEftucMhX1b7Tj2bFnjdZb3nYq94SiPm4orkBQiNp6h/Tej0xm/YYjKTHE6dUM5cIX5ddYhcrsndU1tRGduFhg2h9XFq+uwLJiX0lULMGoCdca2gVIxKPUtfrlm2eYNK4ck8X5JnKFlHcZBLzMrd9jW7UqI85ePjFBn4OjRLrd96gLId2Z/mSBQ5bY56HVSA0nzMkKSBROLmH6Dyx6G6S6M4G4uIOOm0COgpqcxP5H571cytyCTNoAwz0qBEZIlihW5ENmTiqHHO7jdm0MqQ4jypGhJrdjSg29vPd/nlIpf5JTABj4cs++oIbU+A+kfn34vVZucLnu7kxSVdwrPP/HHXdZYTHQacJLuXL8NMnUwY4o4ZsKa1vh8hNnWWgUcCueXsvQbEUYKg4oE4kwV7caB991vY8JSE+DrlMHPwuQttLrNXZBg1Ji2/WwJ80Cx4IUr0+XQFYKwq18Zm7KJsMIVf1cf+KcWuzfHuuYAydMJb8ir4iqHOHF1CRD4/PwEQDLHhnbreZw==--Zm8sEM3tdcao9M97--OG5iQ7NBLYLvAjQZWfS5AQ== \ No newline at end of file +oKhywDQKR4+tqcxeHH5kOVEQXPUHK3eBDs8KsyhJjsOPSc31JeiLZpBBSP7hNM+2fJcJoHepmGWm2PZtzwR3vboPDFbzuShto82Uesn7doQse2sS3KVQI2mlxAnbsl3Jb/QOr3h5YL5vvX3b48UC3alMSofqpND6scM+1ExqWRZhh+LH1RXn+BMuf/3y7n7T6AXgW0KAhzMmPCqkjhUDpnBkV8GzIG91CMfMQmuxbMz5y6j1G+CMxGHIq7gVXiE/jmQuiECNTh+t6XnPbh4WVBnIdK0tOdTIh4AvtzJ1uqxybf07tAR6csxXTpcQbwQB3VSBHICI/zmrSPjZOOL0rR632q8XxpedJHpa4EykO7D2wpT6s8meKEMgDjWcM8+fYhBMW9CwVfi4bIRAQpJXh0E9SCWBbOmijj1AQcahO9un5HgdNeIGeRYDB6g0K+zCBSynbVmgt/P5dOIsWhgvTerqS7CCNJ4/jXyI5IuKHhoYomHYLyjp/KNml3zlvSHMInwfX/FhXUS8JxIuZn0ohX0t8UeFUOSFdp9eMq98JQwvf0GAfUYAa0G69tT0IvEaC6muGhQ52M7/Xb1u+FJvjR1IfP3URU7uj/ezNGBp+lYzvprIWSQtHant+ApEb8cT4ySeXkwz5jub1Xi3kS9XLZlM1Z45wowsPX8dbS1sUtyL3hqho6uHHTPpTE0cE51ZfV6eyqOzqAHIqne5S7U7jR+NHYN1gocKDruZ5Xad2MP3AFSVbR81DTYkkx/RTtakEKMAp483aSaULiRWmALHoyvvflp8A2Xmg56lT9h1iTdSPBz4FwQ3ibucTQNdFkW1gtPpi6jaOPXCH/KW8i06ieYQdR+vGjKx2RZ4ME9pkP8CFXOoYQa0XJAlegxYR/QfO14J/s4vzkmLPvT4MCq1CgW+eUL7jXGZp2GhpxmPK9JY67KyQiPljZ+adDqdD9tEz/bmTzztM48NREWDnk+3XyIJ/yXKd3Ynr6NZCxg0/fVD3VU5P9N8djbtNkxa3Xb7IgbES+sr1wKsDRi1smdRd91atoAkH90LVtw7SisEUonxyPNeBjfy9dot2rwoQtypp2vCN1Xlq+waATjP99H7Lmwd6eEvhi34dqUpCs/tkbo72rmHhmFwQf5XbyVbmXCjEljrDS8N4cPF2G3sztyaG9+JkVEYz5gLIgnEPyaRkN7Pr4NSXwW9ibocXaqwtX6MdJ9PvVnqEGBtDEQtTMaQ0hbUYMU/UhyQxt1OpICTYtME/Y3su4y2OSluTCfPHj99IMU09N8FBpisZEwTj/BHsePEbAwbXNj6XBRPsRAscXB6a0gLD1q0NWOIwqgq1AytNkKwepsK4KX4PQ9AbEHNfSjaqJot+eO3ggPQlWys14RjMYVY0pRKzLXrzaLdeRnzz9o7Qsf9nkEAaK8OVQUabRACeCScQ1VTgtOmq8vAfMepHzlJdCFo4MyoBkfdP0pewtyjZf+O4z+edeJ0OoiuvRggD+ZHroi5RNFnwf9qZMfQDXTsRFEqG5wGbh9IeJAKteVZ/4hYl49dfFwMjEZezrbZy9YFDs+SAtv8ya1HUaU208YWOiELv/Dbd1bkqTtpGfihb9TjVi/o--zYDH9tVXPZEXFM47--9IKOZnpBNCzo7z3AchCYqg== \ No newline at end of file diff --git a/test/controllers/strava_accounts_controller_test.rb b/test/controllers/strava_accounts_controller_test.rb index 41046fb..a713de5 100644 --- a/test/controllers/strava_accounts_controller_test.rb +++ b/test/controllers/strava_accounts_controller_test.rb @@ -13,6 +13,9 @@ class StravaAccountsControllerTest < ActionDispatch::IntegrationTest end test "GET callback exchanges code, saves account and enqueues job" do + post strava_account_url + state = session[:strava_oauth_state] + assert_difference "StravaAccount.count", 1 do assert_enqueued_with(job: StravaSyncJob) do stub_request(:post, "https://www.strava.com/oauth/token") @@ -21,23 +24,33 @@ class StravaAccountsControllerTest < ActionDispatch::IntegrationTest athlete: { id: 999 } }.to_json) - get callback_strava_account_url(code: "auth-code") + get callback_strava_account_url(code: "auth-code", state: state) end end assert_redirected_to account_url - assert_equal "Strava connected! Syncing runs...", flash[:notice] || flash[:alert] # Will fail slightly if missing i18n but we assert flash exists. Actually let's not assert exact text for i18n, just redirected + assert_predicate flash[:notice], :present? account = StravaAccount.last assert_equal @user, account.user assert_equal 999, account.strava_athlete_id end + test "GET callback fails if state is invalid" do + post strava_account_url + get callback_strava_account_url(code: "auth-code", state: "invalid") + assert_redirected_to account_url + assert_predicate flash[:alert], :present? + end + test "GET callback redirects with alert on API error" do + post strava_account_url + state = session[:strava_oauth_state] + stub_request(:post, "https://www.strava.com/oauth/token") .to_return(status: 400, body: "Bad Request") - get callback_strava_account_url(code: "bad-code") + get callback_strava_account_url(code: "bad-code", state: state) assert_redirected_to account_url end diff --git a/test/controllers/strava_webhooks_controller_test.rb b/test/controllers/strava_webhooks_controller_test.rb index c8821e4..52302aa 100644 --- a/test/controllers/strava_webhooks_controller_test.rb +++ b/test/controllers/strava_webhooks_controller_test.rb @@ -2,6 +2,7 @@ class StravaWebhooksControllerTest < ActionDispatch::IntegrationTest setup do + @original_verify_token = ENV["STRAVA_VERIFY_TOKEN"] @account = StravaAccount.create!( user: users(:one), strava_athlete_id: 111, @@ -11,6 +12,21 @@ class StravaWebhooksControllerTest < ActionDispatch::IntegrationTest ) end + teardown do + ENV["STRAVA_VERIFY_TOKEN"] = @original_verify_token + end + + test "GET verify responds with forbidden when verify token is not configured" do + ENV["STRAVA_VERIFY_TOKEN"] = nil + get webhooks_strava_url, params: { + "hub.mode" => "subscribe", + "hub.verify_token" => "some-token", + "hub.challenge" => "15f7d1a91c1f40f8a748fd134752feb3" + } + + assert_response :forbidden + end + test "GET verify responds with challenge when token matches" do ENV["STRAVA_VERIFY_TOKEN"] = "my-secret-token" diff --git a/test/lib/strava/client_test.rb b/test/lib/strava/client_test.rb index ae3ccf7..69e3380 100644 --- a/test/lib/strava/client_test.rb +++ b/test/lib/strava/client_test.rb @@ -5,12 +5,13 @@ class Strava::ClientTest < ActiveSupport::TestCase @client = Strava::Client.new(client_id: "123", client_secret: "shh") end - test "authorize_url includes client_id, scope and redirect_uri" do - url = @client.authorize_url(redirect_uri: "https://calcpace.app/strava/callback") + test "authorize_url includes client_id, scope, state and redirect_uri" do + url = @client.authorize_url(redirect_uri: "https://calcpace.app/strava/callback", state: "my-state") assert_includes url, "https://www.strava.com/oauth/authorize?" assert_includes url, "client_id=123" assert_includes url, "scope=activity%3Aread_all" + assert_includes url, "state=my-state" assert_includes url, "redirect_uri=https%3A%2F%2Fcalcpace.app%2Fstrava%2Fcallback" end From 90bfec65dbb2014cb6999c607b107b770b9f57ec Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 18 Jul 2026 20:45:49 -0300 Subject: [PATCH 14/15] style: fix rubocop Layout/TrailingWhitespace offenses --- app/jobs/strava_sync_job.rb | 2 +- app/lib/strava/client.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/jobs/strava_sync_job.rb b/app/jobs/strava_sync_job.rb index f2f7f06..8b13ade 100644 --- a/app/jobs/strava_sync_job.rb +++ b/app/jobs/strava_sync_job.rb @@ -31,7 +31,7 @@ def import_all(account, client) Rails.logger.info("[StravaSyncJob] Fetched #{batch.size} activities from API (page #{page})") batch.each { |payload| importer.import(payload) } - + if batch.any? last_processed_date = Time.iso8601(batch.last["start_date"]) end diff --git a/app/lib/strava/client.rb b/app/lib/strava/client.rb index f5760a1..0d8009e 100644 --- a/app/lib/strava/client.rb +++ b/app/lib/strava/client.rb @@ -52,7 +52,7 @@ def post(path, params) uri = URI("#{BASE_URL}#{path}") request = Net::HTTP::Post.new(uri) request.set_form_data(params.transform_keys(&:to_s)) - + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10, write_timeout: 5) do |http| http.request(request) end From 742014642b63174fb6a66448a630552abd86a095 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Saraiva Date: Sat, 18 Jul 2026 20:49:55 -0300 Subject: [PATCH 15/15] chore: update loofah and rails-html-sanitizer for security --- Gemfile.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b248dc6..428ca1b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -230,7 +230,7 @@ GEM launchy (>= 2.2, < 4) lint_roller (1.1.0) logger (1.7.0) - loofah (2.25.1) + loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.9.0) @@ -345,8 +345,8 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.7.0) - loofah (~> 2.25) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rails-i18n (8.1.0) i18n (>= 0.7, < 2) @@ -619,7 +619,7 @@ CHECKSUMS letter_opener (1.10.0) sha256=2ff33f2e3b5c3c26d1959be54b395c086ca6d44826e8bf41a14ff96fdf1bdbb2 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 - loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 + loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b @@ -668,7 +668,7 @@ CHECKSUMS rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 rails (8.1.3) sha256=6d017ba5348c98fc909753a8169b21d44de14d2a0b92d140d1a966834c3c9cd3 rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d - rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89 + rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 rails-i18n (8.1.0) sha256=52d5fd6c0abef28d84223cc05647f6ae0fd552637a1ede92deee9545755b6cf3 railties (8.1.3) sha256=913eb0e0cb520aac687ffd74916bd726d48fa21f47833c6292576ef6a286de22 rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a