Extract logic to service classes

This commit is contained in:
Eugene Burmakin 2025-09-16 20:41:53 +02:00
parent 8c45404420
commit eb16959b9a
13 changed files with 1134 additions and 187 deletions

View file

@ -3,37 +3,18 @@
class Api::V1::Maps::HexagonsController < ApiController
skip_before_action :authenticate_api_key, if: :public_sharing_request?
before_action :validate_bbox_params, except: [:bounds]
before_action :set_user_and_dates
def index
# Try to use pre-calculated hexagon centers from stats
if @stat&.hexagon_centers.present?
result = build_hexagons_from_centers(@stat.hexagon_centers)
Rails.logger.debug "Using pre-calculated hexagon centers: #{@stat.hexagon_centers.size} centers"
return render json: result
end
result = Maps::HexagonRequestHandler.call(
params: params,
current_api_user: current_api_user
)
# Handle legacy "area too large" entries - recalculate them now that we can handle large areas
if @stat&.hexagon_centers&.dig('area_too_large')
Rails.logger.info "Recalculating previously skipped large area hexagons for stat #{@stat.id}"
# Trigger recalculation
service = Stats::CalculateMonth.new(@target_user.id, @stat.year, @stat.month)
new_centers = service.send(:calculate_hexagon_centers)
if new_centers && !new_centers.dig(:area_too_large)
@stat.update(hexagon_centers: new_centers)
result = build_hexagons_from_centers(new_centers)
Rails.logger.debug "Successfully recalculated hexagon centers: #{new_centers.size} centers"
return render json: result
end
end
# Fall back to on-the-fly calculation for legacy/missing data
Rails.logger.debug 'No pre-calculated data available, calculating hexagons on-the-fly'
result = Maps::HexagonGrid.new(hexagon_params).call
Rails.logger.debug "Hexagon service result: #{result['features']&.count || 0} features"
render json: result
rescue Maps::HexagonContextResolver::SharedStatsNotFoundError => e
render json: { error: e.message }, status: :not_found
rescue Maps::DateParameterCoercer::InvalidDateFormatError => e
render json: { error: e.message }, status: :bad_request
rescue Maps::HexagonGrid::BoundingBoxTooLargeError,
Maps::HexagonGrid::InvalidCoordinatesError => e
render json: { error: e.message }, status: :bad_request
@ -44,161 +25,41 @@ class Api::V1::Maps::HexagonsController < ApiController
end
def bounds
# Get the bounding box of user's points for the date range
return render json: { error: 'No user found' }, status: :not_found unless @target_user
return render json: { error: 'No date range specified' }, status: :bad_request unless @start_date && @end_date
context = Maps::HexagonContextResolver.call(
params: params,
current_api_user: current_api_user
)
# Convert dates to timestamps (handle both string and timestamp formats)
begin
start_timestamp = coerce_date(@start_date)
end_timestamp = coerce_date(@end_date)
rescue ArgumentError => e
return render json: { error: e.message }, status: :bad_request
end
result = Maps::BoundsCalculator.call(
target_user: context[:target_user],
start_date: context[:start_date],
end_date: context[:end_date]
)
points_relation = @target_user.points.where(timestamp: start_timestamp..end_timestamp)
point_count = points_relation.count
if point_count.positive?
bounds_result = ActiveRecord::Base.connection.exec_query(
"SELECT MIN(latitude) as min_lat, MAX(latitude) as max_lat,
MIN(longitude) as min_lng, MAX(longitude) as max_lng
FROM points
WHERE user_id = $1
AND timestamp BETWEEN $2 AND $3",
'bounds_query',
[@target_user.id, start_timestamp, end_timestamp]
).first
render json: {
min_lat: bounds_result['min_lat'].to_f,
max_lat: bounds_result['max_lat'].to_f,
min_lng: bounds_result['min_lng'].to_f,
max_lng: bounds_result['max_lng'].to_f,
point_count: point_count
}
if result[:success]
render json: result[:data]
else
render json: {
error: 'No data found for the specified date range',
point_count: 0
error: result[:error],
point_count: result[:point_count]
}, status: :not_found
end
rescue Maps::HexagonContextResolver::SharedStatsNotFoundError => e
render json: { error: e.message }, status: :not_found
rescue Maps::BoundsCalculator::NoUserFoundError => e
render json: { error: e.message }, status: :not_found
rescue Maps::BoundsCalculator::NoDateRangeError => e
render json: { error: e.message }, status: :bad_request
rescue Maps::DateParameterCoercer::InvalidDateFormatError => e
render json: { error: e.message }, status: :bad_request
end
private
def build_hexagons_from_centers(centers)
# Convert stored centers back to hexagon polygons
# Each center is [lng, lat, earliest_timestamp, latest_timestamp]
hexagon_features = centers.map.with_index do |center, index|
lng, lat, earliest, latest = center
# Generate hexagon polygon from center point (1000m hexagons)
hexagon_geojson = generate_hexagon_polygon(lng, lat, 1000)
{
type: 'Feature',
id: index + 1,
geometry: hexagon_geojson,
properties: {
hex_id: index + 1,
hex_size: 1000,
earliest_point: earliest ? Time.zone.at(earliest).iso8601 : nil,
latest_point: latest ? Time.zone.at(latest).iso8601 : nil
}
}
end
{
'type' => 'FeatureCollection',
'features' => hexagon_features,
'metadata' => {
'hex_size_m' => 1000,
'count' => hexagon_features.count,
'user_id' => @target_user.id,
'pre_calculated' => true
}
}
end
def generate_hexagon_polygon(center_lng, center_lat, size_meters)
# Generate hexagon vertices around center point
# PostGIS ST_HexagonGrid uses size_meters as the edge-to-edge distance (width/flat-to-flat)
# For a regular hexagon with width = size_meters:
# - Width (edge to edge) = size_meters
# - Radius (center to vertex) = width / √3 ≈ size_meters * 0.577
# - Edge length ≈ radius ≈ size_meters * 0.577
radius_meters = size_meters / Math.sqrt(2.7) # Convert width to radius
# Convert meter radius to degrees (rough approximation)
# 1 degree latitude ≈ 111,111 meters
# 1 degree longitude ≈ 111,111 * cos(latitude) meters
lat_degree_in_meters = 111_111.0
lng_degree_in_meters = lat_degree_in_meters * Math.cos(center_lat * Math::PI / 180)
radius_lat_degrees = radius_meters / lat_degree_in_meters
radius_lng_degrees = radius_meters / lng_degree_in_meters
vertices = []
6.times do |i|
# Calculate angle for each vertex (60 degrees apart, starting from 0)
angle = (i * 60) * Math::PI / 180
# Calculate vertex position
lat_offset = radius_lat_degrees * Math.sin(angle)
lng_offset = radius_lng_degrees * Math.cos(angle)
vertices << [center_lng + lng_offset, center_lat + lat_offset]
end
# Close the polygon by adding the first vertex at the end
vertices << vertices.first
{
type: 'Polygon',
coordinates: [vertices]
}
end
def bbox_params
params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :viewport_width, :viewport_height)
end
def hexagon_params
bbox_params.merge(
user_id: @target_user&.id,
start_date: @start_date,
end_date: @end_date
)
end
def set_user_and_dates
return set_public_sharing_context if params[:uuid].present?
set_authenticated_context
end
def set_public_sharing_context
@stat = Stat.find_by(sharing_uuid: params[:uuid])
unless @stat&.public_accessible?
render json: {
error: 'Shared stats not found or no longer available'
}, status: :not_found and return
end
@target_user = @stat.user
@start_date = Date.new(@stat.year, @stat.month, 1).beginning_of_day.iso8601
@end_date = Date.new(@stat.year, @stat.month, 1).end_of_month.end_of_day.iso8601
end
def set_authenticated_context
@target_user = current_api_user
@start_date = params[:start_date]
@end_date = params[:end_date]
end
def handle_service_error
render json: { error: 'Failed to generate hexagon grid' }, status: :internal_server_error
end
@ -217,23 +78,4 @@ class Api::V1::Maps::HexagonsController < ApiController
error: "Missing required parameters: #{missing_params.join(', ')}"
}, status: :bad_request
end
def coerce_date(param)
case param
when String
# Check if it's a numeric string (timestamp) or date string
if param.match?(/^\d+$/)
param.to_i
else
Time.parse(param).to_i
end
when Integer
param
else
param.to_i
end
rescue ArgumentError => e
Rails.logger.error "Invalid date format: #{param} - #{e.message}"
raise ArgumentError, "Invalid date format: #{param}"
end
end

