mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
Add bunch of tests and refactor some stuff
This commit is contained in:
parent
ad32abb43c
commit
4a859fb350
29 changed files with 2392 additions and 233 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -6,23 +6,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
|
||||
TODO:
|
||||
|
||||
- Specs for app/controllers/api/v1/visits/possible_places_controller.rb
|
||||
- Specs for app/controllers/api/v1/visits_controller.rb
|
||||
- Move code from app/controllers/api/v1/visits/possible_places_controller.rb to services
|
||||
- Move code from app/jobs/bulk_visits_suggesting_job.rb to services
|
||||
- Specs for app/models/concerns/point_validation.rb
|
||||
- Specs for app/models/place.rb
|
||||
- Specs for app/serializers/api/place_serializer.rb
|
||||
- Specs for app/serializers/api/visit_serializer.rb
|
||||
- ActiveModel::Serializer
|
||||
- Specs for app/services/visits/bulk_update_service.rb
|
||||
- Specs for app/services/visits/creator.rb
|
||||
- Specs for app/services/visits/detector.rb
|
||||
- Specs for app/services/visits/merge_service.rb and rename it probably
|
||||
- Specs for app/services/visits/merger.rb
|
||||
- Specs for app/services/visits/place_finder.rb
|
||||
- Specs for app/services/visits/smart_detect.rb
|
||||
- Specs for app/services/visits/suggest.rb
|
||||
- Remove Stimulus controllers for visits on the Visits page
|
||||
- Revert changes to Visits page
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
class Api::V1::Visits::PossiblePlacesController < ApiController
|
||||
def index
|
||||
visit = current_api_user.visits.find(params[:id])
|
||||
possible_places = visit.suggested_places
|
||||
possible_places = visit.suggested_places.map do |place|
|
||||
Api::PlaceSerializer.new(place).call
|
||||
end
|
||||
|
||||
render json: possible_places
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
|
|
|
|||
|
|
@ -2,44 +2,7 @@
|
|||
|
||||
class Api::V1::VisitsController < ApiController
|
||||
def index
|
||||
# If selection is true, filter by coordinates instead of time
|
||||
if params[:selection] == 'true' && params[:sw_lat].present? && params[:sw_lng].present? && params[:ne_lat].present? && params[:ne_lng].present?
|
||||
sw_lat = params[:sw_lat].to_f
|
||||
sw_lng = params[:sw_lng].to_f
|
||||
ne_lat = params[:ne_lat].to_f
|
||||
ne_lng = params[:ne_lng].to_f
|
||||
|
||||
# Create the PostGIS bounding box polygon
|
||||
bounding_box = "ST_MakeEnvelope(#{sw_lng}, #{sw_lat}, #{ne_lng}, #{ne_lat}, 4326)"
|
||||
|
||||
visits =
|
||||
Visit
|
||||
.includes(:place)
|
||||
.where(user: current_api_user)
|
||||
.joins(:place)
|
||||
.where("ST_Contains(#{bounding_box}, ST_SetSRID(places.lonlat::geometry, 4326))")
|
||||
.order(started_at: :desc)
|
||||
else
|
||||
# Regular time-based filtering
|
||||
start_time = begin
|
||||
Time.zone.parse(params[:start_at])
|
||||
rescue StandardError
|
||||
Time.zone.now.beginning_of_day
|
||||
end
|
||||
end_time = begin
|
||||
Time.zone.parse(params[:end_at])
|
||||
rescue StandardError
|
||||
Time.zone.now.end_of_day
|
||||
end
|
||||
|
||||
visits =
|
||||
Visit
|
||||
.includes(:place)
|
||||
.where(user: current_api_user)
|
||||
.where('started_at >= ? AND ended_at <= ?', start_time, end_time)
|
||||
.order(started_at: :desc)
|
||||
end
|
||||
|
||||
visits = Visits::Finder.new(current_api_user, params).call
|
||||
serialized_visits = visits.map do |visit|
|
||||
Api::VisitSerializer.new(visit).call
|
||||
end
|
||||
|
|
@ -81,7 +44,7 @@ class Api::V1::VisitsController < ApiController
|
|||
end
|
||||
|
||||
def bulk_update
|
||||
service = Visits::BulkUpdateService.new(
|
||||
service = Visits::BulkUpdate.new(
|
||||
current_api_user,
|
||||
params[:visit_ids],
|
||||
params[:status]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,18 @@ export class VisitsManager {
|
|||
constructor(map, apiKey) {
|
||||
this.map = map;
|
||||
this.apiKey = apiKey;
|
||||
|
||||
// Create custom panes for different visit types
|
||||
if (!map.getPane('confirmedVisitsPane')) {
|
||||
map.createPane('confirmedVisitsPane');
|
||||
map.getPane('confirmedVisitsPane').style.zIndex = 450; // Above default overlay pane (400)
|
||||
}
|
||||
|
||||
if (!map.getPane('suggestedVisitsPane')) {
|
||||
map.createPane('suggestedVisitsPane');
|
||||
map.getPane('suggestedVisitsPane').style.zIndex = 430; // Below confirmed visits but above base layers
|
||||
}
|
||||
|
||||
this.visitCircles = L.layerGroup();
|
||||
this.confirmedVisitCircles = L.layerGroup().addTo(map); // Always visible layer for confirmed visits
|
||||
this.currentPopup = null;
|
||||
|
|
|
|||
|
|
@ -10,38 +10,22 @@ class BulkVisitsSuggestingJob < ApplicationJob
|
|||
start_at = start_at.to_datetime
|
||||
end_at = end_at.to_datetime
|
||||
|
||||
time_chunks = time_chunks(start_at:, end_at:)
|
||||
time_chunks = Visits::TimeChunks.new(start_at:, end_at:).call
|
||||
|
||||
users.active.find_each do |user|
|
||||
next if user.tracked_points.empty?
|
||||
|
||||
time_chunks.each do |time_chunk|
|
||||
VisitSuggestingJob.perform_later(
|
||||
user_id: user.id, start_at: time_chunk.first, end_at: time_chunk.last
|
||||
)
|
||||
end
|
||||
schedule_chunked_jobs(user, time_chunks)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def time_chunks(start_at:, end_at:)
|
||||
time_chunks = []
|
||||
|
||||
# First chunk: from start_at to end of that year
|
||||
first_end = start_at.end_of_year
|
||||
time_chunks << (start_at...first_end) if start_at < first_end
|
||||
|
||||
# Full-year chunks
|
||||
current = first_end.beginning_of_year + 1.year # Start from the next full year
|
||||
while current + 1.year <= end_at.beginning_of_year
|
||||
time_chunks << (current...current + 1.year)
|
||||
current += 1.year
|
||||
def schedule_chunked_jobs(user, time_chunks)
|
||||
time_chunks.each do |time_chunk|
|
||||
VisitSuggestingJob.perform_later(
|
||||
user_id: user.id, start_at: time_chunk.first, end_at: time_chunk.last
|
||||
)
|
||||
end
|
||||
|
||||
# Last chunk: from start of the last year to end_at
|
||||
time_chunks << (current...end_at) if current < end_at
|
||||
|
||||
time_chunks
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PointValidation
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ class Api::VisitSerializer
|
|||
|
||||
def call
|
||||
{
|
||||
id: id,
|
||||
area_id: area_id,
|
||||
user_id: user_id,
|
||||
started_at: started_at,
|
||||
ended_at: ended_at,
|
||||
duration: duration,
|
||||
name: name,
|
||||
status: status,
|
||||
id: visit.id,
|
||||
area_id: visit.area_id,
|
||||
user_id: visit.user_id,
|
||||
started_at: visit.started_at,
|
||||
ended_at: visit.ended_at,
|
||||
duration: visit.duration,
|
||||
name: visit.name,
|
||||
status: visit.status,
|
||||
place: {
|
||||
latitude: visit.place&.lat || visit.area&.latitude,
|
||||
longitude: visit.place&.lon || visit.area&.longitude,
|
||||
|
|
@ -26,40 +26,4 @@ class Api::VisitSerializer
|
|||
private
|
||||
|
||||
attr_reader :visit
|
||||
|
||||
def id
|
||||
visit.id
|
||||
end
|
||||
|
||||
def area_id
|
||||
visit.area_id
|
||||
end
|
||||
|
||||
def user_id
|
||||
visit.user_id
|
||||
end
|
||||
|
||||
def started_at
|
||||
visit.started_at
|
||||
end
|
||||
|
||||
def ended_at
|
||||
visit.ended_at
|
||||
end
|
||||
|
||||
def duration
|
||||
visit.duration
|
||||
end
|
||||
|
||||
def name
|
||||
visit.name
|
||||
end
|
||||
|
||||
def status
|
||||
visit.status
|
||||
end
|
||||
|
||||
def place_id
|
||||
visit.place_id
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
class BulkUpdateService
|
||||
class BulkUpdate
|
||||
attr_reader :user, :visit_ids, :status, :errors
|
||||
|
||||
def initialize(user, visit_ids, status)
|
||||
|
|
@ -26,7 +26,7 @@ module Visits
|
|||
return
|
||||
end
|
||||
|
||||
return if %w[confirmed declined].include?(status)
|
||||
return if Visit.statuses.keys.include?(status)
|
||||
|
||||
errors << 'Invalid status'
|
||||
end
|
||||
|
|
@ -39,7 +39,9 @@ module Visits
|
|||
return false
|
||||
end
|
||||
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
updated_count = visits.update_all(status: status)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
{ count: updated_count, visits: visits }
|
||||
end
|
||||
31
app/services/visits/find_in_time.rb
Normal file
31
app/services/visits/find_in_time.rb
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
class FindInTime
|
||||
def initialize(user, params)
|
||||
@user = user
|
||||
@start_at = parse_time(params[:start_at])
|
||||
@end_at = parse_time(params[:end_at])
|
||||
end
|
||||
|
||||
def call
|
||||
Visit
|
||||
.includes(:place)
|
||||
.where(user:)
|
||||
.where('started_at >= ? AND ended_at <= ?', start_at, end_at)
|
||||
.order(started_at: :desc)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :start_at, :end_at
|
||||
|
||||
def parse_time(time_string)
|
||||
parsed_time = Time.zone.parse(time_string)
|
||||
|
||||
raise ArgumentError, "Invalid time format: #{time_string}" if parsed_time.nil?
|
||||
|
||||
parsed_time
|
||||
end
|
||||
end
|
||||
end
|
||||
29
app/services/visits/find_within_bounding_box.rb
Normal file
29
app/services/visits/find_within_bounding_box.rb
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
# Finds visits in a selected area on the map
|
||||
class FindWithinBoundingBox
|
||||
def initialize(user, params)
|
||||
@user = user
|
||||
@sw_lat = params[:sw_lat].to_f
|
||||
@sw_lng = params[:sw_lng].to_f
|
||||
@ne_lat = params[:ne_lat].to_f
|
||||
@ne_lng = params[:ne_lng].to_f
|
||||
end
|
||||
|
||||
def call
|
||||
bounding_box = "ST_MakeEnvelope(#{sw_lng}, #{sw_lat}, #{ne_lng}, #{ne_lat}, 4326)"
|
||||
|
||||
Visit
|
||||
.includes(:place)
|
||||
.where(user:)
|
||||
.joins(:place)
|
||||
.where("ST_Contains(#{bounding_box}, ST_SetSRID(places.lonlat::geometry, 4326))")
|
||||
.order(started_at: :desc)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :sw_lat, :sw_lng, :ne_lat, :ne_lng
|
||||
end
|
||||
end
|
||||
31
app/services/visits/finder.rb
Normal file
31
app/services/visits/finder.rb
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
# Finds visits in a selected area on the map
|
||||
class Finder
|
||||
def initialize(user, params)
|
||||
@user = user
|
||||
@params = params
|
||||
end
|
||||
|
||||
def call
|
||||
if area_selected?
|
||||
Visits::FindWithinBoundingBox.new(user, params).call
|
||||
else
|
||||
Visits::FindInTime.new(user, params).call
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :params
|
||||
|
||||
def area_selected?
|
||||
params[:selection] == 'true' &&
|
||||
params[:sw_lat].present? &&
|
||||
params[:sw_lng].present? &&
|
||||
params[:ne_lat].present? &&
|
||||
params[:ne_lng].present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -3,10 +3,11 @@
|
|||
module Visits
|
||||
# Service to handle merging multiple visits into one
|
||||
class MergeService
|
||||
attr_reader :visits, :errors
|
||||
attr_reader :visits, :errors, :base_visit
|
||||
|
||||
def initialize(visits)
|
||||
@visits = visits
|
||||
@base_visit = visits.first
|
||||
@errors = []
|
||||
end
|
||||
|
||||
|
|
@ -27,36 +28,10 @@ module Visits
|
|||
|
||||
def merge_visits
|
||||
Visit.transaction do
|
||||
# Use the first visit as the base for the merged visit
|
||||
base_visit = visits.first
|
||||
update_base_visit(base_visit, visits)
|
||||
reassign_points(base_visit, visits)
|
||||
|
||||
# Calculate the new start and end times
|
||||
earliest_start = visits.min_by(&:started_at).started_at
|
||||
latest_end = visits.max_by(&:ended_at).ended_at
|
||||
|
||||
# Calculate the total duration (sum of all visit durations)
|
||||
total_duration = ((latest_end - earliest_start) / 60).round
|
||||
|
||||
# Create a combined name
|
||||
combined_name = "Combined Visit (#{earliest_start.strftime('%b %d')} - #{latest_end.strftime('%b %d')})"
|
||||
|
||||
# Update the base visit with the new data
|
||||
base_visit.update!(
|
||||
started_at: earliest_start,
|
||||
ended_at: latest_end,
|
||||
duration: total_duration,
|
||||
name: combined_name,
|
||||
status: 'confirmed' # Set status to confirmed for the merged visit
|
||||
)
|
||||
|
||||
# Move all points from other visits to the base visit
|
||||
visits[1..].each do |visit|
|
||||
# Update points to associate with the base visit
|
||||
visit.points.update_all(visit_id: base_visit.id) # rubocop:disable Rails/SkipsModelValidations
|
||||
|
||||
# Delete the other visit
|
||||
visit.destroy!
|
||||
end
|
||||
visits.drop(1).each(&:destroy!)
|
||||
|
||||
base_visit
|
||||
end
|
||||
|
|
@ -65,5 +40,37 @@ module Visits
|
|||
add_error(e.record.errors.full_messages.join(', '))
|
||||
nil
|
||||
end
|
||||
|
||||
def prepare_base_visit
|
||||
earliest_start = visits.min_by(&:started_at).started_at
|
||||
latest_end = visits.max_by(&:ended_at).ended_at
|
||||
total_duration = ((latest_end - earliest_start) / 60).round
|
||||
combined_name = "Combined Visit (#{visits.map(&:name).join(', ')})"
|
||||
|
||||
{
|
||||
earliest_start:,
|
||||
latest_end:,
|
||||
total_duration:,
|
||||
combined_name:
|
||||
}
|
||||
end
|
||||
|
||||
def update_base_visit(base_visit)
|
||||
base_visit_data = prepare_base_visit
|
||||
|
||||
base_visit.update!(
|
||||
started_at: base_visit_data[:earliest_start],
|
||||
ended_at: base_visit_data[:latest_end],
|
||||
duration: base_visit_data[:total_duration],
|
||||
name: base_visit_data[:combined_name],
|
||||
status: 'confirmed'
|
||||
)
|
||||
end
|
||||
|
||||
def reassign_points(base_visit, visits)
|
||||
visits[1..].each do |visit|
|
||||
visit.points.update_all(visit_id: base_visit.id) # rubocop:disable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class Visits::Suggest
|
|||
|
||||
def call
|
||||
visits = Visits::SmartDetect.new(user, start_at:, end_at:).call
|
||||
# create_visits_notification(user) if visits.any?
|
||||
create_visits_notification(user) if visits.any?
|
||||
|
||||
return nil unless DawarichSettings.reverse_geocoding_enabled?
|
||||
|
||||
|
|
|
|||
47
app/services/visits/time_chunks.rb
Normal file
47
app/services/visits/time_chunks.rb
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
class TimeChunks
|
||||
def initialize(start_at:, end_at:)
|
||||
@start_at = start_at
|
||||
@end_at = end_at
|
||||
@time_chunks = []
|
||||
end
|
||||
|
||||
def call
|
||||
# If the start date is in the future or equal to the end date,
|
||||
# handle as a special case extending to the end of the start's year
|
||||
if start_at >= end_at
|
||||
year_end = start_at.end_of_year
|
||||
return [start_at...year_end]
|
||||
end
|
||||
|
||||
# Special case for dates within the same year
|
||||
if start_at.year == end_at.year
|
||||
year_end = start_at.end_of_year
|
||||
return [start_at...year_end]
|
||||
end
|
||||
|
||||
# First chunk: from start_at to end of that year
|
||||
first_end = start_at.end_of_year
|
||||
time_chunks << (start_at...first_end)
|
||||
|
||||
# Full-year chunks
|
||||
current = first_end.beginning_of_year + 1.year # Start from the next full year
|
||||
while current.year < end_at.year
|
||||
year_end = current.end_of_year
|
||||
time_chunks << (current...year_end)
|
||||
current += 1.year
|
||||
end
|
||||
|
||||
# Last chunk: from start of the last year to end_at
|
||||
time_chunks << (current...end_at) if current.year == end_at.year
|
||||
|
||||
time_chunks
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :start_at, :end_at, :time_chunks
|
||||
end
|
||||
end
|
||||
|
|
@ -6,5 +6,43 @@ FactoryBot.define do
|
|||
latitude { 1.5 }
|
||||
longitude { 1.5 }
|
||||
lonlat { "POINT(#{longitude} #{latitude})" }
|
||||
|
||||
trait :with_geodata do
|
||||
geodata do
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"geometry": {
|
||||
"coordinates": [
|
||||
13.0948638,
|
||||
54.2905245
|
||||
],
|
||||
"type": 'Point'
|
||||
},
|
||||
"type": 'Feature',
|
||||
"properties": {
|
||||
"osm_id": 5_762_449_774,
|
||||
"country": 'Germany',
|
||||
"city": 'Stralsund',
|
||||
"countrycode": 'DE',
|
||||
"postcode": '18439',
|
||||
"locality": 'Frankensiedlung',
|
||||
"county": 'Vorpommern-Rügen',
|
||||
"type": 'house',
|
||||
"osm_type": 'N',
|
||||
"osm_key": 'amenity',
|
||||
"housenumber": '84-85',
|
||||
"street": 'Greifswalder Chaussee',
|
||||
"district": 'Franken',
|
||||
"osm_value": 'restaurant',
|
||||
"name": 'Braugasthaus Zum Alten Fritz',
|
||||
"state": 'Mecklenburg-Vorpommern'
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": 'FeatureCollection'
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
162
spec/models/concerns/point_validation_spec.rb
Normal file
162
spec/models/concerns/point_validation_spec.rb
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe PointValidation do
|
||||
# Create a test class that includes the concern
|
||||
let(:test_class) do
|
||||
Class.new do
|
||||
include PointValidation
|
||||
end
|
||||
end
|
||||
|
||||
let(:validator) { test_class.new }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe '#point_exists?' do
|
||||
context 'with invalid coordinates' do
|
||||
it 'returns false for zero coordinates' do
|
||||
params = { longitude: '0', latitude: '0', timestamp: Time.now.to_i }
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
|
||||
it 'returns false for longitude outside valid range' do
|
||||
params = { longitude: '181', latitude: '45', timestamp: Time.now.to_i }
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
|
||||
params = { longitude: '-181', latitude: '45', timestamp: Time.now.to_i }
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
|
||||
it 'returns false for latitude outside valid range' do
|
||||
params = { longitude: '45', latitude: '91', timestamp: Time.now.to_i }
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
|
||||
params = { longitude: '45', latitude: '-91', timestamp: Time.now.to_i }
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'with valid coordinates' do
|
||||
let(:longitude) { 10.0 }
|
||||
let(:latitude) { 50.0 }
|
||||
let(:timestamp) { Time.now.to_i }
|
||||
let(:params) { { longitude: longitude.to_s, latitude: latitude.to_s, timestamp: timestamp } }
|
||||
|
||||
context 'when point does not exist' do
|
||||
before do
|
||||
allow(Point).to receive(:where).and_return(double(exists?: false))
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
|
||||
it 'queries the database with correct parameters' do
|
||||
expect(Point).to receive(:where).with(
|
||||
'ST_SetSRID(ST_MakePoint(?, ?), 4326) = lonlat AND timestamp = ? AND user_id = ?',
|
||||
longitude, latitude, timestamp, user.id
|
||||
).and_return(double(exists?: false))
|
||||
|
||||
validator.point_exists?(params, user.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when point exists' do
|
||||
before do
|
||||
allow(Point).to receive(:where).and_return(double(exists?: true))
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(validator.point_exists?(params, user.id)).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with string parameters' do
|
||||
it 'converts string coordinates to float values' do
|
||||
params = { longitude: '10.5', latitude: '50.5', timestamp: '1650000000' }
|
||||
|
||||
expect(Point).to receive(:where).with(
|
||||
'ST_SetSRID(ST_MakePoint(?, ?), 4326) = lonlat AND timestamp = ? AND user_id = ?',
|
||||
10.5, 50.5, 1_650_000_000, user.id
|
||||
).and_return(double(exists?: false))
|
||||
|
||||
validator.point_exists?(params, user.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with different boundary values' do
|
||||
it 'accepts maximum valid coordinate values' do
|
||||
params = { longitude: '180', latitude: '90', timestamp: Time.now.to_i }
|
||||
|
||||
expect(Point).to receive(:where).and_return(double(exists?: false))
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
|
||||
it 'accepts minimum valid coordinate values' do
|
||||
params = { longitude: '-180', latitude: '-90', timestamp: Time.now.to_i }
|
||||
|
||||
expect(Point).to receive(:where).and_return(double(exists?: false))
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'with integration tests', :db do
|
||||
# These tests require a database with PostGIS support
|
||||
# Only run them if using real database integration
|
||||
|
||||
let(:existing_timestamp) { 1_650_000_000 }
|
||||
let(:existing_point_params) do
|
||||
{
|
||||
longitude: 10.5,
|
||||
latitude: 50.5,
|
||||
timestamp: existing_timestamp,
|
||||
user_id: user.id
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
# Skip this context if not in integration mode
|
||||
skip 'Skipping integration tests' unless ENV['RUN_INTEGRATION_TESTS']
|
||||
|
||||
# Create a point in the database
|
||||
existing_point = Point.create!(
|
||||
lonlat: "POINT(#{existing_point_params[:longitude]} #{existing_point_params[:latitude]})",
|
||||
timestamp: existing_timestamp,
|
||||
user_id: user.id
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns true when a point with same coordinates and timestamp exists' do
|
||||
params = {
|
||||
longitude: existing_point_params[:longitude].to_s,
|
||||
latitude: existing_point_params[:latitude].to_s,
|
||||
timestamp: existing_timestamp
|
||||
}
|
||||
|
||||
expect(validator.point_exists?(params, user.id)).to be true
|
||||
end
|
||||
|
||||
it 'returns false when a point with different coordinates exists' do
|
||||
params = {
|
||||
longitude: (existing_point_params[:longitude] + 0.1).to_s,
|
||||
latitude: existing_point_params[:latitude].to_s,
|
||||
timestamp: existing_timestamp
|
||||
}
|
||||
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
|
||||
it 'returns false when a point with different timestamp exists' do
|
||||
params = {
|
||||
longitude: existing_point_params[:longitude].to_s,
|
||||
latitude: existing_point_params[:latitude].to_s,
|
||||
timestamp: existing_timestamp + 1
|
||||
}
|
||||
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -19,14 +19,51 @@ RSpec.describe Place, type: :model do
|
|||
end
|
||||
|
||||
describe 'methods' do
|
||||
describe '#async_reverse_geocode' do
|
||||
let(:place) { create(:place) }
|
||||
let(:place) { create(:place, :with_geodata) }
|
||||
|
||||
describe '#async_reverse_geocode' do
|
||||
before { allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) }
|
||||
before { allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) }
|
||||
|
||||
it 'updates address' do
|
||||
expect { place.async_reverse_geocode }.to have_enqueued_job(ReverseGeocodingJob).with('Place', place.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#osm_id' do
|
||||
it 'returns the osm_id' do
|
||||
expect(place.osm_id).to eq(583_204_619)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#osm_key' do
|
||||
it 'returns the osm_key' do
|
||||
expect(place.osm_key).to eq('tourism')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#osm_value' do
|
||||
it 'returns the osm_value' do
|
||||
expect(place.osm_value).to eq('restaurant')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#osm_type' do
|
||||
it 'returns the osm_type' do
|
||||
expect(place.osm_type).to eq('N')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#lon' do
|
||||
it 'returns the longitude' do
|
||||
expect(place.lon).to eq(13.094891305125158)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#lat' do
|
||||
it 'returns the latitude' do
|
||||
expect(place.lat).to eq(54.29058712007127)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,55 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe "Api::V1::Visits::PossiblePlaces", type: :request do
|
||||
describe "GET /index" do
|
||||
pending "add some examples (or delete) #{__FILE__}"
|
||||
RSpec.describe 'Api::V1::Visits::PossiblePlaces', type: :request do
|
||||
let(:user) { create(:user) }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:visit) { create(:visit, user:) }
|
||||
let(:place) { create(:place) }
|
||||
let!(:place_visit) { create(:place_visit, visit:, place:) }
|
||||
let(:other_user) { create(:user) }
|
||||
let(:other_visit) { create(:visit, user: other_user) }
|
||||
|
||||
describe 'GET /api/v1/visits/:id/possible_places' do
|
||||
context 'when visit belongs to the user' do
|
||||
it 'returns a list of suggested places for the visit' do
|
||||
get "/api/v1/visits/#{visit.id}/possible_places", params: { api_key: }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response).to be_an(Array)
|
||||
expect(json_response.size).to eq(1)
|
||||
expect(json_response.first['id']).to eq(place.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when visit does not exist' do
|
||||
it 'returns a not found error' do
|
||||
get '/api/v1/visits/999999/possible_places', headers: { 'Authorization' => "Bearer #{api_key}" }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to eq('Visit not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when visit does not belong to the user' do
|
||||
it 'returns a not found error' do
|
||||
get "/api/v1/visits/#{other_visit.id}/possible_places", params: { api_key: }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to eq('Visit not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no api key is provided' do
|
||||
it 'returns unauthorized error' do
|
||||
get "/api/v1/visits/#{visit.id}/possible_places"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,8 +4,66 @@ require 'rails_helper'
|
|||
|
||||
RSpec.describe 'Api::V1::Visits', type: :request do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
let(:api_key) { user.api_key }
|
||||
let(:place) { create(:place) }
|
||||
let(:other_user) { create(:user) }
|
||||
let(:auth_headers) { { 'Authorization' => "Bearer #{api_key}" } }
|
||||
|
||||
describe 'GET /api/v1/visits' do
|
||||
let!(:visit1) { create(:visit, user: user, place: place, started_at: 2.days.ago, ended_at: 1.day.ago) }
|
||||
let!(:visit2) { create(:visit, user: user, place: place, started_at: 4.days.ago, ended_at: 3.days.ago) }
|
||||
let!(:other_user_visit) { create(:visit, user: other_user, place: place) }
|
||||
|
||||
context 'when requesting time-based visits' do
|
||||
let(:params) do
|
||||
{
|
||||
start_at: 5.days.ago.iso8601,
|
||||
end_at: Time.zone.now.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns visits within the specified time range' do
|
||||
get '/api/v1/visits', params: params, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response.size).to eq(2)
|
||||
expect(json_response.pluck('id')).to include(visit1.id, visit2.id)
|
||||
end
|
||||
|
||||
it 'does not return visits from other users' do
|
||||
get '/api/v1/visits', params: params, headers: auth_headers
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response.pluck('id')).not_to include(other_user_visit.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when requesting area-based visits' do
|
||||
let(:place_inside) { create(:place, latitude: 50.0, longitude: 14.0) }
|
||||
let!(:visit_inside) { create(:visit, user: user, place: place_inside) }
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
selection: 'true',
|
||||
sw_lat: '49.0',
|
||||
sw_lng: '13.0',
|
||||
ne_lat: '51.0',
|
||||
ne_lng: '15.0'
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns visits within the specified area' do
|
||||
get '/api/v1/visits', params: params, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response.pluck('id')).to include(visit_inside.id)
|
||||
expect(json_response.pluck('id')).not_to include(visit1.id, visit2.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/visits/:id' do
|
||||
let(:visit) { create(:visit, user:) }
|
||||
|
||||
|
|
@ -27,13 +85,13 @@ RSpec.describe 'Api::V1::Visits', type: :request do
|
|||
|
||||
context 'with valid parameters' do
|
||||
it 'updates the requested visit' do
|
||||
put api_v1_visit_url(visit, api_key:), params: valid_attributes
|
||||
put "/api/v1/visits/#{visit.id}", params: valid_attributes, headers: auth_headers
|
||||
|
||||
expect(visit.reload.name).to eq('New name')
|
||||
end
|
||||
|
||||
it 'renders a JSON response with the visit' do
|
||||
put api_v1_visit_url(visit, api_key:), params: valid_attributes
|
||||
put "/api/v1/visits/#{visit.id}", params: valid_attributes, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
|
@ -41,10 +99,129 @@ RSpec.describe 'Api::V1::Visits', type: :request do
|
|||
|
||||
context 'with invalid parameters' do
|
||||
it 'renders a JSON response with errors for the visit' do
|
||||
put api_v1_visit_url(visit, api_key:), params: invalid_attributes
|
||||
put "/api/v1/visits/#{visit.id}", params: invalid_attributes, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/visits/merge' do
|
||||
let!(:visit1) { create(:visit, user: user, started_at: 2.days.ago, ended_at: 1.day.ago) }
|
||||
let!(:visit2) { create(:visit, user: user, started_at: 4.days.ago, ended_at: 3.days.ago) }
|
||||
let!(:other_user_visit) { create(:visit, user: other_user) }
|
||||
|
||||
context 'with valid parameters' do
|
||||
let(:valid_merge_params) do
|
||||
{
|
||||
visit_ids: [visit1.id, visit2.id]
|
||||
}
|
||||
end
|
||||
|
||||
it 'merges the specified visits' do
|
||||
# Mock the service to avoid dealing with complex merging logic in the test
|
||||
merge_service = instance_double(Visits::MergeService)
|
||||
merged_visit = create(:visit, user: user)
|
||||
|
||||
expect(Visits::MergeService).to receive(:new).with(kind_of(ActiveRecord::Relation)).and_return(merge_service)
|
||||
expect(merge_service).to receive(:call).and_return(merged_visit)
|
||||
|
||||
post '/api/v1/visits/merge', params: valid_merge_params, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
it 'returns an error when fewer than 2 visits are specified' do
|
||||
post '/api/v1/visits/merge', params: { visit_ids: [visit1.id] }, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to include('At least 2 visits must be selected')
|
||||
end
|
||||
|
||||
it 'returns an error when not all visits are found' do
|
||||
post '/api/v1/visits/merge', params: { visit_ids: [visit1.id, 999_999] }, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to include('not found')
|
||||
end
|
||||
|
||||
it 'returns an error when trying to merge other user visits' do
|
||||
post '/api/v1/visits/merge', params: { visit_ids: [visit1.id, other_user_visit.id] }, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to include('not found')
|
||||
end
|
||||
|
||||
it 'returns an error when the merge fails' do
|
||||
merge_service = instance_double(Visits::MergeService)
|
||||
|
||||
expect(Visits::MergeService).to receive(:new).with(kind_of(ActiveRecord::Relation)).and_return(merge_service)
|
||||
expect(merge_service).to receive(:call).and_return(nil)
|
||||
expect(merge_service).to receive(:errors).and_return(['Failed to merge visits'])
|
||||
|
||||
post '/api/v1/visits/merge', params: { visit_ids: [visit1.id, visit2.id] }, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to include('Failed to merge visits')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/visits/bulk_update' do
|
||||
let!(:visit1) { create(:visit, user: user, status: 'suggested') }
|
||||
let!(:visit2) { create(:visit, user: user, status: 'suggested') }
|
||||
let!(:other_user_visit) { create(:visit, user: other_user, status: 'suggested') }
|
||||
let(:bulk_update_service) { instance_double(Visits::BulkUpdate) }
|
||||
|
||||
context 'with valid parameters' do
|
||||
let(:valid_update_params) do
|
||||
{
|
||||
visit_ids: [visit1.id, visit2.id],
|
||||
status: 'confirmed'
|
||||
}
|
||||
end
|
||||
|
||||
it 'updates the status of specified visits' do
|
||||
expect(Visits::BulkUpdate).to receive(:new)
|
||||
.with(user, kind_of(Array), 'confirmed')
|
||||
.and_return(bulk_update_service)
|
||||
expect(bulk_update_service).to receive(:call).and_return({ count: 2 })
|
||||
|
||||
post '/api/v1/visits/bulk_update', params: valid_update_params, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['updated_count']).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
let(:invalid_update_params) do
|
||||
{
|
||||
visit_ids: [visit1.id, visit2.id],
|
||||
status: 'invalid_status'
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns an error when the update fails' do
|
||||
expect(Visits::BulkUpdate).to receive(:new)
|
||||
.with(user, kind_of(Array), 'invalid_status')
|
||||
.and_return(bulk_update_service)
|
||||
expect(bulk_update_service).to receive(:call).and_return(nil)
|
||||
expect(bulk_update_service).to receive(:errors).and_return(['Invalid status'])
|
||||
|
||||
post '/api/v1/visits/bulk_update', params: invalid_update_params, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to include('Invalid status')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
95
spec/serializers/api/place_serializer_spec.rb
Normal file
95
spec/serializers/api/place_serializer_spec.rb
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Api::PlaceSerializer do
|
||||
describe '#call' do
|
||||
let(:place) do
|
||||
instance_double(
|
||||
Place,
|
||||
id: 123,
|
||||
name: 'Central Park',
|
||||
lon: -73.9665,
|
||||
lat: 40.7812,
|
||||
city: 'New York',
|
||||
country: 'United States',
|
||||
source: 'osm',
|
||||
geodata: { 'amenity' => 'park', 'leisure' => 'park' },
|
||||
reverse_geocoded_at: Time.zone.parse('2023-01-15T12:00:00Z')
|
||||
)
|
||||
end
|
||||
|
||||
subject(:serializer) { described_class.new(place) }
|
||||
|
||||
it 'initializes with a place object' do
|
||||
expect(serializer.instance_variable_get(:@place)).to eq(place)
|
||||
end
|
||||
|
||||
it 'serializes a place into a hash with all attributes' do
|
||||
result = serializer.call
|
||||
|
||||
expect(result).to be_a(Hash)
|
||||
expect(result[:id]).to eq(123)
|
||||
expect(result[:name]).to eq('Central Park')
|
||||
expect(result[:longitude]).to eq(-73.9665)
|
||||
expect(result[:latitude]).to eq(40.7812)
|
||||
expect(result[:city]).to eq('New York')
|
||||
expect(result[:country]).to eq('United States')
|
||||
expect(result[:source]).to eq('osm')
|
||||
expect(result[:geodata]).to eq({ 'amenity' => 'park', 'leisure' => 'park' })
|
||||
expect(result[:reverse_geocoded_at]).to eq(Time.zone.parse('2023-01-15T12:00:00Z'))
|
||||
end
|
||||
|
||||
context 'with nil values' do
|
||||
let(:place_with_nils) do
|
||||
instance_double(
|
||||
Place,
|
||||
id: 456,
|
||||
name: 'Unknown Place',
|
||||
lon: nil,
|
||||
lat: nil,
|
||||
city: nil,
|
||||
country: nil,
|
||||
source: nil,
|
||||
geodata: nil,
|
||||
reverse_geocoded_at: nil
|
||||
)
|
||||
end
|
||||
|
||||
subject(:serializer_with_nils) { described_class.new(place_with_nils) }
|
||||
|
||||
it 'handles nil values correctly' do
|
||||
result = serializer_with_nils.call
|
||||
|
||||
expect(result[:id]).to eq(456)
|
||||
expect(result[:name]).to eq('Unknown Place')
|
||||
expect(result[:longitude]).to be_nil
|
||||
expect(result[:latitude]).to be_nil
|
||||
expect(result[:city]).to be_nil
|
||||
expect(result[:country]).to be_nil
|
||||
expect(result[:source]).to be_nil
|
||||
expect(result[:geodata]).to be_nil
|
||||
expect(result[:reverse_geocoded_at]).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with actual Place model', type: :model do
|
||||
let(:real_place) { create(:place) }
|
||||
subject(:real_serializer) { described_class.new(real_place) }
|
||||
|
||||
it 'serializes a real place model correctly' do
|
||||
result = real_serializer.call
|
||||
|
||||
expect(result[:id]).to eq(real_place.id)
|
||||
expect(result[:name]).to eq(real_place.name)
|
||||
expect(result[:longitude]).to eq(real_place.lon)
|
||||
expect(result[:latitude]).to eq(real_place.lat)
|
||||
expect(result[:city]).to eq(real_place.city)
|
||||
expect(result[:country]).to eq(real_place.country)
|
||||
expect(result[:source]).to eq(real_place.source)
|
||||
expect(result[:geodata]).to eq(real_place.geodata)
|
||||
expect(result[:reverse_geocoded_at]).to eq(real_place.reverse_geocoded_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
148
spec/serializers/api/visit_serializer_spec.rb
Normal file
148
spec/serializers/api/visit_serializer_spec.rb
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Api::VisitSerializer do
|
||||
describe '#call' do
|
||||
let(:place) do
|
||||
instance_double(
|
||||
Place,
|
||||
id: 123,
|
||||
lat: 40.7812,
|
||||
lon: -73.9665
|
||||
)
|
||||
end
|
||||
|
||||
let(:area) do
|
||||
instance_double(
|
||||
Area,
|
||||
id: 456,
|
||||
latitude: 41.9028,
|
||||
longitude: -87.6350
|
||||
)
|
||||
end
|
||||
|
||||
let(:visit) do
|
||||
instance_double(
|
||||
Visit,
|
||||
id: 789,
|
||||
area_id: area.id,
|
||||
user_id: 101,
|
||||
started_at: Time.zone.parse('2023-01-15T10:00:00Z'),
|
||||
ended_at: Time.zone.parse('2023-01-15T12:00:00Z'),
|
||||
duration: 120, # 2 hours in minutes
|
||||
name: 'Central Park Visit',
|
||||
status: 'confirmed',
|
||||
place: place,
|
||||
area: area,
|
||||
place_id: place.id
|
||||
)
|
||||
end
|
||||
|
||||
subject(:serializer) { described_class.new(visit) }
|
||||
|
||||
context 'when a visit has both place and area' do
|
||||
it 'serializes the visit with place coordinates' do
|
||||
result = serializer.call
|
||||
|
||||
expect(result).to be_a(Hash)
|
||||
expect(result[:id]).to eq(789)
|
||||
expect(result[:area_id]).to eq(456)
|
||||
expect(result[:user_id]).to eq(101)
|
||||
expect(result[:started_at]).to eq(Time.zone.parse('2023-01-15T10:00:00Z'))
|
||||
expect(result[:ended_at]).to eq(Time.zone.parse('2023-01-15T12:00:00Z'))
|
||||
expect(result[:duration]).to eq(120)
|
||||
expect(result[:name]).to eq('Central Park Visit')
|
||||
expect(result[:status]).to eq('confirmed')
|
||||
|
||||
# Place should use place coordinates
|
||||
expect(result[:place][:id]).to eq(123)
|
||||
expect(result[:place][:latitude]).to eq(40.7812)
|
||||
expect(result[:place][:longitude]).to eq(-73.9665)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a visit has area but no place' do
|
||||
let(:visit_without_place) do
|
||||
instance_double(
|
||||
Visit,
|
||||
id: 789,
|
||||
area_id: area.id,
|
||||
user_id: 101,
|
||||
started_at: Time.zone.parse('2023-01-15T10:00:00Z'),
|
||||
ended_at: Time.zone.parse('2023-01-15T12:00:00Z'),
|
||||
duration: 120,
|
||||
name: 'Chicago Visit',
|
||||
status: 'suggested',
|
||||
place: nil,
|
||||
area: area,
|
||||
place_id: nil
|
||||
)
|
||||
end
|
||||
|
||||
subject(:serializer_without_place) { described_class.new(visit_without_place) }
|
||||
|
||||
it 'falls back to area coordinates' do
|
||||
result = serializer_without_place.call
|
||||
|
||||
expect(result[:place][:id]).to be_nil
|
||||
expect(result[:place][:latitude]).to eq(41.9028)
|
||||
expect(result[:place][:longitude]).to eq(-87.6350)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a visit has neither place nor area' do
|
||||
let(:visit_without_location) do
|
||||
instance_double(
|
||||
Visit,
|
||||
id: 789,
|
||||
area_id: nil,
|
||||
user_id: 101,
|
||||
started_at: Time.zone.parse('2023-01-15T10:00:00Z'),
|
||||
ended_at: Time.zone.parse('2023-01-15T12:00:00Z'),
|
||||
duration: 120,
|
||||
name: 'Unknown Location Visit',
|
||||
status: 'declined',
|
||||
place: nil,
|
||||
area: nil,
|
||||
place_id: nil
|
||||
)
|
||||
end
|
||||
|
||||
subject(:serializer_without_location) { described_class.new(visit_without_location) }
|
||||
|
||||
it 'returns nil for location coordinates' do
|
||||
result = serializer_without_location.call
|
||||
|
||||
expect(result[:place][:id]).to be_nil
|
||||
expect(result[:place][:latitude]).to be_nil
|
||||
expect(result[:place][:longitude]).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with actual Visit model', type: :model do
|
||||
let(:real_place) { create(:place) }
|
||||
let(:real_area) { create(:area) }
|
||||
let(:real_visit) { create(:visit, place: real_place, area: real_area) }
|
||||
|
||||
subject(:real_serializer) { described_class.new(real_visit) }
|
||||
|
||||
it 'serializes a real visit model correctly' do
|
||||
result = real_serializer.call
|
||||
|
||||
expect(result[:id]).to eq(real_visit.id)
|
||||
expect(result[:area_id]).to eq(real_visit.area_id)
|
||||
expect(result[:user_id]).to eq(real_visit.user_id)
|
||||
expect(result[:started_at]).to eq(real_visit.started_at)
|
||||
expect(result[:ended_at]).to eq(real_visit.ended_at)
|
||||
expect(result[:duration]).to eq(real_visit.duration)
|
||||
expect(result[:name]).to eq(real_visit.name)
|
||||
expect(result[:status]).to eq(real_visit.status)
|
||||
|
||||
expect(result[:place][:id]).to eq(real_place.id)
|
||||
expect(result[:place][:latitude]).to eq(real_place.lat)
|
||||
expect(result[:place][:longitude]).to eq(real_place.lon)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
154
spec/services/visits/bulk_update_spec.rb
Normal file
154
spec/services/visits/bulk_update_spec.rb
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::BulkUpdate do
|
||||
let(:user) { create(:user) }
|
||||
let(:other_user) { create(:user) }
|
||||
|
||||
let!(:visit1) { create(:visit, user: user, status: 'suggested') }
|
||||
let!(:visit2) { create(:visit, user: user, status: 'suggested') }
|
||||
let!(:visit3) { create(:visit, user: user, status: 'confirmed') }
|
||||
let!(:other_user_visit) { create(:visit, user: other_user, status: 'suggested') }
|
||||
|
||||
describe '#call' do
|
||||
context 'when all parameters are valid' do
|
||||
let(:visit_ids) { [visit1.id, visit2.id] }
|
||||
let(:status) { 'confirmed' }
|
||||
|
||||
subject(:service) { described_class.new(user, visit_ids, status) }
|
||||
|
||||
it 'updates the status of all specified visits' do
|
||||
result = service.call
|
||||
|
||||
expect(result[:count]).to eq(2)
|
||||
expect(visit1.reload.status).to eq('confirmed')
|
||||
expect(visit2.reload.status).to eq('confirmed')
|
||||
expect(visit3.reload.status).to eq('confirmed') # This one wasn't changed
|
||||
end
|
||||
|
||||
it 'returns a hash with count and visits' do
|
||||
result = service.call
|
||||
|
||||
expect(result).to be_a(Hash)
|
||||
expect(result[:count]).to eq(2)
|
||||
expect(result[:visits]).to include(visit1, visit2)
|
||||
expect(result[:visits]).not_to include(visit3, other_user_visit)
|
||||
end
|
||||
|
||||
it 'does not update visits that belong to other users' do
|
||||
service.call
|
||||
|
||||
expect(other_user_visit.reload.status).to eq('suggested')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when changing to declined status' do
|
||||
let(:visit_ids) { [visit1.id, visit2.id, visit3.id] }
|
||||
let(:status) { 'declined' }
|
||||
|
||||
subject(:service) { described_class.new(user, visit_ids, status) }
|
||||
|
||||
it 'updates the status to declined' do
|
||||
result = service.call
|
||||
|
||||
expect(result[:count]).to eq(3)
|
||||
expect(visit1.reload.status).to eq('declined')
|
||||
expect(visit2.reload.status).to eq('declined')
|
||||
expect(visit3.reload.status).to eq('declined')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when visit_ids is empty' do
|
||||
let(:visit_ids) { [] }
|
||||
let(:status) { 'confirmed' }
|
||||
|
||||
subject(:service) { described_class.new(user, visit_ids, status) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.call).to be(false)
|
||||
end
|
||||
|
||||
it 'adds an error' do
|
||||
service.call
|
||||
expect(service.errors).to include('No visits selected')
|
||||
end
|
||||
|
||||
it 'does not update any visits' do
|
||||
service.call
|
||||
expect(visit1.reload.status).to eq('suggested')
|
||||
expect(visit2.reload.status).to eq('suggested')
|
||||
expect(visit3.reload.status).to eq('confirmed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when visit_ids is nil' do
|
||||
let(:visit_ids) { nil }
|
||||
let(:status) { 'confirmed' }
|
||||
|
||||
subject(:service) { described_class.new(user, visit_ids, status) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.call).to be(false)
|
||||
end
|
||||
|
||||
it 'adds an error' do
|
||||
service.call
|
||||
expect(service.errors).to include('No visits selected')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status is invalid' do
|
||||
let(:visit_ids) { [visit1.id, visit2.id] }
|
||||
let(:status) { 'invalid_status' }
|
||||
|
||||
subject(:service) { described_class.new(user, visit_ids, status) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.call).to be(false)
|
||||
end
|
||||
|
||||
it 'adds an error' do
|
||||
service.call
|
||||
expect(service.errors).to include('Invalid status')
|
||||
end
|
||||
|
||||
it 'does not update any visits' do
|
||||
service.call
|
||||
expect(visit1.reload.status).to eq('suggested')
|
||||
expect(visit2.reload.status).to eq('suggested')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no matching visits are found' do
|
||||
let(:visit_ids) { [999_999, 888_888] }
|
||||
let(:status) { 'confirmed' }
|
||||
|
||||
subject(:service) { described_class.new(user, visit_ids, status) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.call).to be(false)
|
||||
end
|
||||
|
||||
it 'adds an error' do
|
||||
service.call
|
||||
expect(service.errors).to include('No matching visits found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when some visit IDs do not belong to the user' do
|
||||
let(:visit_ids) { [visit1.id, other_user_visit.id] }
|
||||
let(:status) { 'confirmed' }
|
||||
|
||||
subject(:service) { described_class.new(user, visit_ids, status) }
|
||||
|
||||
it 'only updates visits that belong to the user' do
|
||||
result = service.call
|
||||
|
||||
expect(result[:count]).to eq(1)
|
||||
expect(visit1.reload.status).to eq('confirmed')
|
||||
expect(other_user_visit.reload.status).to eq('suggested')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -27,10 +27,6 @@ RSpec.describe Visits::Creator do
|
|||
context 'when matching an area' do
|
||||
let!(:area) { create(:area, user: user, latitude: 40.7128, longitude: -74.0060, radius: 100) }
|
||||
|
||||
before do
|
||||
allow(subject).to receive(:near_area?).and_return(true)
|
||||
end
|
||||
|
||||
it 'creates a visit associated with the area' do
|
||||
visits = subject.create_visits([visit_data])
|
||||
|
||||
|
|
@ -48,16 +44,39 @@ RSpec.describe Visits::Creator do
|
|||
expect(point1.reload.visit_id).to eq(visit.id)
|
||||
expect(point2.reload.visit_id).to eq(visit.id)
|
||||
end
|
||||
|
||||
it 'uses area name for visit name' do
|
||||
area.update(name: 'Custom Area Name')
|
||||
visits = subject.create_visits([visit_data])
|
||||
|
||||
expect(visits.first.name).to eq('Custom Area Name')
|
||||
end
|
||||
|
||||
it 'does not find areas too far from the visit center' do
|
||||
far_area = create(:area, user: user, latitude: 41.8781, longitude: -87.6298, radius: 100) # Chicago
|
||||
|
||||
visits = subject.create_visits([visit_data])
|
||||
|
||||
expect(visits.first.area).to eq(area) # Should match the closer area
|
||||
expect(visits.first.area).not_to eq(far_area)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when matching a place' do
|
||||
let(:place) { create(:place, latitude: 40.7128, longitude: -74.0060) }
|
||||
let(:place) { create(:place, name: 'Test Place') }
|
||||
let(:suggested_place1) { create(:place, name: 'Suggested Place 1') }
|
||||
let(:suggested_place2) { create(:place, name: 'Suggested Place 2') }
|
||||
let(:place_finder) { instance_double(Visits::PlaceFinder) }
|
||||
let(:place_data) do
|
||||
{
|
||||
main_place: place,
|
||||
suggested_places: [suggested_place1, suggested_place2]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(subject).to receive(:near_area?).and_return(false)
|
||||
allow(Visits::PlaceFinder).to receive(:new).with(user).and_return(place_finder)
|
||||
allow(place_finder).to receive(:find_or_create_place).and_return(place)
|
||||
allow(place_finder).to receive(:find_or_create_place).and_return(place_data)
|
||||
end
|
||||
|
||||
it 'creates a visit associated with the place' do
|
||||
|
|
@ -70,6 +89,170 @@ RSpec.describe Visits::Creator do
|
|||
expect(visit.place).to eq(place)
|
||||
expect(visit.name).to eq(place.name)
|
||||
end
|
||||
|
||||
it 'associates suggested places with the visit' do
|
||||
visits = subject.create_visits([visit_data])
|
||||
visit = visits.first
|
||||
|
||||
# Check for place_visits associations
|
||||
expect(visit.place_visits.count).to eq(2)
|
||||
expect(visit.place_visits.pluck(:place_id)).to contain_exactly(
|
||||
suggested_place1.id,
|
||||
suggested_place2.id
|
||||
)
|
||||
expect(visit.suggested_places).to contain_exactly(suggested_place1, suggested_place2)
|
||||
end
|
||||
|
||||
it 'does not create duplicate place_visit associations' do
|
||||
# Create an existing association
|
||||
visit = create(:visit, user: user, place: place)
|
||||
create(:place_visit, visit: visit, place: suggested_place1)
|
||||
|
||||
allow(Visit).to receive(:create!).and_return(visit)
|
||||
|
||||
# Only one new association should be created
|
||||
expect do
|
||||
subject.create_visits([visit_data])
|
||||
end.to change(PlaceVisit, :count).by(1)
|
||||
expect(visit.place_visits.pluck(:place_id)).to contain_exactly(
|
||||
suggested_place1.id,
|
||||
suggested_place2.id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no area or place is found' do
|
||||
let(:place_finder) { instance_double(Visits::PlaceFinder) }
|
||||
|
||||
before do
|
||||
allow(Visits::PlaceFinder).to receive(:new).with(user).and_return(place_finder)
|
||||
allow(place_finder).to receive(:find_or_create_place).and_return(nil)
|
||||
end
|
||||
|
||||
it 'uses suggested name from visit data' do
|
||||
visits = subject.create_visits([visit_data])
|
||||
|
||||
expect(visits.first.area).to be_nil
|
||||
expect(visits.first.place).to be_nil
|
||||
expect(visits.first.name).to eq('Test Place')
|
||||
end
|
||||
|
||||
it 'uses "Unknown Location" when no name is available' do
|
||||
visit_data_without_name = visit_data.dup
|
||||
visit_data_without_name[:suggested_name] = nil
|
||||
|
||||
visits = subject.create_visits([visit_data_without_name])
|
||||
|
||||
expect(visits.first.name).to eq('Unknown Location')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing multiple visits' do
|
||||
let(:place1) { create(:place, name: 'Place 1') }
|
||||
let(:place2) { create(:place, name: 'Place 2') }
|
||||
let(:place_finder) { instance_double(Visits::PlaceFinder) }
|
||||
|
||||
let(:visit_data2) do
|
||||
{
|
||||
start_time: 3.hours.ago.to_i,
|
||||
end_time: 2.hours.ago.to_i,
|
||||
duration: 60.minutes.to_i,
|
||||
center_lat: 41.8781,
|
||||
center_lon: -87.6298,
|
||||
radius: 50,
|
||||
suggested_name: 'Chicago Visit',
|
||||
points: [create(:point, user: user), create(:point, user: user)]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Visits::PlaceFinder).to receive(:new).with(user).and_return(place_finder)
|
||||
allow(place_finder).to receive(:find_or_create_place)
|
||||
.with(visit_data).and_return({ main_place: place1, suggested_places: [] })
|
||||
allow(place_finder).to receive(:find_or_create_place)
|
||||
.with(visit_data2).and_return({ main_place: place2, suggested_places: [] })
|
||||
end
|
||||
|
||||
it 'creates multiple visits' do
|
||||
visits = subject.create_visits([visit_data, visit_data2])
|
||||
|
||||
expect(visits.size).to eq(2)
|
||||
expect(visits[0].place).to eq(place1)
|
||||
expect(visits[0].name).to eq('Place 1')
|
||||
expect(visits[1].place).to eq(place2)
|
||||
expect(visits[1].name).to eq('Place 2')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when transaction fails' do
|
||||
let(:place_finder) { instance_double(Visits::PlaceFinder) }
|
||||
|
||||
before do
|
||||
allow(Visits::PlaceFinder).to receive(:new).with(user).and_return(place_finder)
|
||||
allow(place_finder).to receive(:find_or_create_place).and_return(nil)
|
||||
allow(Visit).to receive(:create!).and_raise(ActiveRecord::RecordInvalid)
|
||||
end
|
||||
|
||||
it 'does not update points if visit creation fails' do
|
||||
expect do
|
||||
subject.create_visits([visit_data])
|
||||
end.to raise_error(ActiveRecord::RecordInvalid)
|
||||
|
||||
# Points should not be associated with any visit
|
||||
expect(point1.reload.visit_id).to be_nil
|
||||
expect(point2.reload.visit_id).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#find_matching_area' do
|
||||
let(:visit_data) do
|
||||
{
|
||||
center_lat: 40.7128,
|
||||
center_lon: -74.0060,
|
||||
radius: 50
|
||||
}
|
||||
end
|
||||
|
||||
it 'finds areas within radius' do
|
||||
area_within = create(:area, user: user, latitude: 40.7129, longitude: -74.0061, radius: 100)
|
||||
area_outside = create(:area, user: user, latitude: 40.7500, longitude: -74.0500, radius: 100)
|
||||
|
||||
result = subject.send(:find_matching_area, visit_data)
|
||||
expect(result).to eq(area_within)
|
||||
end
|
||||
|
||||
it 'returns nil when no areas match' do
|
||||
create(:area, user: user, latitude: 42.0, longitude: -72.0, radius: 100)
|
||||
|
||||
result = subject.send(:find_matching_area, visit_data)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'only considers user areas' do
|
||||
area_other_user = create(:area, latitude: 40.7128, longitude: -74.0060, radius: 100)
|
||||
area_user = create(:area, user: user, latitude: 40.7128, longitude: -74.0060, radius: 100)
|
||||
|
||||
result = subject.send(:find_matching_area, visit_data)
|
||||
expect(result).to eq(area_user)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#near_area?' do
|
||||
it 'returns true when point is within area radius' do
|
||||
area = create(:area, latitude: 40.7128, longitude: -74.0060, radius: 100)
|
||||
center = [40.7129, -74.0061] # Very close to area center
|
||||
|
||||
result = subject.send(:near_area?, center, area)
|
||||
expect(result).to be true
|
||||
end
|
||||
|
||||
it 'returns false when point is outside area radius' do
|
||||
area = create(:area, latitude: 40.7128, longitude: -74.0060, radius: 100)
|
||||
center = [40.7500, -74.0500] # Further away
|
||||
|
||||
result = subject.send(:near_area?, center, area)
|
||||
expect(result).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,73 +3,321 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::Detector do
|
||||
let(:user) { create(:user) }
|
||||
# Constants from the class to make tests more maintainable
|
||||
let(:minimum_visit_duration) { described_class::MINIMUM_VISIT_DURATION }
|
||||
let(:maximum_visit_gap) { described_class::MAXIMUM_VISIT_GAP }
|
||||
let(:minimum_points_for_visit) { described_class::MINIMUM_POINTS_FOR_VISIT }
|
||||
|
||||
# The issue is likely with how we're creating the test points
|
||||
# Let's make them more realistic with proper spacing in time and location
|
||||
# Base time for tests
|
||||
let(:base_time) { Time.zone.now }
|
||||
|
||||
# Create points for a typical visit scenario
|
||||
let(:points) do
|
||||
[
|
||||
# First visit - 3 points close together
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 1.hour.ago.to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)', timestamp: 50.minutes.ago.to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0062 40.7130)', timestamp: 40.minutes.ago.to_i),
|
||||
# First visit - multiple points close together
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: (base_time - 1.hour).to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)', timestamp: (base_time - 50.minutes).to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0062 40.7130)', timestamp: (base_time - 40.minutes).to_i),
|
||||
|
||||
# Gap in time (> MAXIMUM_VISIT_GAP)
|
||||
|
||||
# Second visit - different location
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0500 40.7500)', timestamp: 10.minutes.ago.to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0501 40.7501)', timestamp: 5.minutes.ago.to_i)
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0500 40.7500)', timestamp: (base_time - 10.minutes).to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0501 40.7501)', timestamp: (base_time - 5.minutes).to_i)
|
||||
]
|
||||
end
|
||||
|
||||
subject { described_class.new(points) }
|
||||
|
||||
describe '#detect_potential_visits' do
|
||||
before do
|
||||
allow(subject).to receive(:valid_visit?).and_return(true)
|
||||
allow(subject).to receive(:suggest_place_name).and_return('Test Place')
|
||||
context 'with valid visit data' do
|
||||
before do
|
||||
allow(subject).to receive(:suggest_place_name).and_return('Test Place')
|
||||
end
|
||||
|
||||
it 'identifies separate visits based on time gaps and location changes' do
|
||||
visits = subject.detect_potential_visits
|
||||
|
||||
expect(visits.size).to eq(2)
|
||||
expect(visits.first[:points].size).to eq(3)
|
||||
expect(visits.last[:points].size).to eq(2)
|
||||
end
|
||||
|
||||
it 'calculates correct visit properties' do
|
||||
visits = subject.detect_potential_visits
|
||||
first_visit = visits.first
|
||||
|
||||
# The center should be the average of the first 3 points
|
||||
expected_lat = (40.7128 + 40.7129 + 40.7130) / 3
|
||||
expected_lon = (-74.0060 + -74.0061 + -74.0062) / 3
|
||||
|
||||
expect(first_visit[:start_time]).to eq((base_time - 1.hour).to_i)
|
||||
expect(first_visit[:end_time]).to eq((base_time - 40.minutes).to_i)
|
||||
expect(first_visit[:duration]).to eq(20.minutes.to_i)
|
||||
expect(first_visit[:center_lat]).to be_within(0.0001).of(expected_lat)
|
||||
expect(first_visit[:center_lon]).to be_within(0.0001).of(expected_lon)
|
||||
expect(first_visit[:radius]).to be > 0
|
||||
expect(first_visit[:suggested_name]).to eq('Test Place')
|
||||
end
|
||||
end
|
||||
|
||||
it 'identifies potential visits from points' do
|
||||
visits = subject.detect_potential_visits
|
||||
|
||||
expect(visits.size).to eq(2)
|
||||
expect(visits.first[:points].size).to eq(3)
|
||||
expect(visits.last[:points].size).to eq(2)
|
||||
end
|
||||
|
||||
it 'calculates visit properties correctly' do
|
||||
visits = subject.detect_potential_visits
|
||||
first_visit = visits.first
|
||||
|
||||
# The center should be the average of the first 3 points
|
||||
expected_lat = (40.7128 + 40.7129 + 40.7130) / 3
|
||||
expected_lon = (-74.0060 + -74.0061 + -74.0062) / 3
|
||||
|
||||
expect(first_visit[:duration]).to be_within(60).of(20.minutes.to_i)
|
||||
expect(first_visit[:center_lat]).to be_within(0.001).of(expected_lat)
|
||||
expect(first_visit[:center_lon]).to be_within(0.001).of(expected_lon)
|
||||
expect(first_visit[:radius]).to be > 0
|
||||
expect(first_visit[:suggested_name]).to eq('Test Place')
|
||||
end
|
||||
|
||||
context 'with insufficient points for a visit' do
|
||||
let(:points) do
|
||||
context 'with visits that are too short in duration' do
|
||||
let(:short_duration_points) do
|
||||
[
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 1.hour.ago.to_i),
|
||||
# Large time gap
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)', timestamp: 10.minutes.ago.to_i)
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: (base_time - 1.hour).to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)',
|
||||
timestamp: (base_time - 1.hour + 2.minutes).to_i)
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
allow(subject).to receive(:valid_visit?).and_call_original
|
||||
subject { described_class.new(short_duration_points) }
|
||||
|
||||
it 'filters out visits that are too short' do
|
||||
visits = subject.detect_potential_visits
|
||||
expect(visits).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with insufficient points for a visit' do
|
||||
let(:single_point) do
|
||||
[
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: (base_time - 1.hour).to_i)
|
||||
]
|
||||
end
|
||||
|
||||
it 'does not create a visit' do
|
||||
subject { described_class.new(single_point) }
|
||||
|
||||
it 'does not create a visit with just one point' do
|
||||
visits = subject.detect_potential_visits
|
||||
expect(visits).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with points that create multiple valid visits' do
|
||||
let(:multi_visit_points) do
|
||||
[
|
||||
# First visit
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: (base_time - 3.hours).to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)', timestamp: (base_time - 2.5.hours).to_i),
|
||||
|
||||
# Second visit (different location, after a gap)
|
||||
build_stubbed(:point, lonlat: 'POINT(-73.9800 40.7600)', timestamp: (base_time - 1.5.hours).to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-73.9801 40.7601)', timestamp: (base_time - 1.hour).to_i),
|
||||
|
||||
# Third visit (another location, after another gap)
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0500 40.7500)', timestamp: (base_time - 30.minutes).to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0501 40.7501)', timestamp: (base_time - 20.minutes).to_i)
|
||||
]
|
||||
end
|
||||
|
||||
subject { described_class.new(multi_visit_points) }
|
||||
|
||||
before do
|
||||
allow(subject).to receive(:suggest_place_name).and_return('Test Place')
|
||||
end
|
||||
|
||||
it 'correctly identifies all valid visits' do
|
||||
visits = subject.detect_potential_visits
|
||||
|
||||
expect(visits.size).to eq(3)
|
||||
expect(visits[0][:points].size).to eq(2)
|
||||
expect(visits[1][:points].size).to eq(2)
|
||||
expect(visits[2][:points].size).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with points having small time gaps but in same area' do
|
||||
let(:same_area_points) do
|
||||
[
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: (base_time - 1.hour).to_i),
|
||||
# Small gap (less than MAXIMUM_VISIT_GAP)
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)',
|
||||
timestamp: (base_time - 1.hour + 25.minutes).to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0062 40.7130)',
|
||||
timestamp: (base_time - 1.hour + 40.minutes).to_i)
|
||||
]
|
||||
end
|
||||
|
||||
subject { described_class.new(same_area_points) }
|
||||
|
||||
before do
|
||||
allow(subject).to receive(:suggest_place_name).and_return('Test Place')
|
||||
end
|
||||
|
||||
it 'groups points into a single visit despite small gaps' do
|
||||
visits = subject.detect_potential_visits
|
||||
|
||||
expect(visits.size).to eq(1)
|
||||
expect(visits.first[:points].size).to eq(3)
|
||||
expect(visits.first[:duration]).to eq(40.minutes.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no points' do
|
||||
subject { described_class.new([]) }
|
||||
|
||||
it 'returns an empty array' do
|
||||
visits = subject.detect_potential_visits
|
||||
expect(visits).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
describe '#belongs_to_current_visit?' do
|
||||
let(:current_visit) do
|
||||
{
|
||||
start_time: (base_time - 1.hour).to_i,
|
||||
end_time: (base_time - 50.minutes).to_i,
|
||||
center_lat: 40.7128,
|
||||
center_lon: -74.0060,
|
||||
points: []
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns true for a point with small time gap and close to center' do
|
||||
point = build_stubbed(:point, lonlat: 'POINT(-74.0062 40.7130)',
|
||||
timestamp: (base_time - 45.minutes).to_i)
|
||||
|
||||
result = subject.send(:belongs_to_current_visit?, point, current_visit)
|
||||
expect(result).to be true
|
||||
end
|
||||
|
||||
it 'returns false for a point with large time gap' do
|
||||
point = build_stubbed(:point, lonlat: 'POINT(-74.0062 40.7130)',
|
||||
timestamp: (base_time - 10.minutes).to_i)
|
||||
|
||||
result = subject.send(:belongs_to_current_visit?, point, current_visit)
|
||||
expect(result).to be false
|
||||
end
|
||||
|
||||
it 'returns false for a point far from the center' do
|
||||
point = build_stubbed(:point, lonlat: 'POINT(-74.0500 40.7500)',
|
||||
timestamp: (base_time - 49.minutes).to_i)
|
||||
|
||||
result = subject.send(:belongs_to_current_visit?, point, current_visit)
|
||||
expect(result).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#calculate_max_radius' do
|
||||
it 'returns larger radius for longer visits' do
|
||||
short_radius = subject.send(:calculate_max_radius, 5.minutes.to_i)
|
||||
long_radius = subject.send(:calculate_max_radius, 1.hour.to_i)
|
||||
|
||||
expect(long_radius).to be > short_radius
|
||||
end
|
||||
|
||||
it 'has a minimum radius even for very short visits' do
|
||||
radius = subject.send(:calculate_max_radius, 1.minute.to_i)
|
||||
expect(radius).to be > 0
|
||||
end
|
||||
|
||||
it 'caps the radius at maximum value' do
|
||||
radius = subject.send(:calculate_max_radius, 24.hours.to_i)
|
||||
expect(radius).to be <= 0.5 # Cap at 500 meters
|
||||
end
|
||||
end
|
||||
|
||||
describe '#calculate_visit_radius' do
|
||||
let(:center) { [40.7128, -74.0060] }
|
||||
let(:test_points) do
|
||||
[
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)'), # At center
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0070 40.7138)'), # ~100m away
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0080 40.7148)') # ~200m away
|
||||
]
|
||||
end
|
||||
|
||||
it 'returns the distance to the furthest point as radius' do
|
||||
radius = subject.send(:calculate_visit_radius, test_points, center)
|
||||
|
||||
# Approximately 200 meters, but with some tolerance
|
||||
expect(radius).to be_within(50).of(200)
|
||||
end
|
||||
|
||||
it 'ensures a minimum radius even with close points' do
|
||||
close_points = [
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)'),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)')
|
||||
]
|
||||
|
||||
radius = subject.send(:calculate_visit_radius, close_points, center)
|
||||
expect(radius).to be >= 15 # Minimum 15 meters
|
||||
end
|
||||
end
|
||||
|
||||
describe '#suggest_place_name' do
|
||||
let(:point_with_geodata) do
|
||||
build_stubbed(:point,
|
||||
geodata: {
|
||||
'features' => [
|
||||
{
|
||||
'properties' => {
|
||||
'type' => 'restaurant',
|
||||
'name' => 'Awesome Pizza',
|
||||
'street' => 'Main St',
|
||||
'city' => 'New York',
|
||||
'state' => 'NY'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
end
|
||||
|
||||
let(:point_with_different_geodata) do
|
||||
build_stubbed(:point,
|
||||
geodata: {
|
||||
'features' => [
|
||||
{
|
||||
'properties' => {
|
||||
'type' => 'park',
|
||||
'name' => 'Central Park',
|
||||
'city' => 'New York',
|
||||
'state' => 'NY'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
end
|
||||
|
||||
let(:point_without_geodata) do
|
||||
build_stubbed(:point, geodata: nil)
|
||||
end
|
||||
|
||||
it 'extracts the most common feature name' do
|
||||
test_points = [point_with_geodata, point_with_geodata]
|
||||
name = subject.send(:suggest_place_name, test_points)
|
||||
|
||||
expect(name).to eq('Awesome Pizza, Main St, New York, NY')
|
||||
end
|
||||
|
||||
it 'returns nil for points without geodata' do
|
||||
test_points = [point_without_geodata, point_without_geodata]
|
||||
name = subject.send(:suggest_place_name, test_points)
|
||||
|
||||
expect(name).to be_nil
|
||||
end
|
||||
|
||||
it 'uses the most common feature type across multiple points' do
|
||||
restaurant_points = Array.new(3) { point_with_geodata }
|
||||
park_points = Array.new(2) { point_with_different_geodata }
|
||||
|
||||
test_points = restaurant_points + park_points
|
||||
name = subject.send(:suggest_place_name, test_points)
|
||||
|
||||
expect(name).to eq('Awesome Pizza, Main St, New York, NY')
|
||||
end
|
||||
|
||||
it 'handles empty or invalid geodata gracefully' do
|
||||
point_with_empty_features = build_stubbed(:point, geodata: { 'features' => [] })
|
||||
point_with_invalid_geodata = build_stubbed(:point, geodata: { 'invalid' => 'data' })
|
||||
|
||||
test_points = [point_with_empty_features, point_with_invalid_geodata]
|
||||
name = subject.send(:suggest_place_name, test_points)
|
||||
|
||||
expect(name).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
142
spec/services/visits/find_in_time_spec.rb
Normal file
142
spec/services/visits/find_in_time_spec.rb
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::FindInTime do
|
||||
let(:user) { create(:user) }
|
||||
let(:other_user) { create(:user) }
|
||||
let(:place) { create(:place) }
|
||||
|
||||
let(:reference_time) { Time.zone.parse('2023-01-15 12:00:00') }
|
||||
|
||||
let!(:visit1) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place,
|
||||
started_at: reference_time,
|
||||
ended_at: reference_time + 1.hour
|
||||
)
|
||||
end
|
||||
|
||||
let!(:visit2) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place,
|
||||
started_at: reference_time + 2.hours,
|
||||
ended_at: reference_time + 3.hours
|
||||
)
|
||||
end
|
||||
|
||||
# Visit outside range (before)
|
||||
let!(:visit_before) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place,
|
||||
started_at: reference_time - 3.hours,
|
||||
ended_at: reference_time - 2.hours
|
||||
)
|
||||
end
|
||||
|
||||
# Visit outside range (after)
|
||||
let!(:visit_after) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place,
|
||||
started_at: reference_time + 5.hours,
|
||||
ended_at: reference_time + 6.hours
|
||||
)
|
||||
end
|
||||
|
||||
# Visit for different user within range
|
||||
let!(:other_user_visit) do
|
||||
create(
|
||||
:visit,
|
||||
user: other_user,
|
||||
place: place,
|
||||
started_at: reference_time + 1.hour,
|
||||
ended_at: reference_time + 2.hours
|
||||
)
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
context 'when given a time range' do
|
||||
let(:params) do
|
||||
{
|
||||
start_at: reference_time.to_s,
|
||||
end_at: (reference_time + 4.hours).to_s
|
||||
}
|
||||
end
|
||||
|
||||
subject(:result) { described_class.new(user, params).call }
|
||||
|
||||
it 'returns visits within the time range' do
|
||||
expect(result).to include(visit1, visit2)
|
||||
expect(result).not_to include(visit_before, visit_after)
|
||||
end
|
||||
|
||||
it 'returns visits in descending order by started_at' do
|
||||
expect(result.to_a).to eq([visit2, visit1])
|
||||
end
|
||||
|
||||
it 'does not include visits from other users' do
|
||||
expect(result).not_to include(other_user_visit)
|
||||
end
|
||||
|
||||
it 'preloads the place association' do
|
||||
expect(result.first.association(:place)).to be_loaded
|
||||
end
|
||||
end
|
||||
|
||||
context 'with visits at the boundaries of the time range' do
|
||||
let!(:visit_at_start) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place,
|
||||
started_at: reference_time,
|
||||
ended_at: reference_time + 30.minutes
|
||||
)
|
||||
end
|
||||
|
||||
let!(:visit_at_end) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place,
|
||||
started_at: reference_time + 3.hours + 30.minutes,
|
||||
ended_at: reference_time + 4.hours
|
||||
)
|
||||
end
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
start_at: reference_time.to_s,
|
||||
end_at: (reference_time + 4.hours).to_s
|
||||
}
|
||||
end
|
||||
|
||||
subject(:result) { described_class.new(user, params).call }
|
||||
|
||||
it 'includes visits at the boundaries of the time range' do
|
||||
expect(result).to include(visit_at_start, visit_at_end)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when time parameters are invalid' do
|
||||
let(:params) do
|
||||
{
|
||||
start_at: 'invalid-date',
|
||||
end_at: (reference_time + 4.hours).to_s
|
||||
}
|
||||
end
|
||||
|
||||
it 'raises an ArgumentError' do
|
||||
expect { described_class.new(user, params).call }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
161
spec/services/visits/find_within_bounding_box_spec.rb
Normal file
161
spec/services/visits/find_within_bounding_box_spec.rb
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::FindWithinBoundingBox do
|
||||
let(:user) { create(:user) }
|
||||
let(:other_user) { create(:user) }
|
||||
|
||||
# Define a bounding box for testing
|
||||
# This creates a box around central Paris
|
||||
let(:sw_lat) { 48.8534 } # Southwest latitude
|
||||
let(:sw_lng) { 2.3380 } # Southwest longitude
|
||||
let(:ne_lat) { 48.8667 } # Northeast latitude
|
||||
let(:ne_lng) { 2.3580 } # Northeast longitude
|
||||
|
||||
# Create places inside the bounding box
|
||||
let!(:place_inside_1) do
|
||||
create(:place, latitude: 48.8600, longitude: 2.3500) # Inside the bounding box
|
||||
end
|
||||
|
||||
let!(:place_inside_2) do
|
||||
create(:place, latitude: 48.8580, longitude: 2.3450) # Inside the bounding box
|
||||
end
|
||||
|
||||
# Create places outside the bounding box
|
||||
let!(:place_outside_1) do
|
||||
create(:place, latitude: 48.8700, longitude: 2.3600) # North of the bounding box
|
||||
end
|
||||
|
||||
let!(:place_outside_2) do
|
||||
create(:place, latitude: 48.8500, longitude: 2.3300) # Southwest of the bounding box
|
||||
end
|
||||
|
||||
# Create visits for the test user
|
||||
let!(:visit_inside_1) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place_inside_1,
|
||||
started_at: 2.hours.ago,
|
||||
ended_at: 1.hour.ago
|
||||
)
|
||||
end
|
||||
|
||||
let!(:visit_inside_2) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place_inside_2,
|
||||
started_at: 4.hours.ago,
|
||||
ended_at: 3.hours.ago
|
||||
)
|
||||
end
|
||||
|
||||
let!(:visit_outside_1) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place_outside_1,
|
||||
started_at: 6.hours.ago,
|
||||
ended_at: 5.hours.ago
|
||||
)
|
||||
end
|
||||
|
||||
let!(:visit_outside_2) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place_outside_2,
|
||||
started_at: 8.hours.ago,
|
||||
ended_at: 7.hours.ago
|
||||
)
|
||||
end
|
||||
|
||||
# Create a visit for another user inside the bounding box
|
||||
let!(:other_user_visit_inside) do
|
||||
create(
|
||||
:visit,
|
||||
user: other_user,
|
||||
place: place_inside_1,
|
||||
started_at: 3.hours.ago,
|
||||
ended_at: 2.hours.ago
|
||||
)
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
let(:params) do
|
||||
{
|
||||
sw_lat: sw_lat.to_s,
|
||||
sw_lng: sw_lng.to_s,
|
||||
ne_lat: ne_lat.to_s,
|
||||
ne_lng: ne_lng.to_s
|
||||
}
|
||||
end
|
||||
|
||||
subject(:result) { described_class.new(user, params).call }
|
||||
|
||||
it 'returns visits within the specified bounding box' do
|
||||
expect(result).to include(visit_inside_1, visit_inside_2)
|
||||
expect(result).not_to include(visit_outside_1, visit_outside_2)
|
||||
end
|
||||
|
||||
it 'returns visits in descending order by started_at' do
|
||||
expect(result.to_a).to eq([visit_inside_1, visit_inside_2])
|
||||
end
|
||||
|
||||
it 'does not include visits from other users' do
|
||||
expect(result).not_to include(other_user_visit_inside)
|
||||
end
|
||||
|
||||
it 'preloads the place association' do
|
||||
expect(result.first.association(:place)).to be_loaded
|
||||
end
|
||||
|
||||
context 'with an empty bounding box' do
|
||||
let(:params) do
|
||||
{
|
||||
sw_lat: '0',
|
||||
sw_lng: '0',
|
||||
ne_lat: '0',
|
||||
ne_lng: '0'
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns an empty collection' do
|
||||
expect(result).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a very large bounding box' do
|
||||
let(:params) do
|
||||
{
|
||||
sw_lat: '-90',
|
||||
sw_lng: '-180',
|
||||
ne_lat: '90',
|
||||
ne_lng: '180'
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns all visits for the user' do
|
||||
expect(result).to include(visit_inside_1, visit_inside_2, visit_outside_1, visit_outside_2)
|
||||
expect(result).not_to include(other_user_visit_inside)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with string coordinates' do
|
||||
let(:params) do
|
||||
{
|
||||
sw_lat: sw_lat.to_s,
|
||||
sw_lng: sw_lng.to_s,
|
||||
ne_lat: ne_lat.to_s,
|
||||
ne_lng: ne_lng.to_s
|
||||
}
|
||||
end
|
||||
|
||||
it 'converts strings to floats' do
|
||||
expect(result).to include(visit_inside_1, visit_inside_2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
154
spec/services/visits/finder_spec.rb
Normal file
154
spec/services/visits/finder_spec.rb
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::Finder do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe '#call' do
|
||||
context 'when area selection parameters are provided' do
|
||||
let(:area_params) do
|
||||
{
|
||||
selection: 'true',
|
||||
sw_lat: '48.8534',
|
||||
sw_lng: '2.3380',
|
||||
ne_lat: '48.8667',
|
||||
ne_lng: '2.3580'
|
||||
}
|
||||
end
|
||||
|
||||
it 'delegates to FindWithinBoundingBox service' do
|
||||
bounding_box_finder = instance_double(Visits::FindWithinBoundingBox)
|
||||
expect(Visits::FindWithinBoundingBox).to receive(:new)
|
||||
.with(user, area_params)
|
||||
.and_return(bounding_box_finder)
|
||||
|
||||
expect(bounding_box_finder).to receive(:call)
|
||||
|
||||
described_class.new(user, area_params).call
|
||||
end
|
||||
|
||||
it 'does not call FindInTime service' do
|
||||
expect(Visits::FindWithinBoundingBox).to receive(:new).and_call_original
|
||||
expect(Visits::FindInTime).not_to receive(:new)
|
||||
|
||||
described_class.new(user, area_params).call
|
||||
end
|
||||
end
|
||||
|
||||
context 'when time-based parameters are provided' do
|
||||
let(:time_params) do
|
||||
{
|
||||
start_at: Time.zone.now.beginning_of_day.iso8601,
|
||||
end_at: Time.zone.now.end_of_day.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
it 'delegates to FindInTime service' do
|
||||
time_finder = instance_double(Visits::FindInTime)
|
||||
expect(Visits::FindInTime).to receive(:new)
|
||||
.with(user, time_params)
|
||||
.and_return(time_finder)
|
||||
|
||||
expect(time_finder).to receive(:call)
|
||||
|
||||
described_class.new(user, time_params).call
|
||||
end
|
||||
|
||||
it 'does not call FindWithinBoundingBox service' do
|
||||
expect(Visits::FindInTime).to receive(:new).and_call_original
|
||||
expect(Visits::FindWithinBoundingBox).not_to receive(:new)
|
||||
|
||||
described_class.new(user, time_params).call
|
||||
end
|
||||
end
|
||||
|
||||
context 'when selection is true but coordinates are missing' do
|
||||
let(:incomplete_params) do
|
||||
{
|
||||
selection: 'true',
|
||||
sw_lat: '48.8534'
|
||||
# Missing other coordinates
|
||||
}
|
||||
end
|
||||
|
||||
it 'falls back to FindInTime service' do
|
||||
time_finder = instance_double(Visits::FindInTime)
|
||||
expect(Visits::FindInTime).to receive(:new)
|
||||
.with(user, incomplete_params)
|
||||
.and_return(time_finder)
|
||||
|
||||
expect(time_finder).to receive(:call)
|
||||
|
||||
described_class.new(user, incomplete_params).call
|
||||
end
|
||||
end
|
||||
|
||||
context 'when both area and time parameters are provided' do
|
||||
let(:combined_params) do
|
||||
{
|
||||
selection: 'true',
|
||||
sw_lat: '48.8534',
|
||||
sw_lng: '2.3380',
|
||||
ne_lat: '48.8667',
|
||||
ne_lng: '2.3580',
|
||||
start_at: Time.zone.now.beginning_of_day.iso8601,
|
||||
end_at: Time.zone.now.end_of_day.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
it 'prioritizes area search over time search' do
|
||||
bounding_box_finder = instance_double(Visits::FindWithinBoundingBox)
|
||||
expect(Visits::FindWithinBoundingBox).to receive(:new)
|
||||
.with(user, combined_params)
|
||||
.and_return(bounding_box_finder)
|
||||
|
||||
expect(bounding_box_finder).to receive(:call)
|
||||
expect(Visits::FindInTime).not_to receive(:new)
|
||||
|
||||
described_class.new(user, combined_params).call
|
||||
end
|
||||
end
|
||||
|
||||
context 'when selection is not "true"' do
|
||||
let(:params) do
|
||||
{
|
||||
selection: 'false', # explicitly not true
|
||||
sw_lat: '48.8534',
|
||||
sw_lng: '2.3380',
|
||||
ne_lat: '48.8667',
|
||||
ne_lng: '2.3580'
|
||||
}
|
||||
end
|
||||
|
||||
it 'uses FindInTime service' do
|
||||
expect(Visits::FindInTime).to receive(:new).and_call_original
|
||||
expect(Visits::FindWithinBoundingBox).not_to receive(:new)
|
||||
|
||||
described_class.new(user, params).call
|
||||
end
|
||||
end
|
||||
|
||||
context 'edge cases' do
|
||||
context 'with empty params' do
|
||||
let(:empty_params) { {} }
|
||||
|
||||
it 'uses FindInTime service' do
|
||||
# We need to handle the ArgumentError from FindInTime when params are empty
|
||||
expect(Visits::FindInTime).to receive(:new).and_raise(ArgumentError)
|
||||
expect(Visits::FindWithinBoundingBox).not_to receive(:new)
|
||||
|
||||
expect { described_class.new(user, empty_params).call }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nil params' do
|
||||
let(:nil_params) { nil }
|
||||
|
||||
it 'raises an error' do
|
||||
expect { described_class.new(user, nil_params).call }.to raise_error(NoMethodError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -4,25 +4,89 @@ require 'rails_helper'
|
|||
|
||||
RSpec.describe Visits::PlaceFinder do
|
||||
let(:user) { create(:user) }
|
||||
let(:latitude) { 40.7128 }
|
||||
let(:longitude) { -74.0060 }
|
||||
|
||||
subject { described_class.new(user) }
|
||||
|
||||
describe '#find_or_create_place' do
|
||||
let(:visit_data) do
|
||||
{
|
||||
center_lat: 40.7128,
|
||||
center_lon: -74.0060,
|
||||
suggested_name: 'Test Place'
|
||||
center_lat: latitude,
|
||||
center_lon: longitude,
|
||||
suggested_name: 'Test Place',
|
||||
points: []
|
||||
}
|
||||
end
|
||||
|
||||
context 'when an existing place is found' do
|
||||
let!(:existing_place) { create(:place, latitude: 40.7128, longitude: -74.0060) }
|
||||
let!(:existing_place) { create(:place, latitude: latitude, longitude: longitude) }
|
||||
|
||||
it 'returns the existing place' do
|
||||
place = subject.find_or_create_place(visit_data)
|
||||
it 'returns the existing place as main_place' do
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
|
||||
expect(place).to eq(existing_place)
|
||||
expect(result).to be_a(Hash)
|
||||
expect(result[:main_place]).to eq(existing_place)
|
||||
end
|
||||
|
||||
it 'includes suggested places in the result' do
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
|
||||
expect(result[:suggested_places]).to be_an(Array)
|
||||
expect(result[:suggested_places]).to include(existing_place)
|
||||
end
|
||||
|
||||
it 'finds an existing place by name within search radius' do
|
||||
similar_named_place = create(:place,
|
||||
name: 'Test Place',
|
||||
latitude: latitude + 0.0001,
|
||||
longitude: longitude + 0.0001)
|
||||
|
||||
# Use the name but slightly different coordinates
|
||||
modified_visit_data = visit_data.merge(
|
||||
center_lat: latitude + 0.0002,
|
||||
center_lon: longitude + 0.0002
|
||||
)
|
||||
|
||||
result = subject.find_or_create_place(modified_visit_data)
|
||||
|
||||
expect(result[:main_place]).to eq(similar_named_place)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with places from points data' do
|
||||
let(:point_with_geodata) do
|
||||
build_stubbed(:point,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
geodata: {
|
||||
'properties' => {
|
||||
'name' => 'POI from Point',
|
||||
'city' => 'New York',
|
||||
'country' => 'USA'
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
let(:visit_data_with_points) do
|
||||
visit_data.merge(points: [point_with_geodata])
|
||||
end
|
||||
|
||||
before do
|
||||
# Mock external API calls to isolate point-based place creation
|
||||
allow(Geocoder).to receive(:search).and_return([])
|
||||
allow(subject).to receive(:fetch_places_from_api).and_return([])
|
||||
end
|
||||
|
||||
it 'extracts and creates places from point geodata' do
|
||||
allow(subject).to receive(:create_place_from_point).and_call_original
|
||||
|
||||
expect do
|
||||
result = subject.find_or_create_place(visit_data_with_points)
|
||||
expect(result[:main_place].name).to include('POI from Point')
|
||||
end.to change(Place, :count).by(1)
|
||||
|
||||
expect(subject).to have_received(:create_place_from_point)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -37,29 +101,52 @@ RSpec.describe Visits::PlaceFinder do
|
|||
'country' => 'Test Country'
|
||||
}
|
||||
},
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060
|
||||
latitude: latitude,
|
||||
longitude: longitude
|
||||
)
|
||||
end
|
||||
|
||||
let(:other_geocoder_result) do
|
||||
double(
|
||||
data: {
|
||||
'properties' => {
|
||||
'name' => 'Other Location',
|
||||
'street' => 'Other Street',
|
||||
'city' => 'Test City',
|
||||
'country' => 'Test Country'
|
||||
}
|
||||
},
|
||||
latitude: latitude + 0.001,
|
||||
longitude: longitude + 0.001
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Geocoder).to receive(:search).and_return([geocoder_result])
|
||||
allow(subject).to receive(:process_nearby_organizations)
|
||||
allow(Geocoder).to receive(:search).and_return([geocoder_result, other_geocoder_result])
|
||||
end
|
||||
|
||||
it 'creates a new place with geocoded data' do
|
||||
# Main place and other place
|
||||
expect do
|
||||
subject.find_or_create_place(visit_data)
|
||||
end.to change(Place, :count).by(1)
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
expect(result[:main_place].name).to include('Test Location')
|
||||
end.to change(Place, :count).by(2)
|
||||
place = Place.find_by(name: 'Test Location, Test Street')
|
||||
|
||||
place = Place.last
|
||||
|
||||
expect(place.name).to include('Test Location')
|
||||
expect(place.city).to eq('Test City')
|
||||
expect(place.country).to eq('Test Country')
|
||||
expect(place.source).to eq('photon')
|
||||
end
|
||||
|
||||
it 'returns both main place and suggested places' do
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
|
||||
expect(result[:main_place].name).to include('Test Location')
|
||||
expect(result[:suggested_places].length).to eq(2)
|
||||
expect(result[:suggested_places].map(&:name)).to include('Test Location, Test Street',
|
||||
'Other Location, Other Street')
|
||||
end
|
||||
|
||||
context 'when geocoding returns no results' do
|
||||
before do
|
||||
allow(Geocoder).to receive(:search).and_return([])
|
||||
|
|
@ -67,14 +154,120 @@ RSpec.describe Visits::PlaceFinder do
|
|||
|
||||
it 'creates a place with the suggested name' do
|
||||
expect do
|
||||
subject.find_or_create_place(visit_data)
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
expect(result[:main_place].name).to eq('Test Place')
|
||||
end.to change(Place, :count).by(1)
|
||||
|
||||
place = Place.last
|
||||
|
||||
expect(place.name).to eq('Test Place')
|
||||
expect(place.source).to eq('manual')
|
||||
end
|
||||
|
||||
it 'returns the created place as both main and the only suggested place' do
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
|
||||
expect(result[:main_place].name).to eq('Test Place')
|
||||
expect(result[:suggested_places]).to eq([result[:main_place]])
|
||||
end
|
||||
|
||||
it 'falls back to default name when suggested name is missing' do
|
||||
visit_data_without_name = visit_data.merge(suggested_name: nil)
|
||||
|
||||
result = subject.find_or_create_place(visit_data_without_name)
|
||||
|
||||
expect(result[:main_place].name).to eq(Place::DEFAULT_NAME)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple potential places' do
|
||||
let!(:place1) { create(:place, name: 'Place 1', latitude: latitude, longitude: longitude) }
|
||||
let!(:place2) { create(:place, name: 'Place 2', latitude: latitude + 0.0005, longitude: longitude + 0.0005) }
|
||||
let!(:place3) { create(:place, name: 'Place 3', latitude: latitude + 0.001, longitude: longitude + 0.001) }
|
||||
|
||||
it 'selects the closest place as main_place' do
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
|
||||
expect(result[:main_place]).to eq(place1)
|
||||
end
|
||||
|
||||
it 'includes nearby places as suggested_places' do
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
|
||||
expect(result[:suggested_places]).to include(place1, place2)
|
||||
# place3 might be outside the search radius depending on the constants defined
|
||||
end
|
||||
|
||||
it 'deduplicates places by name' do
|
||||
# Create a duplicate place with the same name
|
||||
create(:place, name: 'Place 1', latitude: latitude + 0.0002, longitude: longitude + 0.0002)
|
||||
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
|
||||
names = result[:suggested_places].map(&:name)
|
||||
expect(names.count('Place 1')).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with API place creation failures' do
|
||||
let(:invalid_geocoder_result) do
|
||||
double(
|
||||
data: {
|
||||
'properties' => {
|
||||
# Missing required fields
|
||||
}
|
||||
},
|
||||
latitude: latitude,
|
||||
longitude: longitude
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Geocoder).to receive(:search).and_return([invalid_geocoder_result])
|
||||
end
|
||||
|
||||
it 'gracefully handles errors in place creation' do
|
||||
allow(subject).to receive(:create_place_from_api_result).and_call_original
|
||||
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
|
||||
# Should create the default place
|
||||
expect(result[:main_place].name).to eq('Test Place')
|
||||
expect(result[:main_place].source).to eq('manual')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
context '#build_place_name' do
|
||||
it 'combines name components correctly' do
|
||||
properties = {
|
||||
'name' => 'Coffee Shop',
|
||||
'street' => 'Main St',
|
||||
'housenumber' => '123',
|
||||
'city' => 'New York'
|
||||
}
|
||||
|
||||
name = subject.send(:build_place_name, properties)
|
||||
expect(name).to eq('Coffee Shop, Main St, 123, New York')
|
||||
end
|
||||
|
||||
it 'removes duplicate components' do
|
||||
properties = {
|
||||
'name' => 'Coffee Shop',
|
||||
'street' => 'Coffee Shop', # Duplicate of name
|
||||
'city' => 'New York'
|
||||
}
|
||||
|
||||
name = subject.send(:build_place_name, properties)
|
||||
expect(name).to eq('Coffee Shop, New York')
|
||||
end
|
||||
|
||||
it 'returns default name when no components are available' do
|
||||
properties = { 'other' => 'irrelevant' }
|
||||
|
||||
name = subject.send(:build_place_name, properties)
|
||||
expect(name).to eq(Place::DEFAULT_NAME)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
161
spec/services/visits/time_chunks_spec.rb
Normal file
161
spec/services/visits/time_chunks_spec.rb
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::TimeChunks do
|
||||
describe '#call' do
|
||||
context 'with a multi-year span' do
|
||||
it 'splits time correctly across year boundaries' do
|
||||
# Span over multiple years
|
||||
start_at = DateTime.new(2020, 6, 15)
|
||||
end_at = DateTime.new(2023, 3, 10)
|
||||
|
||||
service = described_class.new(start_at: start_at, end_at: end_at)
|
||||
chunks = service.call
|
||||
|
||||
# Should have 4 chunks:
|
||||
# 1. 2020-06-15 to 2021-01-01
|
||||
# 2. 2021-01-01 to 2022-01-01
|
||||
# 3. 2022-01-01 to 2023-01-01
|
||||
# 4. 2023-01-01 to 2023-03-10
|
||||
expect(chunks.size).to eq(4)
|
||||
|
||||
# First chunk: partial year (Jun 15 - Jan 1)
|
||||
expect(chunks[0].begin).to eq(start_at)
|
||||
expect(chunks[0].end).to eq(DateTime.new(2020, 12, 31).end_of_day)
|
||||
|
||||
# Second chunk: full year 2021
|
||||
expect(chunks[1].begin).to eq(DateTime.new(2021, 1, 1).beginning_of_year)
|
||||
expect(chunks[1].end).to eq(DateTime.new(2021, 12, 31).end_of_year)
|
||||
|
||||
# Third chunk: full year 2022
|
||||
expect(chunks[2].begin).to eq(DateTime.new(2022, 1, 1).beginning_of_year)
|
||||
expect(chunks[2].end).to eq(DateTime.new(2022, 12, 31).end_of_year)
|
||||
|
||||
# Fourth chunk: partial year (Jan 1 - Mar 10, 2023)
|
||||
expect(chunks[3].begin).to eq(DateTime.new(2023, 1, 1).beginning_of_year)
|
||||
expect(chunks[3].end).to eq(end_at)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a span within a single year' do
|
||||
it 'creates a single chunk ending at year end' do
|
||||
start_at = DateTime.new(2020, 3, 15)
|
||||
end_at = DateTime.new(2020, 10, 20)
|
||||
|
||||
service = described_class.new(start_at: start_at, end_at: end_at)
|
||||
chunks = service.call
|
||||
|
||||
expect(chunks.size).to eq(1)
|
||||
expect(chunks[0].begin).to eq(start_at)
|
||||
# The implementation appears to extend to the end of the year
|
||||
expect(chunks[0].end).to eq(DateTime.new(2020, 12, 31).end_of_day)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with spans exactly on year boundaries' do
|
||||
it 'creates one chunk per year ending at next year start' do
|
||||
start_at = DateTime.new(2020, 1, 1)
|
||||
end_at = DateTime.new(2022, 12, 31).end_of_day
|
||||
|
||||
service = described_class.new(start_at: start_at, end_at: end_at)
|
||||
chunks = service.call
|
||||
|
||||
expect(chunks.size).to eq(3)
|
||||
|
||||
# Three full years, each ending at the start of the next year
|
||||
expect(chunks[0].begin).to eq(DateTime.new(2020, 1, 1).beginning_of_year)
|
||||
expect(chunks[0].end).to eq(DateTime.new(2020, 12, 31).end_of_year)
|
||||
|
||||
expect(chunks[1].begin).to eq(DateTime.new(2021, 1, 1).beginning_of_year)
|
||||
expect(chunks[1].end).to eq(DateTime.new(2021, 12, 31).end_of_year)
|
||||
|
||||
expect(chunks[2].begin).to eq(DateTime.new(2022, 1, 1).beginning_of_year)
|
||||
expect(chunks[2].end).to eq(DateTime.new(2022, 12, 31).end_of_year)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with start and end dates in the same day' do
|
||||
it 'returns a single chunk ending at the end of the year' do
|
||||
date = DateTime.new(2020, 5, 15)
|
||||
start_at = date.beginning_of_day
|
||||
end_at = date.end_of_day
|
||||
|
||||
service = described_class.new(start_at: start_at, end_at: end_at)
|
||||
chunks = service.call
|
||||
|
||||
expect(chunks.size).to eq(1)
|
||||
expect(chunks[0].begin).to eq(start_at)
|
||||
# Implementation extends to end of year
|
||||
expect(chunks[0].end).to eq(DateTime.new(2020, 12, 31).end_of_day)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a full single year' do
|
||||
it 'returns a single chunk for the entire year' do
|
||||
start_at = DateTime.new(2020, 1, 1).beginning_of_day
|
||||
end_at = DateTime.new(2020, 12, 31).end_of_day
|
||||
|
||||
service = described_class.new(start_at: start_at, end_at: end_at)
|
||||
chunks = service.call
|
||||
|
||||
expect(chunks.size).to eq(1)
|
||||
expect(chunks[0].begin).to eq(start_at)
|
||||
expect(chunks[0].end).to eq(end_at)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with dates spanning a decade' do
|
||||
it 'creates appropriate chunks for each year ending at next year start' do
|
||||
start_at = DateTime.new(2020, 1, 1)
|
||||
end_at = DateTime.new(2030, 12, 31)
|
||||
|
||||
service = described_class.new(start_at: start_at, end_at: end_at)
|
||||
chunks = service.call
|
||||
|
||||
# Should have 11 chunks (2020 through 2030)
|
||||
expect(chunks.size).to eq(11)
|
||||
|
||||
# Check first and last chunks
|
||||
expect(chunks.first.begin).to eq(start_at)
|
||||
expect(chunks.last.end).to eq(end_at)
|
||||
|
||||
# Check that each chunk starts on Jan 1 and ends on next Jan 1 (except last)
|
||||
(1...chunks.size - 1).each do |i|
|
||||
year = 2020 + i
|
||||
expect(chunks[i].begin).to eq(DateTime.new(year, 1, 1).beginning_of_year)
|
||||
expect(chunks[i].end).to eq(DateTime.new(year, 12, 31).end_of_year)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with start date after end date' do
|
||||
it 'still creates a chunk for start date year' do
|
||||
start_at = DateTime.new(2023, 1, 1)
|
||||
end_at = DateTime.new(2020, 1, 1)
|
||||
|
||||
service = described_class.new(start_at: start_at, end_at: end_at)
|
||||
chunks = service.call
|
||||
|
||||
# The implementation creates one chunk for the start date year
|
||||
expect(chunks.size).to eq(1)
|
||||
expect(chunks[0].begin).to eq(start_at)
|
||||
expect(chunks[0].end).to eq(DateTime.new(2023, 12, 31).end_of_day)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when start date equals end date' do
|
||||
it 'returns a single chunk extending to year end' do
|
||||
date = DateTime.new(2022, 6, 15, 12, 30)
|
||||
|
||||
service = described_class.new(start_at: date, end_at: date)
|
||||
chunks = service.call
|
||||
|
||||
expect(chunks.size).to eq(1)
|
||||
expect(chunks[0].begin).to eq(date)
|
||||
# Implementation extends to end of year
|
||||
expect(chunks[0].end).to eq(DateTime.new(2022, 12, 31).end_of_day)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue