diff --git a/app/javascript/maps/hexagon_grid.js b/app/javascript/maps/hexagon_grid.js index 03b32ad2..87c2be93 100644 --- a/app/javascript/maps/hexagon_grid.js +++ b/app/javascript/maps/hexagon_grid.js @@ -285,18 +285,7 @@ export class HexagonGrid { // Calculate opacity based on point density (0.2 to 0.8) const opacity = 0.2 + (pointCount / maxPoints) * 0.6; - // Calculate color based on density let color = '#3388ff' - // let color = '#3388ff'; // Default blue - // if (pointCount > maxPoints * 0.7) { - // color = '#d73027'; // High density - red - // } else if (pointCount > maxPoints * 0.4) { - // color = '#fc8d59'; // Medium-high density - orange - // } else if (pointCount > maxPoints * 0.2) { - // color = '#fee08b'; // Medium density - yellow - // } else { - // color = '#91bfdb'; // Low density - light blue - // } return { fillColor: color, diff --git a/app/queries/hexagon_query.rb b/app/queries/hexagon_query.rb new file mode 100644 index 00000000..0acb6e1e --- /dev/null +++ b/app/queries/hexagon_query.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +class HexagonQuery + # Maximum number of hexagons to return in a single request + MAX_HEXAGONS_PER_REQUEST = 5000 + + attr_reader :min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :user_id, :start_date, :end_date + + def initialize(min_lon:, min_lat:, max_lon:, max_lat:, hex_size:, user_id: nil, start_date: nil, end_date: nil) + @min_lon = min_lon + @min_lat = min_lat + @max_lon = max_lon + @max_lat = max_lat + @hex_size = hex_size + @user_id = user_id + @start_date = start_date + @end_date = end_date + end + + def call + ActiveRecord::Base.connection.execute(build_hexagon_sql) + end + + private + + def build_hexagon_sql + user_filter = user_id ? "user_id = #{user_id}" : '1=1' + date_filter = build_date_filter + + <<~SQL + WITH bbox_geom AS ( + SELECT ST_MakeEnvelope(#{min_lon}, #{min_lat}, #{max_lon}, #{max_lat}, 4326) as geom + ), + bbox_utm AS ( + SELECT + ST_Transform(geom, 3857) as geom_utm, + geom as geom_wgs84 + FROM bbox_geom + ), + user_points AS ( + SELECT + lonlat::geometry as point_geom, + ST_Transform(lonlat::geometry, 3857) as point_geom_utm, + id, + timestamp + FROM points + WHERE #{user_filter} + #{date_filter} + AND ST_Intersects( + lonlat::geometry, + (SELECT geom FROM bbox_geom) + ) + ), + hex_grid AS ( + SELECT + (ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).geom as hex_geom_utm, + (ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).i as hex_i, + (ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).j as hex_j + FROM bbox_utm + ), + hexagons_with_points AS ( + SELECT DISTINCT + hex_geom_utm, + hex_i, + hex_j + FROM hex_grid hg + INNER JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm) + ), + hexagon_stats AS ( + SELECT + hwp.hex_geom_utm, + hwp.hex_i, + hwp.hex_j, + COUNT(up.id) as point_count, + MIN(up.timestamp) as earliest_point, + MAX(up.timestamp) as latest_point + FROM hexagons_with_points hwp + INNER JOIN user_points up ON ST_Intersects(hwp.hex_geom_utm, up.point_geom_utm) + GROUP BY hwp.hex_geom_utm, hwp.hex_i, hwp.hex_j + ) + SELECT + ST_AsGeoJSON(ST_Transform(hex_geom_utm, 4326)) as geojson, + hex_i, + hex_j, + point_count, + earliest_point, + latest_point, + row_number() OVER (ORDER BY point_count DESC) as id + FROM hexagon_stats + ORDER BY point_count DESC + LIMIT #{MAX_HEXAGONS_PER_REQUEST}; + SQL + end + + def build_date_filter + return '' unless start_date || end_date + + conditions = [] + conditions << "timestamp >= EXTRACT(EPOCH FROM '#{start_date}'::timestamp)" if start_date + conditions << "timestamp <= EXTRACT(EPOCH FROM '#{end_date}'::timestamp)" if end_date + + conditions.any? ? "AND #{conditions.join(' AND ')}" : '' + end +end \ No newline at end of file diff --git a/app/services/maps/hexagon_grid.rb b/app/services/maps/hexagon_grid.rb index 78e8fc05..5d5ede5d 100644 --- a/app/services/maps/hexagon_grid.rb +++ b/app/services/maps/hexagon_grid.rb @@ -5,8 +5,6 @@ class Maps::HexagonGrid # Constants for configuration DEFAULT_HEX_SIZE = 500 # meters (center to edge) - TARGET_HEX_EDGE_PX = 20 # pixels (edge length target) - MAX_HEXAGONS_PER_REQUEST = 5000 MAX_AREA_KM2 = 250_000 # 500km x 500km # Validation error classes @@ -29,9 +27,9 @@ class Maps::HexagonGrid @min_lat = params[:min_lat].to_f @max_lon = params[:max_lon].to_f @max_lat = params[:max_lat].to_f + @hex_size = params[:hex_size]&.to_f || DEFAULT_HEX_SIZE @viewport_width = params[:viewport_width]&.to_f @viewport_height = params[:viewport_height]&.to_f - @hex_size = calculate_dynamic_hex_size(params) @user_id = params[:user_id] @start_date = params[:start_date] @end_date = params[:end_date] @@ -39,61 +37,12 @@ class Maps::HexagonGrid def call validate! + generate_hexagons end - def area_km2 - @area_km2 ||= calculate_area_km2 - end - - def crosses_dateline? - min_lon > max_lon - end - - def in_polar_region? - max_lat.abs > 85 || min_lat.abs > 85 - end - - def estimated_hexagon_count - # Rough estimation based on area - # A 500m radius hexagon covers approximately 0.65 km² - hexagon_area_km2 = 0.65 * (hex_size / 500.0)**2 - (area_km2 / hexagon_area_km2).round - end - private - def calculate_dynamic_hex_size(params) - # If viewport dimensions are provided, calculate hex_size for 20px edge length - if viewport_width && viewport_height && viewport_width > 0 && viewport_height > 0 - # Calculate the geographic width of the bounding box in meters - avg_lat = (min_lat + max_lat) / 2 - bbox_width_degrees = (max_lon - min_lon).abs - bbox_width_meters = bbox_width_degrees * 111_320 * Math.cos(avg_lat * Math::PI / 180) - - # Calculate how many meters per pixel based on current viewport span (zoom-independent) - meters_per_pixel = bbox_width_meters / viewport_width - - # For a regular hexagon, the edge length is approximately 0.866 times the radius (center to vertex) - # So if we want a 20px edge, we need: edge_length_meters = 20 * meters_per_pixel - # And radius = edge_length / 0.866 - edge_length_meters = TARGET_HEX_EDGE_PX * meters_per_pixel - hex_radius_meters = edge_length_meters / 0.866 - - # Clamp to reasonable bounds to prevent excessive computation - calculated_size = hex_radius_meters.clamp(50, 10_000) - - Rails.logger.debug "Dynamic hex size calculation: bbox_width=#{bbox_width_meters.round}m, viewport=#{viewport_width}px, meters_per_pixel=#{meters_per_pixel.round(2)}, hex_size=#{calculated_size.round}m" - - calculated_size - else - # Fallback to provided hex_size or default - fallback_size = params[:hex_size]&.to_f || DEFAULT_HEX_SIZE - Rails.logger.debug "Using fallback hex size: #{fallback_size}m (no viewport dimensions provided)" - fallback_size - end - end - def validate_bbox_order errors.add(:base, 'min_lon must be less than max_lon') if min_lon >= max_lon errors.add(:base, 'min_lat must be less than max_lat') if min_lat >= max_lat @@ -105,114 +54,20 @@ class Maps::HexagonGrid errors.add(:base, "Area too large (#{area_km2.round} km²). Maximum allowed: #{MAX_AREA_KM2} km²") end - def calculate_area_km2 - width = (max_lon - min_lon).abs - height = (max_lat - min_lat).abs - - # Convert degrees to approximate kilometers - # 1 degree latitude ≈ 111 km - # 1 degree longitude ≈ 111 km * cos(latitude) - avg_lat = (min_lat + max_lat) / 2 - width_km = width * 111 * Math.cos(avg_lat * Math::PI / 180) - height_km = height * 111 - - width_km * height_km - end - def generate_hexagons - sql = build_hexagon_sql + query = HexagonQuery.new( + min_lon:, min_lat:, max_lon:, max_lat:, + hex_size:, user_id:, start_date:, end_date: + ) - Rails.logger.debug "Generating hexagons for bbox: #{[min_lon, min_lat, max_lon, max_lat]}" - Rails.logger.debug "Estimated hexagon count: #{estimated_hexagon_count}" + result = query.call - result = execute_sql(sql) format_hexagons(result) rescue ActiveRecord::StatementInvalid => e - Rails.logger.error "PostGIS error generating hexagons: #{e.message}" - raise PostGISError, "Failed to generate hexagon grid: #{e.message}" - end + message = "Failed to generate hexagon grid: #{e.message}" - def build_hexagon_sql - user_filter = user_id ? "user_id = #{user_id}" : '1=1' - date_filter = build_date_filter - - <<~SQL - WITH bbox_geom AS ( - SELECT ST_MakeEnvelope(#{min_lon}, #{min_lat}, #{max_lon}, #{max_lat}, 4326) as geom - ), - bbox_utm AS ( - SELECT - ST_Transform(geom, 3857) as geom_utm, - geom as geom_wgs84 - FROM bbox_geom - ), - user_points AS ( - SELECT - lonlat::geometry as point_geom, - ST_Transform(lonlat::geometry, 3857) as point_geom_utm, - id, - timestamp - FROM points - WHERE #{user_filter} - #{date_filter} - AND ST_Intersects( - lonlat::geometry, - (SELECT geom FROM bbox_geom) - ) - ), - hex_grid AS ( - SELECT - (ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).geom as hex_geom_utm, - (ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).i as hex_i, - (ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).j as hex_j - FROM bbox_utm - ), - hexagons_with_points AS ( - SELECT DISTINCT - hex_geom_utm, - hex_i, - hex_j - FROM hex_grid hg - INNER JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm) - ), - hexagon_stats AS ( - SELECT - hwp.hex_geom_utm, - hwp.hex_i, - hwp.hex_j, - COUNT(up.id) as point_count, - MIN(up.timestamp) as earliest_point, - MAX(up.timestamp) as latest_point - FROM hexagons_with_points hwp - INNER JOIN user_points up ON ST_Intersects(hwp.hex_geom_utm, up.point_geom_utm) - GROUP BY hwp.hex_geom_utm, hwp.hex_i, hwp.hex_j - ) - SELECT - ST_AsGeoJSON(ST_Transform(hex_geom_utm, 4326)) as geojson, - hex_i, - hex_j, - point_count, - earliest_point, - latest_point, - row_number() OVER (ORDER BY point_count DESC) as id - FROM hexagon_stats - ORDER BY point_count DESC - LIMIT #{MAX_HEXAGONS_PER_REQUEST}; - SQL - end - - def build_date_filter - return '' unless start_date || end_date - - conditions = [] - conditions << "timestamp >= EXTRACT(EPOCH FROM '#{start_date}'::timestamp)" if start_date - conditions << "timestamp <= EXTRACT(EPOCH FROM '#{end_date}'::timestamp)" if end_date - - conditions.any? ? "AND #{conditions.join(' AND ')}" : '' - end - - def execute_sql(sql) - ActiveRecord::Base.connection.execute(sql) + ExceptionReporter.call(e, message) + raise PostGISError, message end def format_hexagons(result) @@ -223,8 +78,8 @@ class Maps::HexagonGrid total_points += point_count # Parse timestamps and format dates - earliest = row['earliest_point'] ? Time.at(row['earliest_point'].to_f).iso8601 : nil - latest = row['latest_point'] ? Time.at(row['latest_point'].to_f).iso8601 : nil + earliest = row['earliest_point'] ? Time.zone.at(row['earliest_point'].to_f).iso8601 : nil + latest = row['latest_point'] ? Time.zone.at(row['latest_point'].to_f).iso8601 : nil { type: 'Feature', @@ -237,14 +92,11 @@ class Maps::HexagonGrid hex_size: hex_size, point_count: point_count, earliest_point: earliest, - latest_point: latest, - density: calculate_density(point_count) + latest_point: latest } } end - Rails.logger.info "Generated #{hexagons.count} hexagons containing #{total_points} points for area #{area_km2.round(2)} km²" - { type: 'FeatureCollection', features: hexagons, @@ -260,20 +112,10 @@ class Maps::HexagonGrid } end - def calculate_density(point_count) - # Calculate points per km² for the hexagon - # A hexagon with radius 500m has area ≈ 0.65 km² - hexagon_area_km2 = 0.65 * (hex_size / 500.0)**2 - (point_count / hexagon_area_km2).round(2) - end - def build_date_range_metadata return nil unless start_date || end_date - { - start_date: start_date, - end_date: end_date - } + { start_date:, end_date: } end def validate! @@ -283,4 +125,11 @@ class Maps::HexagonGrid raise InvalidCoordinatesError, errors.full_messages.join(', ') end + + def viewport_valid? + viewport_width && + viewport_height && + viewport_width.positive? && + viewport_height.positive? + end end diff --git a/spec/factories/stats.rb b/spec/factories/stats.rb index c0f62d73..724ddbfa 100644 --- a/spec/factories/stats.rb +++ b/spec/factories/stats.rb @@ -20,7 +20,7 @@ FactoryBot.define do end trait :with_sharing_enabled do - after(:create) do |stat, evaluator| + after(:create) do |stat, _evaluator| stat.enable_sharing!(expiration: 'permanent') end end diff --git a/spec/models/stat_spec.rb b/spec/models/stat_spec.rb index 5bda16d5..e6a5cadb 100644 --- a/spec/models/stat_spec.rb +++ b/spec/models/stat_spec.rb @@ -106,36 +106,36 @@ RSpec.describe Stat, type: :model do describe '#calculate_data_bounds' do let(:stat) { create(:stat, year: 2024, month: 6, user:) } let(:user) { create(:user) } - + context 'when stat has points' do before do # Create test points within the month (June 2024) - create(:point, - user:, - latitude: 40.6, - longitude: -74.1, + 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, + create(:point, + user:, + latitude: 40.8, + longitude: -73.9, timestamp: Time.new(2024, 6, 15, 15, 0).to_i) - create(:point, - user:, - latitude: 40.7, - longitude: -74.0, + create(:point, + user:, + latitude: 40.7, + longitude: -74.0, timestamp: Time.new(2024, 6, 30, 18, 0).to_i) - + # Points outside the month (should be ignored) - create(:point, - user:, - latitude: 41.0, - longitude: -75.0, + create(:point, + user:, + latitude: 41.0, + longitude: -75.0, timestamp: Time.new(2024, 5, 31, 23, 59).to_i) # May - create(:point, - user:, - latitude: 39.0, - longitude: -72.0, + create(:point, + user:, + latitude: 39.0, + longitude: -72.0, timestamp: Time.new(2024, 7, 1, 0, 1).to_i) # July end @@ -155,10 +155,10 @@ RSpec.describe Stat, type: :model do before do # Add points from a different user (should be ignored) - create(:point, - user: other_user, - latitude: 50.0, - longitude: -80.0, + create(:point, + user: other_user, + latitude: 50.0, + longitude: -80.0, timestamp: Time.new(2024, 6, 15, 12, 0).to_i) end @@ -178,10 +178,10 @@ RSpec.describe Stat, type: :model do let(:single_point_stat) { create(:stat, year: 2024, month: 7, user: single_point_user) } before do - create(:point, - user: single_point_user, - latitude: 45.5, - longitude: -122.65, + create(:point, + user: single_point_user, + latitude: 45.5, + longitude: -122.65, timestamp: Time.new(2024, 7, 15, 14, 30).to_i) end @@ -202,13 +202,13 @@ RSpec.describe Stat, type: :model do before do # Test with extreme coordinate values - create(:point, - user: edge_user, + create(:point, + user: edge_user, latitude: -90.0, # South Pole longitude: -180.0, # Date Line West timestamp: Time.new(2024, 8, 1, 0, 0).to_i) - create(:point, - user: edge_user, + create(:point, + user: edge_user, latitude: 90.0, # North Pole longitude: 180.0, # Date Line East timestamp: Time.new(2024, 8, 31, 23, 59).to_i) @@ -243,15 +243,15 @@ RSpec.describe Stat, type: :model do before do # Create points outside the target month - create(:point, - user: empty_month_user, - latitude: 40.7, - longitude: -74.0, + create(:point, + user: empty_month_user, + latitude: 40.7, + longitude: -74.0, timestamp: Time.new(2024, 8, 31, 23, 59).to_i) # August - create(:point, - user: empty_month_user, - latitude: 40.8, - longitude: -73.9, + create(:point, + user: empty_month_user, + latitude: 40.8, + longitude: -73.9, timestamp: Time.new(2024, 10, 1, 0, 1).to_i) # October end diff --git a/spec/queries/hexagon_query_spec.rb b/spec/queries/hexagon_query_spec.rb new file mode 100644 index 00000000..b9bf8183 --- /dev/null +++ b/spec/queries/hexagon_query_spec.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe HexagonQuery, type: :query do + let(:user) { create(:user) } + let(:min_lon) { -74.1 } + let(:min_lat) { 40.6 } + let(:max_lon) { -73.9 } + let(:max_lat) { 40.8 } + let(:hex_size) { 500 } + + describe '#initialize' do + it 'sets required parameters' do + query = described_class.new( + min_lon: min_lon, + min_lat: min_lat, + max_lon: max_lon, + max_lat: max_lat, + hex_size: hex_size + ) + + expect(query.min_lon).to eq(min_lon) + expect(query.min_lat).to eq(min_lat) + expect(query.max_lon).to eq(max_lon) + expect(query.max_lat).to eq(max_lat) + expect(query.hex_size).to eq(hex_size) + end + + it 'sets optional parameters' do + start_date = '2024-06-01T00:00:00Z' + end_date = '2024-06-30T23:59:59Z' + + query = described_class.new( + min_lon: min_lon, + min_lat: min_lat, + max_lon: max_lon, + max_lat: max_lat, + hex_size: hex_size, + user_id: user.id, + start_date: start_date, + end_date: end_date + ) + + expect(query.user_id).to eq(user.id) + expect(query.start_date).to eq(start_date) + expect(query.end_date).to eq(end_date) + end + end + + describe '#call' do + let(:query) do + described_class.new( + min_lon: min_lon, + min_lat: min_lat, + max_lon: max_lon, + max_lat: max_lat, + hex_size: hex_size, + user_id: user.id + ) + end + + context 'with no points' do + it 'executes without error and returns empty result' do + result = query.call + expect(result.to_a).to be_empty + end + end + + context 'with points in bounding box' do + before do + # Create test points within the bounding box + create(:point, + user:, + latitude: 40.7, + longitude: -74.0, + timestamp: Time.new(2024, 6, 15, 12, 0).to_i) + create(:point, + user:, + latitude: 40.75, + longitude: -73.95, + timestamp: Time.new(2024, 6, 16, 14, 0).to_i) + end + + it 'returns hexagon results with expected structure' do + result = query.call + result_array = result.to_a + + expect(result_array).not_to be_empty + + first_hex = result_array.first + expect(first_hex).to have_key('geojson') + expect(first_hex).to have_key('hex_i') + expect(first_hex).to have_key('hex_j') + expect(first_hex).to have_key('point_count') + expect(first_hex).to have_key('earliest_point') + expect(first_hex).to have_key('latest_point') + expect(first_hex).to have_key('id') + + # Verify geojson can be parsed + geojson = JSON.parse(first_hex['geojson']) + expect(geojson).to have_key('type') + expect(geojson).to have_key('coordinates') + end + + it 'filters by user_id correctly' do + other_user = create(:user) + # Create points for a different user (should be excluded) + create(:point, + user: other_user, + latitude: 40.72, + longitude: -73.98, + timestamp: Time.new(2024, 6, 17, 16, 0).to_i) + + result = query.call + result_array = result.to_a + + # Should only include hexagons with the specified user's points + total_points = result_array.sum { |row| row['point_count'].to_i } + expect(total_points).to eq(2) # Only the 2 points from our user + end + end + + context 'with date filtering' do + let(:query_with_dates) do + described_class.new( + min_lon: min_lon, + min_lat: min_lat, + max_lon: max_lon, + max_lat: max_lat, + hex_size: hex_size, + user_id: user.id, + start_date: '2024-06-15T00:00:00Z', + end_date: '2024-06-16T23:59:59Z' + ) + end + + before do + # Create points within and outside the date range + create(:point, + user:, + latitude: 40.7, + longitude: -74.0, + timestamp: Time.new(2024, 6, 15, 12, 0).to_i) # Within range + create(:point, + user:, + latitude: 40.71, + longitude: -74.01, + timestamp: Time.new(2024, 6, 20, 12, 0).to_i) # Outside range + end + + it 'filters points by date range' do + result = query_with_dates.call + result_array = result.to_a + + expect(result_array).not_to be_empty + + # Should only include the point within the date range + total_points = result_array.sum { |row| row['point_count'].to_i } + expect(total_points).to eq(1) + end + end + + context 'without user_id filter' do + let(:query_no_user) do + described_class.new( + min_lon: min_lon, + min_lat: min_lat, + max_lon: max_lon, + max_lat: max_lat, + hex_size: hex_size + ) + end + + before do + user1 = create(:user) + user2 = create(:user) + + create(:point, user: user1, latitude: 40.7, longitude: -74.0, timestamp: Time.current.to_i) + create(:point, user: user2, latitude: 40.75, longitude: -73.95, timestamp: Time.current.to_i) + end + + it 'includes points from all users' do + result = query_no_user.call + result_array = result.to_a + + expect(result_array).not_to be_empty + + # Should include points from both users + total_points = result_array.sum { |row| row['point_count'].to_i } + expect(total_points).to eq(2) + end + end + end + + describe '#build_date_filter (private method behavior)' do + context 'when testing date filter behavior through query execution' do + it 'works correctly with start_date only' do + query = described_class.new( + min_lon: min_lon, + min_lat: min_lat, + max_lon: max_lon, + max_lat: max_lat, + hex_size: hex_size, + user_id: user.id, + start_date: '2024-06-15T00:00:00Z' + ) + + # Should execute without SQL syntax errors + expect { query.call }.not_to raise_error + end + + it 'works correctly with end_date only' do + query = described_class.new( + min_lon: min_lon, + min_lat: min_lat, + max_lon: max_lon, + max_lat: max_lat, + hex_size: hex_size, + user_id: user.id, + end_date: '2024-06-30T23:59:59Z' + ) + + # Should execute without SQL syntax errors + expect { query.call }.not_to raise_error + end + + it 'works correctly with both start_date and end_date' do + query = described_class.new( + min_lon: min_lon, + min_lat: min_lat, + max_lon: max_lon, + max_lat: max_lat, + hex_size: hex_size, + user_id: user.id, + start_date: '2024-06-01T00:00:00Z', + end_date: '2024-06-30T23:59:59Z' + ) + + # Should execute without SQL syntax errors + expect { query.call }.not_to raise_error + end + end + end +end \ No newline at end of file diff --git a/spec/requests/api/v1/maps/hexagons_spec.rb b/spec/requests/api/v1/maps/hexagons_spec.rb index 7ddd13f2..f3750cf8 100644 --- a/spec/requests/api/v1/maps/hexagons_spec.rb +++ b/spec/requests/api/v1/maps/hexagons_spec.rb @@ -29,8 +29,8 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do before do # Create test points within the date range and bounding box 10.times do |i| - create(:point, - user:, + create(:point, + user:, latitude: 40.7 + (i * 0.001), # Slightly different coordinates longitude: -74.0 + (i * 0.001), timestamp: Time.new(2024, 6, 15, 12, i).to_i) # Different times @@ -41,7 +41,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do get '/api/v1/maps/hexagons', params: valid_params, headers: headers expect(response).to have_http_status(:success) - + json_response = JSON.parse(response.body) expect(json_response).to have_key('type') expect(json_response['type']).to eq('FeatureCollection') @@ -51,11 +51,11 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do it 'requires all bbox parameters' do incomplete_params = valid_params.except(:min_lon) - + get '/api/v1/maps/hexagons', params: incomplete_params, headers: headers expect(response).to have_http_status(:bad_request) - + json_response = JSON.parse(response.body) expect(json_response['error']).to include('Missing required parameters') expect(json_response['error']).to include('min_lon') @@ -63,7 +63,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do it 'handles service validation errors' do invalid_params = valid_params.merge(min_lon: 200) # Invalid longitude - + get '/api/v1/maps/hexagons', params: invalid_params, headers: headers expect(response).to have_http_status(:bad_request) @@ -71,7 +71,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do it 'uses custom hex_size when provided' do custom_params = valid_params.merge(hex_size: 500) - + get '/api/v1/maps/hexagons', params: custom_params, headers: headers expect(response).to have_http_status(:success) @@ -85,9 +85,9 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do before do # Create test points within the stat's month 15.times do |i| - create(:point, - user:, - latitude: 40.7 + (i * 0.002), + create(:point, + user:, + latitude: 40.7 + (i * 0.002), longitude: -74.0 + (i * 0.002), timestamp: Time.new(2024, 6, 20, 10, i).to_i) end @@ -97,7 +97,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do get '/api/v1/maps/hexagons', params: uuid_params expect(response).to have_http_status(:success) - + json_response = JSON.parse(response.body) expect(json_response).to have_key('type') expect(json_response['type']).to eq('FeatureCollection') @@ -107,9 +107,9 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do it 'uses stat date range automatically' do # Points outside the stat's month should not be included 5.times do |i| - create(:point, - user:, - latitude: 40.7 + (i * 0.003), + create(:point, + user:, + latitude: 40.7 + (i * 0.003), longitude: -74.0 + (i * 0.003), timestamp: Time.new(2024, 7, 1, 8, i).to_i) # July points end @@ -122,11 +122,11 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do context 'with invalid sharing UUID' do it 'returns not found' do invalid_uuid_params = valid_params.merge(uuid: 'invalid-uuid') - + get '/api/v1/maps/hexagons', params: invalid_uuid_params expect(response).to have_http_status(:not_found) - + json_response = JSON.parse(response.body) expect(json_response['error']).to eq('Shared stats not found or no longer available') end @@ -139,7 +139,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do get '/api/v1/maps/hexagons', params: uuid_params expect(response).to have_http_status(:not_found) - + json_response = JSON.parse(response.body) expect(json_response['error']).to eq('Shared stats not found or no longer available') end @@ -152,7 +152,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do get '/api/v1/maps/hexagons', params: uuid_params expect(response).to have_http_status(:not_found) - + json_response = JSON.parse(response.body) expect(json_response['error']).to eq('Shared stats not found or no longer available') end @@ -199,7 +199,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do get '/api/v1/maps/hexagons/bounds', params: date_params, headers: headers expect(response).to have_http_status(:success) - + json_response = JSON.parse(response.body) expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count') expect(json_response['min_lat']).to eq(40.6) @@ -210,12 +210,12 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do end it 'returns not found when no points exist in date range' do - get '/api/v1/maps/hexagons/bounds', - params: { start_date: '2023-01-01T00:00:00Z', end_date: '2023-01-31T23:59:59Z' }, + get '/api/v1/maps/hexagons/bounds', + params: { start_date: '2023-01-01T00:00:00Z', end_date: '2023-01-31T23:59:59Z' }, headers: headers expect(response).to have_http_status(:not_found) - + json_response = JSON.parse(response.body) expect(json_response['error']).to eq('No data found for the specified date range') expect(json_response['point_count']).to eq(0) @@ -235,7 +235,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do get '/api/v1/maps/hexagons/bounds', params: { uuid: stat.sharing_uuid } expect(response).to have_http_status(:success) - + json_response = JSON.parse(response.body) expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count') expect(json_response['min_lat']).to eq(41.0) @@ -248,7 +248,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do get '/api/v1/maps/hexagons/bounds', params: { uuid: 'invalid-uuid' } expect(response).to have_http_status(:not_found) - + json_response = JSON.parse(response.body) expect(json_response['error']).to eq('Shared stats not found or no longer available') end @@ -257,11 +257,11 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do context 'without authentication' do it 'returns unauthorized' do - get '/api/v1/maps/hexagons/bounds', + get '/api/v1/maps/hexagons/bounds', params: { start_date: '2024-06-01T00:00:00Z', end_date: '2024-06-30T23:59:59Z' } expect(response).to have_http_status(:unauthorized) end end end -end \ No newline at end of file +end diff --git a/spec/requests/shared/stats_spec.rb b/spec/requests/shared/stats_spec.rb index 23d5a131..cf711b54 100644 --- a/spec/requests/shared/stats_spec.rb +++ b/spec/requests/shared/stats_spec.rb @@ -81,31 +81,31 @@ RSpec.describe 'Shared::Stats', type: :request do describe 'PATCH /stats/:year/:month/sharing' do context 'when user is signed in' do let!(:stat_to_share) { create(:stat, user:, year: 2024, month: 6) } - + before { sign_in user } context 'enabling sharing' do it 'enables sharing and returns success' do - patch sharing_stats_path(year: 2024, month: 6), - params: { enabled: '1' }, - as: :json + patch sharing_stats_path(year: 2024, month: 6), + params: { enabled: '1' }, + as: :json expect(response).to have_http_status(:success) - + json_response = JSON.parse(response.body) expect(json_response['success']).to be(true) expect(json_response['sharing_url']).to be_present expect(json_response['message']).to eq('Sharing enabled successfully') - + stat_to_share.reload expect(stat_to_share.sharing_enabled?).to be(true) expect(stat_to_share.sharing_uuid).to be_present end it 'sets custom expiration when provided' do - patch sharing_stats_path(year: 2024, month: 6), - params: { enabled: '1', expiration: '1_week' }, - as: :json + patch sharing_stats_path(year: 2024, month: 6), + params: { enabled: '1', expiration: '1_week' }, + as: :json expect(response).to have_http_status(:success) stat_to_share.reload @@ -117,16 +117,16 @@ RSpec.describe 'Shared::Stats', type: :request do let!(:enabled_stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 7) } it 'disables sharing and returns success' do - patch sharing_stats_path(year: 2024, month: 7), - params: { enabled: '0' }, - as: :json + patch sharing_stats_path(year: 2024, month: 7), + params: { enabled: '0' }, + as: :json expect(response).to have_http_status(:success) - + json_response = JSON.parse(response.body) expect(json_response['success']).to be(true) expect(json_response['message']).to eq('Sharing disabled successfully') - + enabled_stat.reload expect(enabled_stat.sharing_enabled?).to be(false) end @@ -134,9 +134,9 @@ RSpec.describe 'Shared::Stats', type: :request do context 'when stat does not exist' do it 'returns not found' do - patch sharing_stats_path(year: 2024, month: 12), - params: { enabled: '1' }, - as: :json + patch sharing_stats_path(year: 2024, month: 12), + params: { enabled: '1' }, + as: :json expect(response).to have_http_status(:not_found) end @@ -145,13 +145,13 @@ RSpec.describe 'Shared::Stats', type: :request do context 'when user is not signed in' do it 'returns unauthorized' do - patch sharing_stats_path(year: 2024, month: 6), - params: { enabled: '1' }, - as: :json + patch sharing_stats_path(year: 2024, month: 6), + params: { enabled: '1' }, + as: :json expect(response).to have_http_status(:unauthorized) end end end end -end \ No newline at end of file +end diff --git a/spec/requests/stats_spec.rb b/spec/requests/stats_spec.rb index 8c008e85..b6755cb9 100644 --- a/spec/requests/stats_spec.rb +++ b/spec/requests/stats_spec.rb @@ -111,5 +111,4 @@ RSpec.describe '/stats', type: :request do end end end - end diff --git a/spec/services/users/export_import_integration_spec.rb b/spec/services/users/export_import_integration_spec.rb index d30f62fe..2be18ee7 100644 --- a/spec/services/users/export_import_integration_spec.rb +++ b/spec/services/users/export_import_integration_spec.rb @@ -179,19 +179,23 @@ RSpec.describe 'Users Export-Import Integration', type: :service do import_stats = import_service.import # Verify all entities were imported correctly - expect(import_stats[:places_created]).to eq(original_places_count), - "Expected #{original_places_count} places to be created, got #{import_stats[:places_created]}" - expect(import_stats[:visits_created]).to eq(original_visits_count), - "Expected #{original_visits_count} visits to be created, got #{import_stats[:visits_created]}" + expect(import_stats[:places_created]).to \ + eq(original_places_count), + "Expected #{original_places_count} places to be created, got #{import_stats[:places_created]}" + expect(import_stats[:visits_created]).to \ + eq(original_visits_count), + "Expected #{original_visits_count} visits to be created, got #{import_stats[:visits_created]}" # Verify the imported user has access to all their data imported_places_count = import_user.places.distinct.count imported_visits_count = import_user.visits.count - expect(imported_places_count).to eq(original_places_count), - "Expected user to have access to #{original_places_count} places, got #{imported_places_count}" - expect(imported_visits_count).to eq(original_visits_count), - "Expected user to have #{original_visits_count} visits, got #{imported_visits_count}" + expect(imported_places_count).to \ + eq(original_places_count), + "Expected user to have access to #{original_places_count} places, got #{imported_places_count}" + expect(imported_visits_count).to \ + eq(original_visits_count), + "Expected user to have #{original_visits_count} visits, got #{imported_visits_count}" # Verify specific visits have their place associations imported_visits = import_user.visits.includes(:place) @@ -211,12 +215,13 @@ RSpec.describe 'Users Export-Import Integration', type: :service do private def create_full_user_dataset(user) - user.update!(settings: { - 'distance_unit' => 'km', - 'timezone' => 'America/New_York', - 'immich_url' => 'https://immich.example.com', - 'immich_api_key' => 'test-api-key' - }) + user.update!(settings: + { + 'distance_unit' => 'km', + 'timezone' => 'America/New_York', + 'immich_url' => 'https://immich.example.com', + 'immich_api_key' => 'test-api-key' + }) usa = create(:country, name: 'United States', iso_a2: 'US', iso_a3: 'USA') canada = create(:country, name: 'Canada', iso_a2: 'CA', iso_a3: 'CAN')