View file

@ -0,0 +1,74 @@
# frozen_string_literal: true
module Maps
class BoundsCalculator
class NoUserFoundError < StandardError; end
class NoDateRangeError < StandardError; end
class NoDataFoundError < StandardError; end
def self.call(target_user:, start_date:, end_date:)
new(target_user: target_user, start_date: start_date, end_date: end_date).call
end
def initialize(target_user:, start_date:, end_date:)
@target_user = target_user
@start_date = start_date
@end_date = end_date
end
def call
validate_inputs!
start_timestamp = Maps::DateParameterCoercer.call(@start_date)
end_timestamp = Maps::DateParameterCoercer.call(@end_date)
points_relation = @target_user.points.where(timestamp: start_timestamp..end_timestamp)
point_count = points_relation.count
return build_no_data_response if point_count.zero?
bounds_result = execute_bounds_query(start_timestamp, end_timestamp)
build_success_response(bounds_result, point_count)
end
private
def validate_inputs!
raise NoUserFoundError, 'No user found' unless @target_user
raise NoDateRangeError, 'No date range specified' unless @start_date && @end_date
end
def execute_bounds_query(start_timestamp, end_timestamp)
ActiveRecord::Base.connection.exec_query(
"SELECT MIN(latitude) as min_lat, MAX(latitude) as max_lat,
MIN(longitude) as min_lng, MAX(longitude) as max_lng
FROM points
WHERE user_id = $1
AND timestamp BETWEEN $2 AND $3",
'bounds_query',
[@target_user.id, start_timestamp, end_timestamp]
).first
end
def build_success_response(bounds_result, point_count)
{
success: true,
data: {
min_lat: bounds_result['min_lat'].to_f,
max_lat: bounds_result['max_lat'].to_f,
min_lng: bounds_result['min_lng'].to_f,
max_lng: bounds_result['max_lng'].to_f,
point_count: point_count
}
}
end
def build_no_data_response
{
success: false,
error: 'No data found for the specified date range',
point_count: 0
}
end
end
end

