diff --git a/app/controllers/api/v1/maps/hexagons_controller.rb b/app/controllers/api/v1/maps/hexagons_controller.rb index 58d03c6b..64abb4e3 100644 --- a/app/controllers/api/v1/maps/hexagons_controller.rb +++ b/app/controllers/api/v1/maps/hexagons_controller.rb @@ -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 diff --git a/app/services/maps/bounds_calculator.rb b/app/services/maps/bounds_calculator.rb new file mode 100644 index 00000000..6312fb7c --- /dev/null +++ b/app/services/maps/bounds_calculator.rb @@ -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 diff --git a/app/services/maps/date_parameter_coercer.rb b/app/services/maps/date_parameter_coercer.rb new file mode 100644 index 00000000..0c91e576 --- /dev/null +++ b/app/services/maps/date_parameter_coercer.rb @@ -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 diff --git a/app/services/maps/hexagon_center_manager.rb b/app/services/maps/hexagon_center_manager.rb new file mode 100644 index 00000000..84f47c25 --- /dev/null +++ b/app/services/maps/hexagon_center_manager.rb @@ -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 diff --git a/app/services/maps/hexagon_context_resolver.rb b/app/services/maps/hexagon_context_resolver.rb new file mode 100644 index 00000000..008fa070 --- /dev/null +++ b/app/services/maps/hexagon_context_resolver.rb @@ -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 \ No newline at end of file diff --git a/app/services/maps/hexagon_polygon_generator.rb b/app/services/maps/hexagon_polygon_generator.rb new file mode 100644 index 00000000..9e071661 --- /dev/null +++ b/app/services/maps/hexagon_polygon_generator.rb @@ -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 \ No newline at end of file diff --git a/app/services/maps/hexagon_request_handler.rb b/app/services/maps/hexagon_request_handler.rb new file mode 100644 index 00000000..1ab5b005 --- /dev/null +++ b/app/services/maps/hexagon_request_handler.rb @@ -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 \ No newline at end of file diff --git a/spec/services/maps/bounds_calculator_spec.rb b/spec/services/maps/bounds_calculator_spec.rb new file mode 100644 index 00000000..a48ec8bb --- /dev/null +++ b/spec/services/maps/bounds_calculator_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/services/maps/date_parameter_coercer_spec.rb b/spec/services/maps/date_parameter_coercer_spec.rb new file mode 100644 index 00000000..107147ae --- /dev/null +++ b/spec/services/maps/date_parameter_coercer_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/services/maps/hexagon_center_manager_spec.rb b/spec/services/maps/hexagon_center_manager_spec.rb new file mode 100644 index 00000000..cb6733d2 --- /dev/null +++ b/spec/services/maps/hexagon_center_manager_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/services/maps/hexagon_context_resolver_spec.rb b/spec/services/maps/hexagon_context_resolver_spec.rb new file mode 100644 index 00000000..916db63b --- /dev/null +++ b/spec/services/maps/hexagon_context_resolver_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/services/maps/hexagon_polygon_generator_spec.rb b/spec/services/maps/hexagon_polygon_generator_spec.rb new file mode 100644 index 00000000..32764487 --- /dev/null +++ b/spec/services/maps/hexagon_polygon_generator_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/services/maps/hexagon_request_handler_spec.rb b/spec/services/maps/hexagon_request_handler_spec.rb new file mode 100644 index 00000000..bc43c294 --- /dev/null +++ b/spec/services/maps/hexagon_request_handler_spec.rb @@ -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 \ No newline at end of file