Extract hexagon query to separate class

This commit is contained in:
Eugene Burmakin
2025-09-12 21:38:25 +02:00
parent 57ecda2b1b
commit 88e9c85766
10 changed files with 479 additions and 288 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -111,5 +111,4 @@ RSpec.describe '/stats', type: :request do
end
end
end
end

View File

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