View file

@ -0,0 +1,42 @@
# frozen_string_literal: true
module Maps
class DateParameterCoercer
class InvalidDateFormatError < StandardError; end
def self.call(param)
new(param).call
end
def initialize(param)
@param = param
end
def call
coerce_date(@param)
end
private
attr_reader :param
def coerce_date(param)
case param
when String
# Check if it's a numeric string (timestamp) or date string
if param.match?(/^\d+$/)
param.to_i
else
Time.parse(param).to_i
end
when Integer
param
else
param.to_i
end
rescue ArgumentError => e
Rails.logger.error "Invalid date format: #{param} - #{e.message}"
raise InvalidDateFormatError, "Invalid date format: #{param}"
end
end
end

View file

@ -0,0 +1,104 @@
# frozen_string_literal: true
module Maps
class HexagonCenterManager
def self.call(stat:, target_user:)
new(stat: stat, target_user: target_user).call
end
def initialize(stat:, target_user:)
@stat = stat
@target_user = target_user
end
def call
return build_response_from_centers if pre_calculated_centers_available?
return handle_legacy_area_too_large if legacy_area_too_large?
nil # No pre-calculated data available
end
private
attr_reader :stat, :target_user
def pre_calculated_centers_available?
return false unless stat&.hexagon_centers.present?
# Handle legacy hash format
if stat.hexagon_centers.is_a?(Hash)
!stat.hexagon_centers['area_too_large']
else
# Handle array format (actual hexagon centers)
stat.hexagon_centers.is_a?(Array) && stat.hexagon_centers.any?
end
end
def legacy_area_too_large?
stat&.hexagon_centers.is_a?(Hash) && stat.hexagon_centers['area_too_large']
end
def build_response_from_centers
centers = stat.hexagon_centers
Rails.logger.debug "Using pre-calculated hexagon centers: #{centers.size} centers"
result = build_hexagons_from_centers(centers)
{ success: true, data: result, pre_calculated: true }
end
def handle_legacy_area_too_large
Rails.logger.info "Recalculating previously skipped large area hexagons for stat #{stat.id}"
# Trigger recalculation
service = Stats::CalculateMonth.new(target_user.id, stat.year, stat.month)
new_centers = service.send(:calculate_hexagon_centers)
if new_centers && new_centers.is_a?(Array)
stat.update(hexagon_centers: new_centers)
result = build_hexagons_from_centers(new_centers)
Rails.logger.debug "Successfully recalculated hexagon centers: #{new_centers.size} centers"
return { success: true, data: result, pre_calculated: true }
end
nil # Recalculation failed or still too large
end
def build_hexagons_from_centers(centers)
# Convert stored centers back to hexagon polygons
# Each center is [lng, lat, earliest_timestamp, latest_timestamp]
hexagon_features = centers.map.with_index do |center, index|
lng, lat, earliest, latest = center
# Generate hexagon polygon from center point (1000m hexagons)
hexagon_geojson = Maps::HexagonPolygonGenerator.call(
center_lng: lng,
center_lat: lat,
size_meters: 1000
)
{
'type' => 'Feature',
'id' => index + 1,
'geometry' => hexagon_geojson,
'properties' => {
'hex_id' => index + 1,
'hex_size' => 1000,
'earliest_point' => earliest ? Time.zone.at(earliest).iso8601 : nil,
'latest_point' => latest ? Time.zone.at(latest).iso8601 : nil
}
}
end
{
'type' => 'FeatureCollection',
'features' => hexagon_features,
'metadata' => {
'hex_size_m' => 1000,
'count' => hexagon_features.count,
'user_id' => target_user.id,
'pre_calculated' => true
}
}
end
end
end

