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/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..428ca1b 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)
@@ -226,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)
@@ -341,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)
@@ -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
@@ -608,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
@@ -657,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
@@ -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/app/controllers/strava_accounts_controller.rb b/app/controllers/strava_accounts_controller.rb
new file mode 100644
index 0000000..108cedc
--- /dev/null
+++ b/app/controllers/strava_accounts_controller.rb
@@ -0,0 +1,41 @@
+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, 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
+
+ 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 => e
+ Rails.logger.error("[Strava] Failed to connect: #{e.message}")
+ 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/app/controllers/strava_webhooks_controller.rb b/app/controllers/strava_webhooks_controller.rb
new file mode 100644
index 0000000..f613937
--- /dev/null
+++ b/app/controllers/strava_webhooks_controller.rb
@@ -0,0 +1,28 @@
+class StravaWebhooksController < ActionController::API
+ def verify
+ 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
+ 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.
+ 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])
+
+ if account
+ StravaSyncJob.perform_later(account.id)
+ end
+ end
+
+ head :ok
+ end
+end
diff --git a/app/jobs/strava_sync_job.rb b/app/jobs/strava_sync_job.rb
new file mode 100644
index 0000000..8b13ade
--- /dev/null
+++ b/app/jobs/strava_sync_job.rb
@@ -0,0 +1,47 @@
+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
+ account.refresh_token!
+
+ last_date = import_all(account, client)
+ account.sync_finished!(last_date)
+ rescue Strava::Client::ApiError => e
+ account&.sync_failed!(e.message)
+ raise
+ end
+
+ private
+
+ 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|
+ 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) }
+
+ 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
new file mode 100644
index 0000000..814e4df
--- /dev/null
+++ b/app/lib/strava/activity_importer.rb
@@ -0,0 +1,74 @@
+module Strava
+ class ActivityImporter
+ RUN_SPORT_TYPES = %w[Run TrailRun Walk].freeze
+
+ def initialize(profile)
+ @profile = profile
+ end
+
+ # @param payload [Hash] one activity from GET /athlete/activities
+ # @return [Activity, :duplicate, :skipped]
+ def import(payload)
+ external_id = payload["id"].to_s
+ 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
+
+ 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
+ rescue ActiveRecord::RecordNotUnique
+ Rails.logger.info("[StravaImporter] Skipped #{external_id} (concurrent duplicate)")
+ :duplicate
+ end
+ 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/lib/strava/client.rb b/app/lib/strava/client.rb
new file mode 100644
index 0000000..0d8009e
--- /dev/null
+++ b/app/lib/strava/client.rb
@@ -0,0 +1,84 @@
+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:, state:)
+ params = {
+ client_id: @client_id, redirect_uri: redirect_uri,
+ response_type: "code", scope: "activity:read_all", approval_prompt: "auto", state: state
+ }
+ "#{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}")
+ 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)
+ 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, 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)
+ 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/app/models/activity.rb b/app/models/activity.rb
index 7262a54..5384035 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, map_polyline: 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, map_polyline: map_polyline }
attrs[:public] = BOOLEAN_TYPE.cast(public) unless public.nil?
profile.activities.create!(attrs)
end
diff --git a/app/models/strava_account.rb b/app/models/strava_account.rb
new file mode 100644
index 0000000..e729a64
--- /dev/null
+++ b/app/models/strava_account.rb
@@ -0,0 +1,55 @@
+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 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!(date = nil)
+ update!(sync_status: "idle", last_synced_at: date || 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/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/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/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/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
diff --git a/config/locales/strava.en.yml b/config/locales/strava.en.yml
new file mode 100644
index 0000000..0867107
--- /dev/null
+++ b/config/locales/strava.en.yml
@@ -0,0 +1,20 @@
+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."
+ 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"
diff --git a/config/routes.rb b/config/routes.rb
index 521f0ea..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
@@ -35,6 +38,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/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/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 3e5d64d..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_06_21_201058) 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"
@@ -47,18 +47,22 @@
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"
t.string "location"
+ t.text "map_polyline"
t.string "name"
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 +132,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 +291,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 +352,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/controllers/strava_accounts_controller_test.rb b/test/controllers/strava_accounts_controller_test.rb
new file mode 100644
index 0000000..a713de5
--- /dev/null
+++ b/test/controllers/strava_accounts_controller_test.rb
@@ -0,0 +1,72 @@
+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
+ 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")
+ .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", state: state)
+ end
+ end
+
+ assert_redirected_to account_url
+ 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", state: state)
+ 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
diff --git a/test/controllers/strava_webhooks_controller_test.rb b/test/controllers/strava_webhooks_controller_test.rb
new file mode 100644
index 0000000..52302aa
--- /dev/null
+++ b/test/controllers/strava_webhooks_controller_test.rb
@@ -0,0 +1,93 @@
+require "test_helper"
+
+class StravaWebhooksControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @original_verify_token = ENV["STRAVA_VERIFY_TOKEN"]
+ @account = StravaAccount.create!(
+ user: users(:one),
+ strava_athlete_id: 111,
+ access_token: "at",
+ refresh_token: "rt",
+ token_expires_at: 2.hours.from_now
+ )
+ 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"
+
+ 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
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
diff --git a/test/lib/strava/activity_importer_test.rb b/test/lib/strava/activity_importer_test.rb
new file mode 100644
index 0000000..816df8b
--- /dev/null
+++ b/test/lib/strava/activity_importer_test.rb
@@ -0,0 +1,78 @@
+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 "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")
+
+ assert_difference "Activity.count" do
+ Strava::ActivityImporter.new(@profile).import(minimal)
+ 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..69e3380
--- /dev/null
+++ b/test/lib/strava/client_test.rb
@@ -0,0 +1,67 @@
+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, 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
+
+ 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
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
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