From af1bb773edcb070ced5b6df5178e24da09c10f9f Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 11 Jan 2026 12:36:31 +0100 Subject: [PATCH] Extract logic to services from TracksController#index and add tests --- app/controllers/api/v1/tracks_controller.rb | 45 ++--------- app/serializers/tracks/geojson_serializer.rb | 42 ++++++++++ app/services/tracks/index_query.rb | 61 ++++++++++++++ .../tracks/geojson_serializer_spec.rb | 38 +++++++++ spec/services/tracks/index_query_spec.rb | 80 +++++++++++++++++++ 5 files changed, 226 insertions(+), 40 deletions(-) create mode 100644 app/serializers/tracks/geojson_serializer.rb create mode 100644 app/services/tracks/index_query.rb create mode 100644 spec/serializers/tracks/geojson_serializer_spec.rb create mode 100644 spec/services/tracks/index_query_spec.rb diff --git a/app/controllers/api/v1/tracks_controller.rb b/app/controllers/api/v1/tracks_controller.rb index 862f5236..98c49bb6 100644 --- a/app/controllers/api/v1/tracks_controller.rb +++ b/app/controllers/api/v1/tracks_controller.rb @@ -2,50 +2,15 @@ class Api::V1::TracksController < ApiController def index - tracks = current_api_user.tracks + tracks_query = Tracks::IndexQuery.new(user: current_api_user, params: params) + paginated_tracks = tracks_query.call - # Date range filtering (overlap logic) - if params[:start_at].present? && params[:end_at].present? - start_at = Time.zone.parse(params[:start_at]) - end_at = Time.zone.parse(params[:end_at]) + geojson = Tracks::GeojsonSerializer.new(paginated_tracks).call - # Show tracks that overlap: end_at >= start_filter AND start_at <= end_filter - tracks = tracks.where('end_at >= ? AND start_at <= ?', start_at, end_at) + tracks_query.pagination_headers(paginated_tracks).each do |header, value| + response.set_header(header, value) end - # Pagination (Kaminari) - tracks = tracks - .order(start_at: :desc) - .page(params[:page]) - .per(params[:per_page] || 100) - - # Serialize to GeoJSON format - features = tracks.map do |track| - { - type: 'Feature', - geometry: RGeo::GeoJSON.encode(track.original_path), - properties: { - id: track.id, - color: '#ff0000', # Red color - start_at: track.start_at.iso8601, - end_at: track.end_at.iso8601, - distance: track.distance.to_i, - avg_speed: track.avg_speed.to_f, - duration: track.duration - } - } - end - - geojson = { - type: 'FeatureCollection', - features: features - } - - # Add pagination headers - response.set_header('X-Current-Page', tracks.current_page.to_s) - response.set_header('X-Total-Pages', tracks.total_pages.to_s) - response.set_header('X-Total-Count', tracks.total_count.to_s) - render json: geojson end end diff --git a/app/serializers/tracks/geojson_serializer.rb b/app/serializers/tracks/geojson_serializer.rb new file mode 100644 index 00000000..e3ab47e3 --- /dev/null +++ b/app/serializers/tracks/geojson_serializer.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Tracks + class GeojsonSerializer + DEFAULT_COLOR = '#ff0000' + + def initialize(tracks) + @tracks = Array.wrap(tracks) + end + + def call + { + type: 'FeatureCollection', + features: tracks.map { |track| feature_for(track) } + } + end + + private + + attr_reader :tracks + + def feature_for(track) + { + type: 'Feature', + geometry: RGeo::GeoJSON.encode(track.original_path), + properties: properties_for(track) + } + end + + def properties_for(track) + { + id: track.id, + color: DEFAULT_COLOR, + start_at: track.start_at.iso8601, + end_at: track.end_at.iso8601, + distance: track.distance.to_i, + avg_speed: track.avg_speed.to_f, + duration: track.duration + } + end + end +end diff --git a/app/services/tracks/index_query.rb b/app/services/tracks/index_query.rb new file mode 100644 index 00000000..193daaac --- /dev/null +++ b/app/services/tracks/index_query.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Tracks + # Encapsulates filtering and pagination logic for API track listings + class IndexQuery + DEFAULT_PER_PAGE = 100 + + def initialize(user:, params: {}) + @user = user + @params = params.with_indifferent_access + end + + def call + scoped = user.tracks + scoped = apply_date_range(scoped) + + scoped + .order(start_at: :desc) + .page(page_param) + .per(per_page_param) + end + + def pagination_headers(paginated_relation) + { + 'X-Current-Page' => paginated_relation.current_page.to_s, + 'X-Total-Pages' => paginated_relation.total_pages.to_s, + 'X-Total-Count' => paginated_relation.total_count.to_s + } + end + + private + + attr_reader :user, :params + + def page_param + candidate = params[:page].to_i + candidate.positive? ? candidate : 1 + end + + def per_page_param + candidate = params[:per_page].to_i + candidate.positive? ? candidate : DEFAULT_PER_PAGE + end + + def apply_date_range(scope) + return scope unless params[:start_at].present? && params[:end_at].present? + + start_at = parse_timestamp(params[:start_at]) + end_at = parse_timestamp(params[:end_at]) + return scope if start_at.blank? || end_at.blank? + + scope.where('end_at >= ? AND start_at <= ?', start_at, end_at) + end + + def parse_timestamp(value) + Time.zone.parse(value) + rescue ArgumentError, TypeError + nil + end + end +end diff --git a/spec/serializers/tracks/geojson_serializer_spec.rb b/spec/serializers/tracks/geojson_serializer_spec.rb new file mode 100644 index 00000000..60cf53c7 --- /dev/null +++ b/spec/serializers/tracks/geojson_serializer_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Tracks::GeojsonSerializer do + let(:track) do + create(:track, + start_at: Time.zone.parse('2024-01-01 10:00'), + end_at: Time.zone.parse('2024-01-01 11:00'), + distance: 1234.56, + avg_speed: 42.5, + duration: 3600) + end + + describe '#call' do + it 'returns a FeatureCollection structure' do + result = described_class.new([track]).call + + expect(result[:type]).to eq('FeatureCollection') + expect(result[:features].length).to eq(1) + end + + it 'includes geometry and track properties' do + feature = described_class.new([track]).call[:features].first + + expect(feature[:geometry][:type]).to eq('LineString') + expect(feature[:properties]).to include( + id: track.id, + color: '#ff0000', + start_at: track.start_at.iso8601, + end_at: track.end_at.iso8601, + distance: track.distance.to_i, + avg_speed: track.avg_speed.to_f, + duration: track.duration + ) + end + end +end diff --git a/spec/services/tracks/index_query_spec.rb b/spec/services/tracks/index_query_spec.rb new file mode 100644 index 00000000..f36e1be9 --- /dev/null +++ b/spec/services/tracks/index_query_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Tracks::IndexQuery do + let(:user) { create(:user) } + let(:params) { {} } + let(:query) { described_class.new(user: user, params: params) } + + describe '#call' do + let!(:newest_track) do + create(:track, user: user, + start_at: Time.zone.parse('2024-01-03 10:00'), + end_at: Time.zone.parse('2024-01-03 12:00')) + end + + let!(:older_track) do + create(:track, user: user, + start_at: Time.zone.parse('2024-01-01 10:00'), + end_at: Time.zone.parse('2024-01-01 12:00')) + end + let!(:other_user_track) { create(:track) } + + it 'returns tracks for the user ordered by start_at desc' do + result = query.call + + expect(result).to match_array([newest_track, older_track]) + expect(result.first).to eq(newest_track) + expect(result).not_to include(other_user_track) + end + + context 'with pagination params' do + let(:params) { { page: 1, per_page: 1 } } + + it 'applies pagination settings' do + result = query.call + expect(result.count).to eq(1) + end + end + + context 'with overlapping date range filter' do + let(:params) do + { + start_at: '2024-01-02T00:00:00Z', + end_at: '2024-01-04T00:00:00Z' + } + end + + it 'returns tracks that overlap the date range' do + result = query.call + + expect(result).to include(newest_track) + expect(result).not_to include(older_track) + end + end + + context 'with invalid date params' do + let(:params) { { start_at: 'invalid', end_at: 'also-invalid' } } + + it 'ignores the invalid filter and returns all tracks' do + result = query.call + expect(result.count).to eq(2) + end + end + end + + describe '#pagination_headers' do + it 'builds the pagination header hash' do + paginated_relation = double('paginated', current_page: 2, total_pages: 5, total_count: 12) + + headers = query.pagination_headers(paginated_relation) + + expect(headers).to eq( + 'X-Current-Page' => '2', + 'X-Total-Pages' => '5', + 'X-Total-Count' => '12' + ) + end + end +end