View file

@ -0,0 +1,58 @@
# frozen_string_literal: true
module Maps
class HexagonContextResolver
class SharedStatsNotFoundError < StandardError; end
def self.call(params:, current_api_user: nil)
new(params: params, current_api_user: current_api_user).call
end
def initialize(params:, current_api_user: nil)
@params = params
@current_api_user = current_api_user
end
def call
return resolve_public_sharing_context if public_sharing_request?
resolve_authenticated_context
end
private
attr_reader :params, :current_api_user
def public_sharing_request?
params[:uuid].present?
end
def resolve_public_sharing_context
stat = Stat.find_by(sharing_uuid: params[:uuid])
unless stat&.public_accessible?
raise SharedStatsNotFoundError, 'Shared stats not found or no longer available'
end
target_user = stat.user
start_date = Date.new(stat.year, stat.month, 1).beginning_of_day.iso8601
end_date = Date.new(stat.year, stat.month, 1).end_of_month.end_of_day.iso8601
{
target_user: target_user,
start_date: start_date,
end_date: end_date,
stat: stat
}
end
def resolve_authenticated_context
{
target_user: current_api_user,
start_date: params[:start_date],
end_date: params[:end_date],
stat: nil
}
end
end
end

View file

@ -0,0 +1,70 @@
# frozen_string_literal: true
module Maps
class HexagonPolygonGenerator
DEFAULT_SIZE_METERS = 1000
def self.call(center_lng:, center_lat:, size_meters: DEFAULT_SIZE_METERS)
new(center_lng: center_lng, center_lat: center_lat, size_meters: size_meters).call
end
def initialize(center_lng:, center_lat:, size_meters: DEFAULT_SIZE_METERS)
@center_lng = center_lng
@center_lat = center_lat
@size_meters = size_meters
end
def call
generate_hexagon_polygon
end
private
attr_reader :center_lng, :center_lat, :size_meters
def generate_hexagon_polygon
# Generate hexagon vertices around center point
# PostGIS ST_HexagonGrid uses size_meters as the edge-to-edge distance (width/flat-to-flat)
# For a regular hexagon with width = size_meters:
# - Width (edge to edge) = size_meters
# - Radius (center to vertex) = width / √3 ≈ size_meters * 0.577
# - Edge length ≈ radius ≈ size_meters * 0.577
radius_meters = size_meters / Math.sqrt(2.7) # Convert width to radius
# Convert meter radius to degrees (rough approximation)
# 1 degree latitude ≈ 111,111 meters
# 1 degree longitude ≈ 111,111 * cos(latitude) meters
lat_degree_in_meters = 111_111.0
lng_degree_in_meters = lat_degree_in_meters * Math.cos(center_lat * Math::PI / 180)
radius_lat_degrees = radius_meters / lat_degree_in_meters
radius_lng_degrees = radius_meters / lng_degree_in_meters
vertices = build_vertices(radius_lat_degrees, radius_lng_degrees)
{
'type' => 'Polygon',
'coordinates' => [vertices]
}
end
def build_vertices(radius_lat_degrees, radius_lng_degrees)
vertices = []
6.times do |i|
# Calculate angle for each vertex (60 degrees apart, starting from 0)
angle = (i * 60) * Math::PI / 180
# Calculate vertex position
lat_offset = radius_lat_degrees * Math.sin(angle)
lng_offset = radius_lng_degrees * Math.cos(angle)
vertices << [center_lng + lng_offset, center_lat + lat_offset]
end
# Close the polygon by adding the first vertex at the end
vertices << vertices.first
vertices
end
end
end

View file

