Extract logic to services from TracksController#index and add tests

This commit is contained in:
Eugene Burmakin 2026-01-11 12:36:31 +01:00
parent 7f38e07d4d
commit af1bb773ed
5 changed files with 226 additions and 40 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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