# frozen_string_literal: true require 'rails_helper' RSpec.describe Points::Create do describe '#call' do let(:user) { create(:user) } let(:timestamp) { Time.current } let(:params_service) { instance_double(Points::Params) } let(:point_params) do { locations: [ { lat: 51.5074, lon: -0.1278, timestamp: timestamp.iso8601 }, { lat: 40.7128, lon: -74.0060, timestamp: (timestamp + 1.hour).iso8601 } ] } end let(:processed_data) do [ { lonlat: 'POINT(-0.1278 51.5074)', timestamp: timestamp, user_id: user.id, created_at: Time.current, updated_at: Time.current }, { lonlat: 'POINT(-74.006 40.7128)', timestamp: timestamp + 1.hour, user_id: user.id, created_at: Time.current, updated_at: Time.current } ] end let(:upsert_result) do [ Point.new(id: 1, lonlat: 'POINT(-0.1278 51.5074)', timestamp: timestamp), Point.new(id: 2, lonlat: 'POINT(-74.006 40.7128)', timestamp: timestamp + 1.hour) ] end describe 'basic point creation' do before do allow(Points::Params).to receive(:new).with(point_params, user.id).and_return(params_service) allow(params_service).to receive(:call).and_return(processed_data) end it 'initializes the params service with correct arguments' do expect(Points::Params).to receive(:new).with(point_params, user.id) described_class.new(user, point_params).call end it 'calls the params service' do expect(params_service).to receive(:call) described_class.new(user, point_params).call end it 'upserts the processed data' do expect(Point).to receive(:upsert_all) .with( processed_data, unique_by: %i[lonlat timestamp user_id], returning: Arel.sql( 'id, timestamp, ST_X(lonlat::geometry) AS longitude, ST_Y(lonlat::geometry) AS latitude' ) ) .and_return(upsert_result) described_class.new(user, point_params).call end it 'returns the upsert result' do allow(Point).to receive(:upsert_all).and_return(upsert_result) result = described_class.new(user, point_params).call expect(result).to eq(upsert_result) end end context 'with duplicate points' do let(:duplicate_point_params) do { locations: [ { lat: 51.5074, lon: -0.1278, timestamp: timestamp.iso8601 }, { lat: 51.5074, lon: -0.1278, timestamp: timestamp.iso8601 }, # Duplicate { lat: 40.7128, lon: -74.0060, timestamp: (timestamp + 1.hour).iso8601 } ] } end let(:duplicate_processed_data) do current_time = Time.current [ { lonlat: 'POINT(-0.1278 51.5074)', timestamp: timestamp, user_id: user.id, created_at: current_time, updated_at: current_time }, { lonlat: 'POINT(-0.1278 51.5074)', # Duplicate timestamp: timestamp, user_id: user.id, created_at: current_time, updated_at: current_time }, { lonlat: 'POINT(-74.006 40.7128)', timestamp: timestamp + 1.hour, user_id: user.id, created_at: current_time, updated_at: current_time } ] end let(:deduplicated_upsert_result) do [ Point.new(id: 1, lonlat: 'POINT(-0.1278 51.5074)', timestamp: timestamp), Point.new(id: 2, lonlat: 'POINT(-74.006 40.7128)', timestamp: timestamp + 1.hour) ] end before do allow_any_instance_of(Points::Params).to receive(:call).and_return(duplicate_processed_data) end describe 'deduplication behavior' do it 'reduces the number of points to unique combinations' do expect(Point).to receive(:upsert_all) do |data, _options| expect(data.size).to eq(2) deduplicated_upsert_result end described_class.new(user, duplicate_point_params).call end it 'preserves the correct lonlat values' do expect(Point).to receive(:upsert_all) do |data, _options| expect(data.map { |d| d[:lonlat] }).to match_array(['POINT(-0.1278 51.5074)', 'POINT(-74.006 40.7128)']) deduplicated_upsert_result end described_class.new(user, duplicate_point_params).call end it 'preserves the correct timestamps' do expect(Point).to receive(:upsert_all) do |data, _options| expect(data.map { |d| d[:timestamp] }).to match_array([timestamp, timestamp + 1.hour]) deduplicated_upsert_result end described_class.new(user, duplicate_point_params).call end it 'maintains the correct user_id for all points' do expect(Point).to receive(:upsert_all) do |data, _options| expect(data.map { |d| d[:user_id] }).to all(eq(user.id)) deduplicated_upsert_result end described_class.new(user, duplicate_point_params).call end it 'uses the correct unique constraint' do expect(Point).to receive(:upsert_all) do |_data, options| expect(options[:unique_by]).to eq(%i[lonlat timestamp user_id]) deduplicated_upsert_result end described_class.new(user, duplicate_point_params).call end it 'uses the correct returning clause' do expect(Point).to receive(:upsert_all) do |_data, options| expect(options[:returning]).to eq( Arel.sql('id, timestamp, ST_X(lonlat::geometry) AS longitude, ST_Y(lonlat::geometry) AS latitude') ) deduplicated_upsert_result end described_class.new(user, duplicate_point_params).call end end describe 'database interaction' do it 'creates only unique points' do expect do described_class.new(user, duplicate_point_params).call end.to change(Point, :count).by(2) end it 'creates points with correct coordinates' do described_class.new(user, duplicate_point_params).call points = Point.order(:timestamp).last(2) expect(points[0].lonlat.x).to be_within(0.0001).of(-0.1278) expect(points[0].lonlat.y).to be_within(0.0001).of(51.5074) expect(points[1].lonlat.x).to be_within(0.0001).of(-74.006) expect(points[1].lonlat.y).to be_within(0.0001).of(40.7128) end end end context 'with large datasets' do let(:many_locations) do 2001.times.map do |i| { lat: 51.5074 + (i * 0.001), lon: -0.1278 - (i * 0.001), timestamp: (timestamp + i.minutes).iso8601 } end end let(:large_params) { { locations: many_locations } } let(:large_processed_data) do many_locations.map.with_index do |loc, i| { lonlat: "POINT(#{loc[:lon]} #{loc[:lat]})", timestamp: timestamp + i.minutes, user_id: user.id, created_at: Time.current, updated_at: Time.current } end end let(:first_batch_result) { 1000.times.map { |i| Point.new(id: i + 1, lonlat: anything, timestamp: anything) } } let(:second_batch_result) do 1000.times.map do |i| Point.new(id: i + 1001, lonlat: anything, timestamp: anything) end end let(:third_batch_result) { [Point.new(id: 2001, lonlat: anything, timestamp: anything)] } let(:combined_results) { first_batch_result + second_batch_result + third_batch_result } before do allow(Points::Params).to receive(:new).with(large_params, user.id).and_return(params_service) allow(params_service).to receive(:call).and_return(large_processed_data) allow(Point).to receive(:upsert_all).exactly(3).times.and_return(first_batch_result, second_batch_result, third_batch_result) end it 'handles batching for large datasets' do result = described_class.new(user, large_params).call expect(result.size).to eq(2001) expect(result).to eq(combined_results) end end context 'with real data insertion' do let(:actual_processed_data) do [ { lonlat: 'POINT(-0.1278 51.5074)', timestamp: timestamp, user_id: user.id, created_at: Time.current, updated_at: Time.current }, { lonlat: 'POINT(-74.006 40.7128)', timestamp: timestamp + 1.hour, user_id: user.id, created_at: Time.current, updated_at: Time.current } ] end before do allow_any_instance_of(Points::Params).to receive(:call).and_return(actual_processed_data) end it 'creates points in the database' do expect do described_class.new(user, point_params).call end.to change(Point, :count).by(2) points = Point.order(:timestamp).last(2) expect(points[0].lonlat.x).to be_within(0.0001).of(-0.1278) expect(points[0].lonlat.y).to be_within(0.0001).of(51.5074) point_time = points[0].timestamp.is_a?(Integer) ? Time.zone.at(points[0].timestamp) : points[0].timestamp expect(point_time).to be_within(1.second).of(timestamp) expect(points[1].lonlat.x).to be_within(0.0001).of(-74.006) expect(points[1].lonlat.y).to be_within(0.0001).of(40.7128) point_time = points[1].timestamp.is_a?(Integer) ? Time.zone.at(points[1].timestamp) : points[1].timestamp expect(point_time).to be_within(1.second).of(timestamp + 1.hour) end end context 'with GeoJSON example data' do let(:geojson_file) { file_fixture('points/geojson_example.json') } let(:geojson_data) { JSON.parse(File.read(geojson_file)) } let(:expected_processed_data) do [ { lonlat: 'POINT(-122.40530871 37.744304130000003)', timestamp: Time.parse('2025-01-17T21:03:01Z'), user_id: user.id, created_at: Time.current, updated_at: Time.current }, { lonlat: 'POINT(-122.40518926999999 37.744513759999997)', timestamp: Time.parse('2025-01-17T21:03:02Z'), user_id: user.id, created_at: Time.current, updated_at: Time.current } ] end let(:all_processed_data) do 6.times.map do |i| if i < 2 expected_processed_data[i] else { lonlat: 'POINT(-122.0 37.0)', timestamp: Time.parse('2025-01-17T21:03:03Z') + i.minutes, user_id: user.id, created_at: Time.current, updated_at: Time.current } end end end let(:expected_results) do all_processed_data.map.with_index do |data, i| expected_time = data[:timestamp].to_i Point.new( id: i + 1, lonlat: data[:lonlat], timestamp: expected_time ) end end before do allow(Points::Params).to receive(:new).with(geojson_data, user.id).and_return(params_service) allow(params_service).to receive(:call).and_return(all_processed_data) allow(Point).to receive(:upsert_all) .with( all_processed_data, unique_by: %i[lonlat timestamp user_id], returning: Arel.sql('id, timestamp, ST_X(lonlat::geometry) AS longitude, ST_Y(lonlat::geometry) AS latitude') ) .and_return(expected_results) end it 'correctly processes real GeoJSON example data' do result = described_class.new(user, geojson_data).call expect(result.size).to eq(6) expect(result).to eq(expected_results) # Compare the x and y coordinates instead of the full point object expect(result[0].lonlat.x).to be_within(0.0001).of(-122.40530871) expect(result[0].lonlat.y).to be_within(0.0001).of(37.744304130000003) # Convert timestamp back to Time for comparison time_obj = Time.zone.at(result[0].timestamp) expected_time = Time.parse('2025-01-17T21:03:01Z') expect(time_obj).to be_within(1.second).of(expected_time) expect(result[1].lonlat.x).to be_within(0.0001).of(-122.40518926999999) expect(result[1].lonlat.y).to be_within(0.0001).of(37.744513759999997) # Convert timestamp back to Time for comparison time_obj = Time.zone.at(result[1].timestamp) expected_time = Time.parse('2025-01-17T21:03:02Z') expect(time_obj).to be_within(1.second).of(expected_time) end end end end