@ -0,0 +1,62 @@
# frozen_string_literal: true
module Maps
class HexagonRequestHandler
def self.call(params:, current_api_user: nil)
new(params: params, current_api_user: current_api_user).call
end
def initialize(params:, current_api_user: nil)
@params = params
@current_api_user = current_api_user
end
def call
context = resolve_context
# Try to use pre-calculated hexagon centers first
if context[:stat]
cached_result = Maps::HexagonCenterManager.call(
stat: context[:stat],
target_user: context[:target_user]
)
return cached_result[:data] if cached_result&.dig(:success)
end
# Fall back to on-the-fly calculation
Rails.logger.debug 'No pre-calculated data available, calculating hexagons on-the-fly'
generate_hexagons_on_the_fly(context)
end
private
attr_reader :params, :current_api_user
def resolve_context
Maps::HexagonContextResolver.call(
params: params,
current_api_user: current_api_user
)
end
def generate_hexagons_on_the_fly(context)
hexagon_params = build_hexagon_params(context)
result = Maps::HexagonGrid.new(hexagon_params).call
Rails.logger.debug "Hexagon service result: #{result['features']&.count || 0} features"
result
end
def build_hexagon_params(context)
bbox_params.merge(
user_id: context[:target_user]&.id,
start_date: context[:start_date],
end_date: context[:end_date]
)
end
def bbox_params
params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :viewport_width, :viewport_height)
end
end
end

View file

@ -0,0 +1,120 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Maps::BoundsCalculator do
describe '.call' do
subject(:calculate_bounds) do
described_class.call(
target_user: target_user,
start_date: start_date,
end_date: end_date
)
end
let(:user) { create(:user) }
let(:target_user) { user }
let(:start_date) { '2024-06-01T00:00:00Z' }
let(:end_date) { '2024-06-30T23:59:59Z' }
context 'with valid user and date range' do
before do
# Create test points within the date range
create(:point, user:, latitude: 40.6, longitude: -74.1,
timestamp: Time.new(2024, 6, 1, 12, 0).to_i)
create(:point, user:, latitude: 40.8, longitude: -73.9,
timestamp: Time.new(2024, 6, 30, 15, 0).to_i)
create(:point, user:, latitude: 40.7, longitude: -74.0,
timestamp: Time.new(2024, 6, 15, 10, 0).to_i)
end
it 'returns success with bounds data' do
expect(calculate_bounds).to match({
success: true,
data: {
min_lat: 40.6,
max_lat: 40.8,
min_lng: -74.1,
max_lng: -73.9,
point_count: 3
}
})
end
end
context 'with no points in date range' do
before do
# Create points outside the date range
create(:point, user:, latitude: 40.7, longitude: -74.0,
timestamp: Time.new(2024, 5, 15, 10, 0).to_i)
end
it 'returns failure with no data message' do
expect(calculate_bounds).to match({
success: false,
error: 'No data found for the specified date range',
point_count: 0
})
end
end
context 'with no user' do
let(:target_user) { nil }
it 'raises NoUserFoundError' do
expect { calculate_bounds }.to raise_error(
Maps::BoundsCalculator::NoUserFoundError,
'No user found'
)
end
end
context 'with no start date' do
let(:start_date) { nil }
it 'raises NoDateRangeError' do
expect { calculate_bounds }.to raise_error(
Maps::BoundsCalculator::NoDateRangeError,
'No date range specified'
)
end
end
context 'with no end date' do
let(:end_date) { nil }
it 'raises NoDateRangeError' do
expect { calculate_bounds }.to raise_error(
Maps::BoundsCalculator::NoDateRangeError,
'No date range specified'
)
end
end
context 'with invalid date format' do
let(:start_date) { 'invalid-date' }
it 'raises InvalidDateFormatError' do
expect { calculate_bounds }.to raise_error(
Maps::DateParameterCoercer::InvalidDateFormatError
)
end
end
context 'with timestamp format dates' do
let(:start_date) { 1_717_200_000 }
let(:end_date) { 1_719_791_999 }
before do
create(:point, user:, latitude: 41.0, longitude: -74.5,
timestamp: Time.new(2024, 6, 5, 9, 0).to_i)
end
it 'handles timestamp format correctly' do
result = calculate_bounds
expect(result[:success]).to be true
expect(result[:data][:point_count]).to eq(1)
end
end
end
end

View file

@ -0,0 +1,70 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Maps::DateParameterCoercer do
describe '.call' do
subject(:coerce_date) { described_class.call(param) }
context 'with integer parameter' do
let(:param) { 1_717_200_000 }
it 'returns the integer unchanged' do
expect(coerce_date).to eq(1_717_200_000)
end
end
context 'with numeric string parameter' do
let(:param) { '1717200000' }
it 'converts to integer' do
expect(coerce_date).to eq(1_717_200_000)
end
end
context 'with ISO date string parameter' do
let(:param) { '2024-06-01T00:00:00Z' }
it 'parses and converts to timestamp' do
expected_timestamp = Time.parse('2024-06-01T00:00:00Z').to_i
expect(coerce_date).to eq(expected_timestamp)
end
end
context 'with date string parameter' do
let(:param) { '2024-06-01' }
it 'parses and converts to timestamp' do
expected_timestamp = Time.parse('2024-06-01').to_i
expect(coerce_date).to eq(expected_timestamp)
end
end
context 'with invalid date string' do
let(:param) { 'invalid-date' }
it 'raises InvalidDateFormatError' do
expect { coerce_date }.to raise_error(
Maps::DateParameterCoercer::InvalidDateFormatError,
'Invalid date format: invalid-date'
)
end
end
context 'with nil parameter' do
let(:param) { nil }
it 'converts to 0' do
expect(coerce_date).to eq(0)
end
end
context 'with float parameter' do
let(:param) { 1_717_200_000.5 }
it 'converts to integer' do
expect(coerce_date).to eq(1_717_200_000)
end
end
end
end

View file

@ -0,0 +1,129 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Maps::HexagonCenterManager do
describe '.call' do
subject(:manage_centers) do
described_class.call(
stat: stat,
target_user: target_user
)
end
let(:user) { create(:user) }
let(:target_user) { user }
context 'with pre-calculated hexagon centers' do
let(:pre_calculated_centers) do
[
[-74.0, 40.7, 1_717_200_000, 1_717_203_600], # lng, lat, earliest, latest timestamps
[-74.01, 40.71, 1_717_210_000, 1_717_213_600],
[-74.02, 40.72, 1_717_220_000, 1_717_223_600]
]
end
let(:stat) { create(:stat, user:, year: 2024, month: 6, hexagon_centers: pre_calculated_centers) }
it 'returns success with pre-calculated data' do
result = manage_centers
expect(result[:success]).to be true
expect(result[:pre_calculated]).to be true
expect(result[:data]['type']).to eq('FeatureCollection')
expect(result[:data]['features'].length).to eq(3)
expect(result[:data]['metadata']['pre_calculated']).to be true
expect(result[:data]['metadata']['count']).to eq(3)
expect(result[:data]['metadata']['user_id']).to eq(target_user.id)
end
it 'generates proper hexagon features from centers' do
result = manage_centers
features = result[:data]['features']
features.each_with_index do |feature, index|
expect(feature['type']).to eq('Feature')
expect(feature['id']).to eq(index + 1)
expect(feature['geometry']['type']).to eq('Polygon')
expect(feature['geometry']['coordinates'].first.length).to eq(7) # 6 vertices + closing
properties = feature['properties']
expect(properties['hex_id']).to eq(index + 1)
expect(properties['hex_size']).to eq(1000)
expect(properties['earliest_point']).to be_present
expect(properties['latest_point']).to be_present
end
end
end
context 'with legacy area_too_large flag' do
let(:stat) do
create(:stat, user:, year: 2024, month: 6, hexagon_centers: { 'area_too_large' => true })
end
before do
# Mock the Stats::CalculateMonth service
allow_any_instance_of(Stats::CalculateMonth).to receive(:calculate_hexagon_centers)
.and_return(new_centers)
end
context 'when recalculation succeeds' do
let(:new_centers) do
[
[-74.0, 40.7, 1_717_200_000, 1_717_203_600],
[-74.01, 40.71, 1_717_210_000, 1_717_213_600]
]
end
it 'recalculates and updates the stat' do
expect(stat).to receive(:update).with(hexagon_centers: new_centers)
result = manage_centers
expect(result[:success]).to be true
expect(result[:pre_calculated]).to be true
expect(result[:data]['features'].length).to eq(2)
end
end
context 'when recalculation fails' do
let(:new_centers) { nil }
it 'returns nil' do
expect(manage_centers).to be_nil
end
end
context 'when recalculation returns area_too_large again' do
let(:new_centers) { { area_too_large: true } }
it 'returns nil' do
expect(manage_centers).to be_nil
end
end
end
context 'with no stat' do
let(:stat) { nil }
it 'returns nil' do
expect(manage_centers).to be_nil
end
end
context 'with stat but no hexagon_centers' do
let(:stat) { create(:stat, user:, year: 2024, month: 6, hexagon_centers: nil) }
it 'returns nil' do
expect(manage_centers).to be_nil
end
end
context 'with empty hexagon_centers' do
let(:stat) { create(:stat, user:, year: 2024, month: 6, hexagon_centers: []) }
it 'returns nil' do
expect(manage_centers).to be_nil
end
end
end
end

View file

@ -0,0 +1,102 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Maps::HexagonContextResolver do
describe '.call' do
subject(:resolve_context) do
described_class.call(
params: params,
current_api_user: current_api_user
)
end
let(:user) { create(:user) }
let(:current_api_user) { user }
context 'with authenticated user (no UUID)' do
let(:params) do
{
start_date: '2024-06-01T00:00:00Z',
end_date: '2024-06-30T23:59:59Z'
}
end
it 'resolves authenticated context' do
result = resolve_context
expect(result).to match({
target_user: current_api_user,
start_date: '2024-06-01T00:00:00Z',
end_date: '2024-06-30T23:59:59Z',
stat: nil
})
end
end
context 'with public sharing UUID' do
let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) }
let(:params) { { uuid: stat.sharing_uuid } }
let(:current_api_user) { nil }
it 'resolves public sharing context' do
result = resolve_context
expect(result[:target_user]).to eq(user)
expect(result[:stat]).to eq(stat)
expect(result[:start_date]).to eq('2024-06-01T00:00:00+00:00')
expect(result[:end_date]).to eq('2024-06-30T23:59:59+00:00')
end
end
context 'with invalid sharing UUID' do
let(:params) { { uuid: 'invalid-uuid' } }
let(:current_api_user) { nil }
it 'raises SharedStatsNotFoundError' do
expect { resolve_context }.to raise_error(
Maps::HexagonContextResolver::SharedStatsNotFoundError,
'Shared stats not found or no longer available'
)
end
end
context 'with expired sharing' do
let(:stat) { create(:stat, :with_sharing_expired, user:, year: 2024, month: 6) }
let(:params) { { uuid: stat.sharing_uuid } }
let(:current_api_user) { nil }
it 'raises SharedStatsNotFoundError' do
expect { resolve_context }.to raise_error(
Maps::HexagonContextResolver::SharedStatsNotFoundError,
'Shared stats not found or no longer available'
)
end
end
context 'with disabled sharing' do
let(:stat) { create(:stat, :with_sharing_disabled, user:, year: 2024, month: 6) }
let(:params) { { uuid: stat.sharing_uuid } }
let(:current_api_user) { nil }
it 'raises SharedStatsNotFoundError' do
expect { resolve_context }.to raise_error(
Maps::HexagonContextResolver::SharedStatsNotFoundError,
'Shared stats not found or no longer available'
)
end
end
context 'with stat that does not exist' do
let(:params) { { uuid: 'non-existent-uuid' } }
let(:current_api_user) { nil }
it 'raises SharedStatsNotFoundError' do
expect { resolve_context }.to raise_error(
Maps::HexagonContextResolver::SharedStatsNotFoundError,
'Shared stats not found or no longer available'
)
end
end
end
end

View file

@ -0,0 +1,99 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Maps::HexagonPolygonGenerator do
describe '.call' do
subject(:generate_polygon) do
described_class.call(
center_lng: center_lng,
center_lat: center_lat,
size_meters: size_meters
)
end
let(:center_lng) { -74.0 }
let(:center_lat) { 40.7 }
let(:size_meters) { 1000 }
it 'returns a polygon geometry' do
result = generate_polygon
expect(result['type']).to eq('Polygon')
expect(result['coordinates']).to be_an(Array)
expect(result['coordinates'].length).to eq(1) # One ring
end
it 'generates a hexagon with 7 coordinate pairs (6 vertices + closing)' do
result = generate_polygon
coordinates = result['coordinates'].first
expect(coordinates.length).to eq(7) # 6 vertices + closing vertex
expect(coordinates.first).to eq(coordinates.last) # Closed polygon
end
it 'generates unique vertices' do
result = generate_polygon
coordinates = result['coordinates'].first
# Remove the closing vertex for uniqueness check
unique_vertices = coordinates[0..5]
expect(unique_vertices.uniq.length).to eq(6) # All vertices should be unique
end
it 'generates vertices around the center point' do
result = generate_polygon
coordinates = result['coordinates'].first
# Check that all vertices are different from center
coordinates[0..5].each do |vertex|
lng, lat = vertex
expect(lng).not_to eq(center_lng)
expect(lat).not_to eq(center_lat)
end
end
context 'with different size' do
let(:size_meters) { 500 }
it 'generates a smaller hexagon' do
small_result = generate_polygon
large_result = described_class.call(
center_lng: center_lng,
center_lat: center_lat,
size_meters: 2000
)
# Small hexagon should have vertices closer to center than large hexagon
small_distance = calculate_distance_from_center(small_result['coordinates'].first.first)
large_distance = calculate_distance_from_center(large_result['coordinates'].first.first)
expect(small_distance).to be < large_distance
end
end
context 'with different center coordinates' do
let(:center_lng) { 13.4 } # Berlin
let(:center_lat) { 52.5 }
it 'generates hexagon around the new center' do
result = generate_polygon
coordinates = result[:coordinates].first
# Check that vertices are around the Berlin coordinates
avg_lng = coordinates[0..5].sum { |vertex| vertex[0] } / 6
avg_lat = coordinates[0..5].sum { |vertex| vertex[1] } / 6
expect(avg_lng).to be_within(0.01).of(center_lng)
expect(avg_lat).to be_within(0.01).of(center_lat)
end
end
private
def calculate_distance_from_center(vertex)
lng, lat = vertex
Math.sqrt((lng - center_lng)**2 + (lat - center_lat)**2)
end
end
end

View file

@ -0,0 +1,175 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Maps::HexagonRequestHandler do
describe '.call' do
subject(:handle_request) do
described_class.call(
params: params,
current_api_user: current_api_user
)
end
let(:user) { create(:user) }
let(:current_api_user) { user }
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
end
context 'with authenticated user and bounding box params' do
let(:params) do
ActionController::Parameters.new({
min_lon: -74.1,
min_lat: 40.6,
max_lon: -73.9,
max_lat: 40.8,
hex_size: 1000,
start_date: '2024-06-01T00:00:00Z',
end_date: '2024-06-30T23:59:59Z'
})
end
before do
# Create test points within the date range and bounding box
10.times do |i|
create(:point,
user:,
latitude: 40.7 + (i * 0.001),
longitude: -74.0 + (i * 0.001),
timestamp: Time.new(2024, 6, 15, 12, i).to_i)
end
end
it 'returns on-the-fly hexagon calculation' do
result = handle_request
expect(result).to be_a(Hash)
expect(result['type']).to eq('FeatureCollection')
expect(result['features']).to be_an(Array)
expect(result['metadata']).to be_present
end
end
context 'with public sharing UUID and pre-calculated centers' do
let(:pre_calculated_centers) do
[
[-74.0, 40.7, 1_717_200_000, 1_717_203_600],
[-74.01, 40.71, 1_717_210_000, 1_717_213_600]
]
end
let(:stat) do
create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6,
hexagon_centers: pre_calculated_centers)
end
let(:params) do
ActionController::Parameters.new({
uuid: stat.sharing_uuid,
min_lon: -74.1,
min_lat: 40.6,
max_lon: -73.9,
max_lat: 40.8,
hex_size: 1000
})
end
let(:current_api_user) { nil }
it 'returns pre-calculated hexagon data' do
result = handle_request
expect(result['type']).to eq('FeatureCollection')
expect(result['features'].length).to eq(2)
expect(result['metadata']['pre_calculated']).to be true
expect(result['metadata']['user_id']).to eq(user.id)
end
end
context 'with public sharing UUID but no pre-calculated centers' do
let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) }
let(:params) do
ActionController::Parameters.new({
uuid: stat.sharing_uuid,
min_lon: -74.1,
min_lat: 40.6,
max_lon: -73.9,
max_lat: 40.8,
hex_size: 1000
})
end
let(:current_api_user) { nil }
before do
# Create test points for the stat's month
5.times do |i|
create(:point,
user:,
latitude: 40.7 + (i * 0.001),
longitude: -74.0 + (i * 0.001),
timestamp: Time.new(2024, 6, 15, 12, i).to_i)
end
end
it 'falls back to on-the-fly calculation' do
result = handle_request
expect(result['type']).to eq('FeatureCollection')
expect(result['features']).to be_an(Array)
expect(result['metadata']).to be_present
expect(result['metadata']['pre_calculated']).to be_falsy
end
end
context 'with legacy area_too_large that can be recalculated' do
let(:stat) do
create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6,
hexagon_centers: { 'area_too_large' => true })
end
let(:params) do
ActionController::Parameters.new({
uuid: stat.sharing_uuid,
min_lon: -74.1,
min_lat: 40.6,
max_lon: -73.9,
max_lat: 40.8,
hex_size: 1000
})
end
let(:current_api_user) { nil }
before do
# Mock successful recalculation
allow_any_instance_of(Stats::CalculateMonth).to receive(:calculate_hexagon_centers)
.and_return([[-74.0, 40.7, 1_717_200_000, 1_717_203_600]])
end
it 'recalculates and returns pre-calculated data' do
expect(stat).to receive(:update).with(
hexagon_centers: [[-74.0, 40.7, 1_717_200_000, 1_717_203_600]]
)
result = handle_request
expect(result['type']).to eq('FeatureCollection')
expect(result['features'].length).to eq(1)
expect(result['metadata']['pre_calculated']).to be true
end
end
context 'error handling' do
let(:params) do
ActionController::Parameters.new({
uuid: 'invalid-uuid'
})
end
let(:current_api_user) { nil }
it 'raises SharedStatsNotFoundError for invalid UUID' do
expect { handle_request }.to raise_error(
Maps::HexagonContextResolver::SharedStatsNotFoundError
)
end
end
end
end