Merge pull request #1717 from Freika/feature/search

Feature/search
This commit is contained in:
Evgenii Burmakin 2025-09-03 23:42:46 +02:00 committed by GitHub
commit c471534acf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 3277 additions and 13 deletions

View file

@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
# [UNRELEASED]
The Search release
In this release we're introducing a new search feature that allows users to search for places and see when they visited them. On the map page, click on Search icon, enter a place name (e.g. "Alexanderplatz"), wait for suggestions to load, and click on the suggestion you want to search for. You then will see a list of years you visited that place. Click on the year to unfold list of visits for that year. Then click on the visit you want to see on the map and you will be moved to that visit on the map. From the opened visit popup you can create a new visit to save it in the database.
Important: This feature relies on reverse geocoding. Without reverse geocoding, the search feature will not work.
## Added
- User can now search for places and see when they visited them.
## Fixed
- Default value for `points_count` attribute is now set to 0 in the User model.

219
CLAUDE.md Normal file
View file

@ -0,0 +1,219 @@
# CLAUDE.md - Dawarich Development Guide
This file contains essential information for Claude to work effectively with the Dawarich codebase.
## Project Overview
**Dawarich** is a self-hostable web application built with Ruby on Rails 8.0 that serves as a replacement for Google Timeline (Google Location History). It allows users to track, visualize, and analyze their location data through an interactive web interface.
### Key Features
- Location history tracking and visualization
- Interactive maps with multiple layers (heatmap, points, lines, fog of war)
- Import from various sources (Google Maps Timeline, OwnTracks, Strava, GPX, GeoJSON, photos)
- Export to GeoJSON and GPX formats
- Statistics and analytics (countries visited, distance traveled, etc.)
- Trips management with photo integration
- Areas and visits tracking
- Integration with photo management systems (Immich, Photoprism)
## Technology Stack
### Backend
- **Framework**: Ruby on Rails 8.0
- **Database**: PostgreSQL with PostGIS extension
- **Background Jobs**: Sidekiq with Redis
- **Authentication**: Devise
- **Authorization**: Pundit
- **API Documentation**: rSwag (Swagger)
- **Monitoring**: Prometheus, Sentry
- **File Processing**: AWS S3 integration
### Frontend
- **CSS Framework**: Tailwind CSS with DaisyUI components
- **JavaScript**: Stimulus, Turbo Rails, Hotwired
- **Maps**: Leaflet.js
- **Charts**: Chartkick
### Key Gems
- `activerecord-postgis-adapter` - PostgreSQL PostGIS support
- `geocoder` - Geocoding services
- `rgeo` - Ruby Geometric Library
- `gpx` - GPX file processing
- `parallel` - Parallel processing
- `sidekiq` - Background job processing
- `chartkick` - Chart generation
## Project Structure
```
├── app/
│ ├── controllers/ # Rails controllers
│ ├── models/ # ActiveRecord models with PostGIS support
│ ├── views/ # ERB templates
│ ├── services/ # Business logic services
│ ├── jobs/ # Sidekiq background jobs
│ ├── queries/ # Database query objects
│ ├── policies/ # Pundit authorization policies
│ ├── serializers/ # API response serializers
│ ├── javascript/ # Stimulus controllers and JS
│ └── assets/ # CSS and static assets
├── config/ # Rails configuration
├── db/ # Database migrations and seeds
├── docker/ # Docker configuration
├── spec/ # RSpec test suite
└── swagger/ # API documentation
```
## Core Models
### Primary Models
- **User**: Authentication and user management
- **Point**: Individual location points with coordinates and timestamps
- **Track**: Collections of related points forming routes
- **Area**: Geographic areas drawn by users
- **Visit**: Detected visits to areas
- **Trip**: User-defined travel periods with analytics
- **Import**: Data import operations
- **Export**: Data export operations
- **Stat**: Calculated statistics and metrics
### Geographic Features
- Uses PostGIS for advanced geographic queries
- Implements distance calculations and spatial relationships
- Supports various coordinate systems and projections
## Development Environment
### Setup
1. **Docker Development**: Use `docker-compose -f docker/docker-compose.yml up`
2. **DevContainer**: VS Code devcontainer support available
3. **Local Development**:
- `bundle exec rails db:prepare`
- `bundle exec sidekiq` (background jobs)
- `bundle exec bin/dev` (main application)
### Default Credentials
- Username: `demo@dawarich.app`
- Password: `password`
## Testing
### Test Suite
- **Framework**: RSpec
- **System Tests**: Capybara + Selenium WebDriver
- **E2E Tests**: Playwright
- **Coverage**: SimpleCov
- **Factories**: FactoryBot
- **Mocking**: WebMock
### Test Commands
```bash
bundle exec rspec # Run all specs
bundle exec rspec spec/models/ # Model specs only
npx playwright test # E2E tests
```
## Background Jobs
### Sidekiq Jobs
- **Import Jobs**: Process uploaded location data files
- **Calculation Jobs**: Generate statistics and analytics
- **Notification Jobs**: Send user notifications
- **Photo Processing**: Extract EXIF data from photos
### Key Job Classes
- `Tracks::ParallelGeneratorJob` - Generate track data in parallel
- Various import jobs for different data sources
- Statistical calculation jobs
## API Documentation
- **Framework**: rSwag (Swagger/OpenAPI)
- **Location**: `/api-docs` endpoint
- **Authentication**: JWT-based for API access
## Database Schema
### Key Tables
- `users` - User accounts and settings
- `points` - Location points with PostGIS geometry
- `tracks` - Route collections
- `areas` - User-defined geographic areas
- `visits` - Detected area visits
- `trips` - Travel periods
- `imports`/`exports` - Data transfer operations
- `stats` - Calculated metrics
### PostGIS Integration
- Extensive use of PostGIS geometry types
- Spatial indexes for performance
- Geographic calculations and queries
## Configuration
### Environment Variables
See `.env.template` for available configuration options including:
- Database configuration
- Redis settings
- AWS S3 credentials
- External service integrations
- Feature flags
### Key Config Files
- `config/database.yml` - Database configuration
- `config/sidekiq.yml` - Background job settings
- `config/schedule.yml` - Cron job schedules
- `docker/docker-compose.yml` - Development environment
## Deployment
### Docker
- Production: `docker/docker-compose.production.yml`
- Development: `docker/docker-compose.yml`
- Multi-stage Docker builds supported
### Procfiles
- `Procfile` - Production Heroku deployment
- `Procfile.dev` - Development with Foreman
- `Procfile.production` - Production processes
## Code Quality
### Tools
- **Linting**: RuboCop with Rails extensions
- **Security**: Brakeman, bundler-audit
- **Dependencies**: Strong Migrations for safe database changes
- **Performance**: Stackprof for profiling
### Commands
```bash
bundle exec rubocop # Code linting
bundle exec brakeman # Security scan
bundle exec bundle-audit # Dependency security
```
## Important Notes for Development
1. **Location Data**: Always handle location data with appropriate precision and privacy considerations
2. **PostGIS**: Leverage PostGIS features for geographic calculations rather than Ruby-based solutions
2.1 **Coordinates**: Use `lonlat` column in `points` table for geographic calculations
3. **Background Jobs**: Use Sidekiq for any potentially long-running operations
4. **Testing**: Include both unit and integration tests for location-based features
5. **Performance**: Consider database indexes for geographic queries
6. **Security**: Never log or expose user location data inappropriately
## Contributing
- **Main Branch**: `master`
- **Development**: `dev` branch for pull requests
- **Issues**: GitHub Issues for bug reports
- **Discussions**: GitHub Discussions for feature requests
- **Community**: Discord server for questions
## Resources
- **Documentation**: https://dawarich.app/docs/
- **Repository**: https://github.com/Freika/dawarich
- **Discord**: https://discord.gg/pHsBjpt5J8
- **Changelog**: See CHANGELOG.md for version history
- **Development Setup**: See DEVELOPMENT.md

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,99 @@
# frozen_string_literal: true
class Api::V1::LocationsController < ApiController
before_action :validate_search_params, only: [:index]
before_action :validate_suggestion_params, only: [:suggestions]
def index
if coordinate_search?
search_results = LocationSearch::PointFinder.new(current_api_user, search_params).call
render json: Api::LocationSearchResultSerializer.new(search_results).call
else
render json: { error: 'Coordinates (lat, lon) are required' }, status: :bad_request
end
rescue StandardError => e
Rails.logger.error "Location search error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: { error: 'Search failed. Please try again.' }, status: :internal_server_error
end
def suggestions
if search_query.present? && search_query.length >= 2
suggestions = LocationSearch::GeocodingService.new(search_query).search
# Format suggestions for the frontend
formatted_suggestions = suggestions.map do |suggestion|
{
name: suggestion[:name],
address: suggestion[:address],
coordinates: [suggestion[:lat], suggestion[:lon]],
type: suggestion[:type]
}
end
render json: { suggestions: formatted_suggestions }
else
render json: { suggestions: [] }
end
rescue StandardError => e
Rails.logger.error "Suggestions error: #{e.message}"
render json: { suggestions: [] }
end
private
def search_query
params[:q]&.strip
end
def search_params
{
latitude: params[:lat]&.to_f,
longitude: params[:lon]&.to_f,
limit: params[:limit]&.to_i || 50,
date_from: parse_date(params[:date_from]),
date_to: parse_date(params[:date_to]),
radius_override: params[:radius_override]&.to_i
}
end
def coordinate_search?
params[:lat].present? && params[:lon].present?
end
def validate_search_params
unless coordinate_search?
render json: { error: 'Coordinates (lat, lon) are required' }, status: :bad_request
return false
end
lat = params[:lat]&.to_f
lon = params[:lon]&.to_f
if lat.abs > 90 || lon.abs > 180
render json: { error: 'Invalid coordinates: latitude must be between -90 and 90, longitude between -180 and 180' },
status: :bad_request
return false
end
true
end
def validate_suggestion_params
if search_query.present? && search_query.length > 200
render json: { error: 'Search query too long (max 200 characters)' }, status: :bad_request
return false
end
true
end
def parse_date(date_string)
return nil if date_string.blank?
Date.parse(date_string)
rescue ArgumentError
nil
end
end

View file

@ -36,6 +36,8 @@ class MapController < ApplicationController
end
def calculate_distance
return 0 if @coordinates.size < 2
total_distance = 0
@coordinates.each_cons(2) do

View file

@ -36,6 +36,7 @@ import { fetchAndDisplayPhotos } from "../maps/photos";
import { countryCodesMap } from "../maps/country_codes";
import { VisitsManager } from "../maps/visits";
import { ScratchLayer } from "../maps/scratch_layer";
import { LocationSearch } from "../maps/location_search";
import "leaflet-draw";
import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war";
@ -189,6 +190,9 @@ export default class extends BaseController {
// Initialize the visits manager
this.visitsManager = new VisitsManager(this.map, this.apiKey);
// Expose visits manager globally for location search integration
window.visitsManager = this.visitsManager;
// Initialize layers for the layer control
const controlsLayer = {
@ -239,6 +243,9 @@ export default class extends BaseController {
// Initialize Live Map Handler
this.initializeLiveMapHandler();
// Initialize Location Search
this.initializeLocationSearch();
}
disconnect() {
@ -1824,4 +1831,10 @@ export default class extends BaseController {
toggleTracksVisibility(this.tracksLayer, this.map, this.tracksVisible);
}
}
initializeLocationSearch() {
if (this.map && this.apiKey) {
this.locationSearch = new LocationSearch(this.map, this.apiKey);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1572,7 +1572,7 @@ export class VisitsManager {
// Show confirmation dialog
const confirmDelete = confirm('Are you sure you want to delete this visit? This action cannot be undone.');
if (!confirmDelete) {
return;
}

View file

@ -0,0 +1,47 @@
# frozen_string_literal: true
class Api::LocationSearchResultSerializer
def initialize(search_result)
@search_result = search_result
end
def call
{
query: @search_result[:query],
locations: serialize_locations(@search_result[:locations]),
total_locations: @search_result[:total_locations],
search_metadata: @search_result[:search_metadata]
}
end
private
def serialize_locations(locations)
locations.map do |location|
{
place_name: location[:place_name],
coordinates: location[:coordinates],
address: location[:address],
total_visits: location[:total_visits],
first_visit: location[:first_visit],
last_visit: location[:last_visit],
visits: serialize_visits(location[:visits])
}
end
end
def serialize_visits(visits)
visits.map do |visit|
{
timestamp: visit[:timestamp],
date: visit[:date],
coordinates: visit[:coordinates],
distance_meters: visit[:distance_meters],
duration_estimate: visit[:duration_estimate],
points_count: visit[:points_count],
accuracy_meters: visit[:accuracy_meters],
visit_details: visit[:visit_details]
}
end
end
end

View file

@ -0,0 +1,96 @@
# frozen_string_literal: true
module LocationSearch
class GeocodingService
MAX_RESULTS = 10
def initialize(query)
@query = query
end
def search
return [] if query.blank?
perform_geocoding_search(query)
rescue StandardError => e
Rails.logger.error "Geocoding search failed for query '#{query}': #{e.message}"
[]
end
def provider_name
Geocoder.config.lookup.to_s.capitalize
end
private
attr_reader :query
def perform_geocoding_search(query)
results = Geocoder.search(query, limit: MAX_RESULTS)
return [] if results.blank?
normalize_geocoding_results(results)
end
def normalize_geocoding_results(results)
normalized_results = results.filter_map do |result|
lat = result.latitude.to_f
lon = result.longitude.to_f
next unless valid_coordinates?(lat, lon)
{
lat: lat,
lon: lon,
name: result.address&.split(',')&.first || 'Unknown location',
address: result.address || '',
type: result.data&.dig('type') || result.data&.dig('class') || 'unknown',
provider_data: {
osm_id: result.data&.dig('osm_id'),
place_rank: result.data&.dig('place_rank'),
importance: result.data&.dig('importance')
}
}
end
deduplicate_results(normalized_results)
end
def deduplicate_results(results)
deduplicated = []
results.each do |result|
# Check if there's already a result within 100m
duplicate = deduplicated.find do |existing|
distance = calculate_distance_in_meters(
result[:lat], result[:lon],
existing[:lat], existing[:lon]
)
distance < 100 # meters
end
deduplicated << result unless duplicate
end
deduplicated
end
def calculate_distance_in_meters(lat1, lon1, lat2, lon2)
# Use Geocoder's distance calculation (same as in Distanceable concern)
distance_km = Geocoder::Calculations.distance_between(
[lat1, lon1],
[lat2, lon2],
units: :km
)
# Convert to meters and handle potential nil/invalid results
return 0 unless distance_km.is_a?(Numeric) && distance_km.finite?
distance_km * 1000 # Convert km to meters
end
def valid_coordinates?(lat, lon)
lat.between?(-90, 90) && lon.between?(-180, 180)
end
end
end

View file

@ -0,0 +1,122 @@
# frozen_string_literal: true
module LocationSearch
class PointFinder
def initialize(user, params = {})
@user = user
@latitude = params[:latitude]
@longitude = params[:longitude]
@limit = params[:limit] || 50
@date_from = params[:date_from]
@date_to = params[:date_to]
@radius_override = params[:radius_override]
end
def call
return empty_result unless valid_coordinates?
location = {
lat: @latitude,
lon: @longitude,
type: 'coordinate_search'
}
find_matching_points([location])
end
private
def find_matching_points(geocoded_locations)
results = []
geocoded_locations.each do |location|
search_radius = @radius_override || determine_search_radius(location[:type])
matching_points = spatial_matcher.find_points_near(
@user,
location[:lat],
location[:lon],
search_radius,
date_filter_options
)
if matching_points.empty?
wider_search = spatial_matcher.find_points_near(
@user,
location[:lat],
location[:lon],
1000, # 1km radius for debugging
date_filter_options
)
next
end
visits = result_aggregator.group_points_into_visits(matching_points)
results << {
place_name: location[:name],
coordinates: [location[:lat], location[:lon]],
address: location[:address],
total_visits: visits.length,
first_visit: visits.first[:date],
last_visit: visits.last[:date],
visits: visits.take(@limit)
}
end
{
locations: results,
total_locations: results.length,
search_metadata: {}
}
end
def spatial_matcher
@spatial_matcher ||= LocationSearch::SpatialMatcher.new
end
def result_aggregator
@result_aggregator ||= LocationSearch::ResultAggregator.new
end
def date_filter_options
{
date_from: @date_from,
date_to: @date_to
}
end
def determine_search_radius(location_type)
case location_type.to_s.downcase
when 'shop', 'store', 'retail'
75 # Small radius for specific shops
when 'restaurant', 'cafe', 'food'
75 # Small radius for specific restaurants
when 'building', 'house', 'address'
50 # Very small radius for specific addresses
when 'street', 'road'
50 # Very small radius for streets
when 'neighbourhood', 'neighborhood', 'district', 'suburb'
300 # Medium radius for neighborhoods
when 'city', 'town', 'village'
1000 # Large radius for cities
else
500 # Default radius for unknown types
end
end
def valid_coordinates?
@latitude.present? && @longitude.present? &&
@latitude.to_f.between?(-90, 90) && @longitude.to_f.between?(-180, 180)
end
def empty_result
{
locations: [],
total_locations: 0,
search_metadata: {}
}
end
end
end

View file

@ -0,0 +1,110 @@
# frozen_string_literal: true
module LocationSearch
class ResultAggregator
include ActionView::Helpers::TextHelper
# Time threshold for grouping consecutive points into visits (minutes)
VISIT_TIME_THRESHOLD = 30
def group_points_into_visits(points)
return [] if points.empty?
# Sort points by timestamp to handle unordered input
sorted_points = points.sort_by { |p| p[:timestamp] }
visits = []
current_visit_points = []
sorted_points.each do |point|
if current_visit_points.empty? || within_visit_threshold?(current_visit_points.last, point)
current_visit_points << point
else
# Finalize current visit and start a new one
visits << create_visit_from_points(current_visit_points) if current_visit_points.any?
current_visit_points = [point]
end
end
# Don't forget the last visit
visits << create_visit_from_points(current_visit_points) if current_visit_points.any?
visits.sort_by { |visit| -visit[:timestamp] } # Most recent first
end
private
def within_visit_threshold?(previous_point, current_point)
time_diff = (current_point[:timestamp] - previous_point[:timestamp]).abs / 60.0 # minutes
time_diff <= VISIT_TIME_THRESHOLD
end
def create_visit_from_points(points)
return nil if points.empty?
# Sort points by timestamp to get chronological order
sorted_points = points.sort_by { |p| p[:timestamp] }
first_point = sorted_points.first
last_point = sorted_points.last
# Calculate visit duration
duration_minutes = if sorted_points.length > 1
((last_point[:timestamp] - first_point[:timestamp]) / 60.0).round
else
# Single point visit - estimate based on typical stay time
15 # minutes
end
# Find the most accurate point (lowest accuracy value means higher precision)
most_accurate_point = points.min_by { |p| p[:accuracy] || 999999 }
# Calculate average distance from search center
average_distance = (points.sum { |p| p[:distance_meters] } / points.length).round(2)
{
timestamp: first_point[:timestamp],
date: first_point[:date],
coordinates: most_accurate_point[:coordinates],
distance_meters: average_distance,
duration_estimate: format_duration(duration_minutes),
points_count: points.length,
accuracy_meters: most_accurate_point[:accuracy],
visit_details: {
start_time: first_point[:date],
end_time: last_point[:date],
duration_minutes: duration_minutes,
city: most_accurate_point[:city],
country: most_accurate_point[:country],
altitude_range: calculate_altitude_range(points)
}
}
end
def format_duration(minutes)
return "~#{pluralize(minutes, 'minute')}" if minutes < 60
hours = minutes / 60
remaining_minutes = minutes % 60
if remaining_minutes == 0
"~#{pluralize(hours, 'hour')}"
else
"~#{pluralize(hours, 'hour')} #{pluralize(remaining_minutes, 'minute')}"
end
end
def calculate_altitude_range(points)
altitudes = points.map { |p| p[:altitude] }.compact
return nil if altitudes.empty?
min_altitude = altitudes.min
max_altitude = altitudes.max
if min_altitude == max_altitude
"#{min_altitude}m"
else
"#{min_altitude}m - #{max_altitude}m"
end
end
end
end

View file

@ -0,0 +1,79 @@
# frozen_string_literal: true
module LocationSearch
class SpatialMatcher
def initialize
# Using PostGIS for efficient spatial queries
end
def find_points_near(user, latitude, longitude, radius_meters, date_options = {})
points_query = build_spatial_query(user, latitude, longitude, radius_meters, date_options)
ActiveRecord::Base.connection.exec_query(points_query)
.map { |row| format_point_result(row) }
.sort_by { |point| point[:timestamp] }
.reverse # Most recent first
end
private
def build_spatial_query(user, latitude, longitude, radius_meters, date_options = {})
date_filter = build_date_filter(date_options)
<<~SQL
SELECT
p.id,
p.timestamp,
ST_Y(p.lonlat::geometry) as latitude,
ST_X(p.lonlat::geometry) as longitude,
p.city,
p.country,
p.altitude,
p.accuracy,
ST_Distance(p.lonlat, ST_Point(#{longitude}, #{latitude})::geography) as distance_meters,
TO_TIMESTAMP(p.timestamp) as recorded_at
FROM points p
WHERE p.user_id = #{user.id}
AND ST_DWithin(p.lonlat, ST_Point(#{longitude}, #{latitude})::geography, #{radius_meters})
#{date_filter}
ORDER BY p.timestamp DESC;
SQL
end
def build_date_filter(date_options)
return '' unless date_options[:date_from] || date_options[:date_to]
filters = []
if date_options[:date_from]
timestamp_from = date_options[:date_from].to_time.to_i
filters << "p.timestamp >= #{timestamp_from}"
end
if date_options[:date_to]
# Add one day to include the entire end date
timestamp_to = (date_options[:date_to] + 1.day).to_time.to_i
filters << "p.timestamp < #{timestamp_to}"
end
return '' if filters.empty?
"AND #{filters.join(' AND ')}"
end
def format_point_result(row)
{
id: row['id'].to_i,
timestamp: row['timestamp'].to_i,
coordinates: [row['latitude'].to_f, row['longitude'].to_f],
city: row['city'],
country: row['country'],
altitude: row['altitude']&.to_i,
accuracy: row['accuracy']&.to_i,
distance_meters: row['distance_meters'].to_f.round(2),
recorded_at: row['recorded_at'],
date: Time.zone.at(row['timestamp'].to_i).iso8601
}
end
end
end

View file

@ -100,6 +100,11 @@ Rails.application.routes.draw do
get 'users/me', to: 'users#me'
resources :areas, only: %i[index create update destroy]
resources :locations, only: %i[index] do
collection do
get 'suggestions'
end
end
resources :points, only: %i[index create update destroy]
resources :visits, only: %i[index create update destroy] do
get 'possible_places', to: 'visits/possible_places#index', on: :member

View file

@ -28,7 +28,7 @@ FactoryBot.define do
course { nil }
course_accuracy { nil }
external_track_id { nil }
lonlat { "POINT(#{FFaker::Geolocation.lng} #{FFaker::Geolocation.lat})" }
lonlat { "POINT(#{longitude} #{latitude})" }
user
country_id { nil }

View file

@ -7,10 +7,13 @@ RSpec.describe DataMigrations::MigratePointsLatlonJob, type: :job do
it 'updates the lonlat column for all tracked points' do
user = create(:user)
point = create(:point, latitude: 2.0, longitude: 1.0, user: user)
# Clear the lonlat to simulate points that need migration
point.update_column(:lonlat, nil)
expect { subject.perform(user.id) }.to change {
point.reload.lonlat
}.to(RGeo::Geographic.spherical_factory.point(1.0, 2.0))
}.from(nil).to(RGeo::Geographic.spherical_factory.point(1.0, 2.0))
end
end
end

View file

@ -0,0 +1,379 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Api::V1::LocationsController, type: :request do
let(:user) { create(:user) }
let(:api_key) { user.api_key }
let(:headers) { { 'Authorization' => "Bearer #{api_key}" } }
describe 'GET /api/v1/locations' do
context 'with valid authentication' do
context 'when coordinates are provided' do
let(:latitude) { 52.5200 }
let(:longitude) { 13.4050 }
let(:mock_search_result) do
{
query: nil,
locations: [
{
place_name: 'Kaufland Mitte',
coordinates: [52.5200, 13.4050],
address: 'Alexanderplatz 1, Berlin',
total_visits: 2,
first_visit: '2024-01-15T09:30:00Z',
last_visit: '2024-03-20T18:45:00Z',
visits: [
{
timestamp: 1711814700,
date: '2024-03-20T18:45:00Z',
coordinates: [52.5201, 13.4051],
distance_meters: 45.5,
duration_estimate: '~25m',
points_count: 8
}
]
}
],
total_locations: 1,
search_metadata: {
geocoding_provider: 'photon',
candidates_found: 3,
search_time_ms: 234
}
}
end
before do
allow_any_instance_of(LocationSearch::PointFinder)
.to receive(:call).and_return(mock_search_result)
end
it 'returns successful response with search results' do
get '/api/v1/locations', params: { lat: latitude, lon: longitude }, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['query']).to be_nil
expect(json_response['locations']).to be_an(Array)
expect(json_response['locations'].first['place_name']).to eq('Kaufland Mitte')
expect(json_response['total_locations']).to eq(1)
end
it 'includes search metadata in response' do
get '/api/v1/locations', params: { lat: latitude, lon: longitude }, headers: headers
json_response = JSON.parse(response.body)
expect(json_response['search_metadata']).to include(
'geocoding_provider' => 'photon',
'candidates_found' => 3
)
end
it 'passes search parameters to PointFinder service' do
expect(LocationSearch::PointFinder)
.to receive(:new)
.with(user, hash_including(
latitude: latitude,
longitude: longitude,
limit: 50,
date_from: nil,
date_to: nil,
radius_override: nil
))
.and_return(double(call: mock_search_result))
get '/api/v1/locations', params: { lat: latitude, lon: longitude }, headers: headers
end
context 'with additional search parameters' do
let(:params) do
{
lat: latitude,
lon: longitude,
limit: 20,
date_from: '2024-01-01',
date_to: '2024-03-31',
radius_override: 200
}
end
it 'passes all parameters to the service' do
expect(LocationSearch::PointFinder)
.to receive(:new)
.with(user, hash_including(
latitude: latitude,
longitude: longitude,
limit: 20,
date_from: Date.parse('2024-01-01'),
date_to: Date.parse('2024-03-31'),
radius_override: 200
))
.and_return(double(call: mock_search_result))
get '/api/v1/locations', params: params, headers: headers
end
end
context 'with invalid date parameters' do
it 'handles invalid date_from gracefully' do
expect {
get '/api/v1/locations', params: { lat: latitude, lon: longitude, date_from: 'invalid-date' }, headers: headers
}.not_to raise_error
expect(response).to have_http_status(:ok)
end
it 'handles invalid date_to gracefully' do
expect {
get '/api/v1/locations', params: { lat: latitude, lon: longitude, date_to: 'invalid-date' }, headers: headers
}.not_to raise_error
expect(response).to have_http_status(:ok)
end
end
end
context 'when no search results are found' do
let(:empty_result) do
{
query: 'NonexistentPlace',
locations: [],
total_locations: 0,
search_metadata: { geocoding_provider: nil, candidates_found: 0, search_time_ms: 0 }
}
end
before do
allow_any_instance_of(LocationSearch::PointFinder)
.to receive(:call).and_return(empty_result)
end
it 'returns empty results successfully' do
get '/api/v1/locations', params: { lat: 0.0, lon: 0.0 }, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['locations']).to be_empty
expect(json_response['total_locations']).to eq(0)
end
end
context 'when coordinates are missing' do
it 'returns bad request error' do
get '/api/v1/locations', headers: headers
expect(response).to have_http_status(:bad_request)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Coordinates (lat, lon) are required')
end
end
context 'when only latitude is provided' do
it 'returns bad request error' do
get '/api/v1/locations', params: { lat: 52.5200 }, headers: headers
expect(response).to have_http_status(:bad_request)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Coordinates (lat, lon) are required')
end
end
context 'when coordinates are invalid' do
it 'returns bad request error for invalid latitude' do
get '/api/v1/locations', params: { lat: 91, lon: 0 }, headers: headers
expect(response).to have_http_status(:bad_request)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Invalid coordinates: latitude must be between -90 and 90, longitude between -180 and 180')
end
it 'returns bad request error for invalid longitude' do
get '/api/v1/locations', params: { lat: 0, lon: 181 }, headers: headers
expect(response).to have_http_status(:bad_request)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Invalid coordinates: latitude must be between -90 and 90, longitude between -180 and 180')
end
end
context 'when service raises an error' do
before do
allow_any_instance_of(LocationSearch::PointFinder)
.to receive(:call).and_raise(StandardError.new('Service error'))
end
it 'returns internal server error' do
get '/api/v1/locations', params: { lat: 52.5200, lon: 13.4050 }, headers: headers
expect(response).to have_http_status(:internal_server_error)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Search failed. Please try again.')
end
end
end
context 'without authentication' do
it 'returns unauthorized error' do
get '/api/v1/locations', params: { lat: 52.5200, lon: 13.4050 }
expect(response).to have_http_status(:unauthorized)
end
end
context 'with invalid API key' do
let(:invalid_headers) { { 'Authorization' => 'Bearer invalid_key' } }
it 'returns unauthorized error' do
get '/api/v1/locations', params: { lat: 52.5200, lon: 13.4050 }, headers: invalid_headers
expect(response).to have_http_status(:unauthorized)
end
end
context 'with user data isolation' do
let(:user1) { create(:user) }
let(:user2) { create(:user) }
let(:user1_headers) { { 'Authorization' => "Bearer #{user1.api_key}" } }
before do
# Create points for both users
create(:point, user: user1, latitude: 52.5200, longitude: 13.4050)
create(:point, user: user2, latitude: 52.5200, longitude: 13.4050)
# Mock service to verify user isolation
allow(LocationSearch::PointFinder).to receive(:new) do |user, _params|
expect(user).to eq(user1) # Should only be called with user1
double(call: { query: nil, locations: [], total_locations: 0, search_metadata: {} })
end
end
it 'only searches within the authenticated user data' do
get '/api/v1/locations', params: { lat: 52.5200, lon: 13.4050 }, headers: user1_headers
expect(response).to have_http_status(:ok)
end
end
end
describe 'GET /api/v1/locations/suggestions' do
context 'with valid authentication' do
let(:mock_suggestions) do
[
{
lat: 52.5200,
lon: 13.4050,
name: 'Kaufland Mitte',
address: 'Alexanderplatz 1, Berlin',
type: 'shop'
},
{
lat: 52.5100,
lon: 13.4000,
name: 'Kaufland Friedrichshain',
address: 'Warschauer Str. 80, Berlin',
type: 'shop'
}
]
end
before do
allow_any_instance_of(LocationSearch::GeocodingService)
.to receive(:search).and_return(mock_suggestions)
end
context 'with valid search query' do
it 'returns formatted suggestions' do
get '/api/v1/locations/suggestions', params: { q: 'Kaufland' }, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['suggestions']).to be_an(Array)
expect(json_response['suggestions'].length).to eq(2)
first_suggestion = json_response['suggestions'].first
expect(first_suggestion).to include(
'name' => 'Kaufland Mitte',
'address' => 'Alexanderplatz 1, Berlin',
'coordinates' => [52.5200, 13.4050],
'type' => 'shop'
)
end
it 'limits suggestions to 10 results' do
large_suggestions = Array.new(10) do |i|
{
lat: 52.5000 + i * 0.001,
lon: 13.4000 + i * 0.001,
name: "Location #{i}",
address: "Address #{i}",
type: 'place'
}
end
allow_any_instance_of(LocationSearch::GeocodingService)
.to receive(:search).and_return(large_suggestions)
get '/api/v1/locations/suggestions', params: { q: 'test' }, headers: headers
json_response = JSON.parse(response.body)
expect(json_response['suggestions'].length).to eq(10)
end
end
context 'with short search query' do
it 'returns empty suggestions for queries shorter than 2 characters' do
get '/api/v1/locations/suggestions', params: { q: 'a' }, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['suggestions']).to be_empty
end
end
context 'with blank query' do
it 'returns empty suggestions' do
get '/api/v1/locations/suggestions', params: { q: '' }, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['suggestions']).to be_empty
end
end
context 'when geocoding service raises an error' do
before do
allow_any_instance_of(LocationSearch::GeocodingService)
.to receive(:search).and_raise(StandardError.new('Geocoding error'))
end
it 'returns empty suggestions gracefully' do
get '/api/v1/locations/suggestions', params: { q: 'test' }, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['suggestions']).to be_empty
end
end
end
context 'without authentication' do
it 'returns unauthorized error' do
get '/api/v1/locations/suggestions', params: { q: 'test' }
expect(response).to have_http_status(:unauthorized)
end
end
end
end

View file

@ -0,0 +1,179 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe LocationSearch::GeocodingService do
let(:query) { 'Kaufland Berlin' }
let(:service) { described_class.new(query) }
describe '#search' do
context 'with valid query' do
let(:mock_geocoder_result) do
double(
'Geocoder::Result',
latitude: 52.5200,
longitude: 13.4050,
address: 'Kaufland, Alexanderplatz 1, Berlin',
data: {
'type' => 'shop',
'osm_id' => '12345',
'place_rank' => 30,
'importance' => 0.8
}
)
end
before do
allow(Geocoder).to receive(:search).and_return([mock_geocoder_result])
allow(Geocoder.config).to receive(:lookup).and_return(:photon)
end
it 'returns normalized geocoding results' do
results = service.search
expect(results).to be_an(Array)
expect(results.first).to include(
lat: 52.5200,
lon: 13.4050,
name: 'Kaufland',
address: 'Kaufland, Alexanderplatz 1, Berlin',
type: 'shop'
)
end
it 'includes provider data' do
results = service.search
expect(results.first[:provider_data]).to include(
osm_id: '12345',
place_rank: 30,
importance: 0.8
)
end
it 'limits results to MAX_RESULTS' do
expect(Geocoder).to receive(:search).with(query, limit: 10)
service.search
end
end
context 'with blank query' do
let(:service) { described_class.new('') }
it 'returns empty array' do
expect(service.search).to eq([])
end
end
context 'when Geocoder returns no results' do
before do
allow(Geocoder).to receive(:search).and_return([])
end
it 'returns empty array' do
expect(service.search).to eq([])
end
end
context 'when Geocoder raises an error' do
before do
allow(Geocoder).to receive(:search).and_raise(StandardError.new('Geocoding error'))
end
it 'handles error gracefully and returns empty array' do
expect(service.search).to eq([])
end
end
context 'with invalid coordinates' do
let(:invalid_result) do
double(
'Geocoder::Result',
latitude: 91.0, # Invalid latitude
longitude: 13.4050,
address: 'Invalid location',
data: {}
)
end
let(:valid_result) do
double(
'Geocoder::Result',
latitude: 52.5200,
longitude: 13.4050,
address: 'Valid location',
data: {}
)
end
before do
allow(Geocoder).to receive(:search).and_return([invalid_result, valid_result])
end
it 'filters out results with invalid coordinates' do
results = service.search
expect(results.length).to eq(1)
expect(results.first[:lat]).to eq(52.5200)
end
end
describe '#deduplicate_results' do
let(:duplicate_results) do
[
{
lat: 52.5200,
lon: 13.4050,
name: 'Location 1',
address: 'Address 1',
type: 'shop',
provider_data: {}
},
{
lat: 52.5201, # Within 100m of first location
lon: 13.4051,
name: 'Location 2',
address: 'Address 2',
type: 'shop',
provider_data: {}
}
]
end
let(:mock_results) do
duplicate_results.map do |result|
double(
'Geocoder::Result',
latitude: result[:lat],
longitude: result[:lon],
address: result[:address],
data: { 'type' => result[:type] }
)
end
end
before do
allow(Geocoder).to receive(:search).and_return(mock_results)
end
it 'removes locations within 100m of each other' do
service = described_class.new('test')
results = service.search
expect(results.length).to eq(1)
expect(results.first[:name]).to eq('Address 1')
end
end
end
describe '#provider_name' do
before do
allow(Geocoder.config).to receive(:lookup).and_return(:nominatim)
end
it 'returns the current geocoding provider name' do
expect(service.provider_name).to eq('Nominatim')
end
end
end

View file

@ -0,0 +1,165 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe LocationSearch::PointFinder do
let(:user) { create(:user) }
let(:service) { described_class.new(user, search_params) }
let(:search_params) { { latitude: 52.5200, longitude: 13.4050 } }
describe '#call' do
context 'with valid coordinates' do
let(:mock_matching_points) do
[
{
id: 1,
timestamp: 1711814700,
coordinates: [52.5201, 13.4051],
distance_meters: 45.5,
date: '2024-03-20T18:45:00Z'
}
]
end
let(:mock_visits) do
[
{
timestamp: 1711814700,
date: '2024-03-20T18:45:00Z',
coordinates: [52.5201, 13.4051],
distance_meters: 45.5,
duration_estimate: '~25m',
points_count: 1
}
]
end
before do
allow_any_instance_of(LocationSearch::SpatialMatcher)
.to receive(:find_points_near).and_return(mock_matching_points)
allow_any_instance_of(LocationSearch::ResultAggregator)
.to receive(:group_points_into_visits).and_return(mock_visits)
end
it 'returns search results with location data' do
result = service.call
expect(result[:locations]).to be_an(Array)
expect(result[:locations].first).to include(
coordinates: [52.5200, 13.4050],
total_visits: 1
)
end
it 'calls spatial matcher with correct coordinates and radius' do
expect_any_instance_of(LocationSearch::SpatialMatcher)
.to receive(:find_points_near)
.with(user, 52.5200, 13.4050, 500, { date_from: nil, date_to: nil })
service.call
end
context 'with custom radius override' do
let(:search_params) { { latitude: 52.5200, longitude: 13.4050, radius_override: 150 } }
it 'uses custom radius when override provided' do
expect_any_instance_of(LocationSearch::SpatialMatcher)
.to receive(:find_points_near)
.with(user, anything, anything, 150, anything)
service.call
end
end
context 'with date filtering' do
let(:search_params) do
{
latitude: 52.5200,
longitude: 13.4050,
date_from: Date.parse('2024-01-01'),
date_to: Date.parse('2024-03-31')
}
end
it 'passes date filters to spatial matcher' do
expect_any_instance_of(LocationSearch::SpatialMatcher)
.to receive(:find_points_near)
.with(user, anything, anything, anything, {
date_from: Date.parse('2024-01-01'),
date_to: Date.parse('2024-03-31')
})
service.call
end
end
context 'with limit parameter' do
let(:search_params) { { latitude: 52.5200, longitude: 13.4050, limit: 10 } }
let(:many_visits) { Array.new(15) { |i| { timestamp: i, date: "2024-01-#{i+1}T12:00:00Z" } } }
before do
allow_any_instance_of(LocationSearch::SpatialMatcher)
.to receive(:find_points_near).and_return([{}])
allow_any_instance_of(LocationSearch::ResultAggregator)
.to receive(:group_points_into_visits).and_return(many_visits)
end
it 'limits the number of visits returned' do
result = service.call
expect(result[:locations].first[:visits].length).to eq(10)
end
end
end
context 'when no matching points found' do
let(:search_params) { { latitude: 52.5200, longitude: 13.4050 } }
before do
allow_any_instance_of(LocationSearch::SpatialMatcher)
.to receive(:find_points_near).and_return([])
end
it 'excludes locations with no visits' do
result = service.call
expect(result[:locations]).to be_empty
expect(result[:total_locations]).to eq(0)
end
end
context 'when coordinates are missing' do
let(:search_params) { {} }
it 'returns empty result without calling services' do
expect(LocationSearch::SpatialMatcher).not_to receive(:new)
result = service.call
expect(result[:locations]).to be_empty
end
end
context 'when only latitude is provided' do
let(:search_params) { { latitude: 52.5200 } }
it 'returns empty result' do
result = service.call
expect(result[:locations]).to be_empty
end
end
context 'when only longitude is provided' do
let(:search_params) { { longitude: 13.4050 } }
it 'returns empty result' do
result = service.call
expect(result[:locations]).to be_empty
end
end
end
end

View file

@ -0,0 +1,307 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe LocationSearch::ResultAggregator do
let(:service) { described_class.new }
describe '#group_points_into_visits' do
context 'with empty points array' do
it 'returns empty array' do
result = service.group_points_into_visits([])
expect(result).to eq([])
end
end
context 'with single point' do
let(:single_point) do
{
id: 1,
timestamp: 1711814700,
coordinates: [52.5200, 13.4050],
distance_meters: 45.5,
accuracy: 10,
date: '2024-03-20T18:45:00Z',
city: 'Berlin',
country: 'Germany',
altitude: 100
}
end
it 'creates a single visit' do
result = service.group_points_into_visits([single_point])
expect(result.length).to eq(1)
visit = result.first
expect(visit[:timestamp]).to eq(1711814700)
expect(visit[:coordinates]).to eq([52.5200, 13.4050])
expect(visit[:points_count]).to eq(1)
end
it 'estimates duration for single point visits' do
result = service.group_points_into_visits([single_point])
visit = result.first
expect(visit[:duration_estimate]).to eq('~15 minutes')
expect(visit[:visit_details][:duration_minutes]).to eq(15)
end
end
context 'with consecutive points' do
let(:consecutive_points) do
[
{
id: 1,
timestamp: 1711814700, # 18:45
coordinates: [52.5200, 13.4050],
distance_meters: 45.5,
accuracy: 10,
date: '2024-03-20T18:45:00Z',
city: 'Berlin',
country: 'Germany'
},
{
id: 2,
timestamp: 1711816500, # 19:15 (30 minutes later)
coordinates: [52.5201, 13.4051],
distance_meters: 48.2,
accuracy: 8,
date: '2024-03-20T19:15:00Z',
city: 'Berlin',
country: 'Germany'
},
{
id: 3,
timestamp: 1711817400, # 19:30 (15 minutes later)
coordinates: [52.5199, 13.4049],
distance_meters: 42.1,
accuracy: 12,
date: '2024-03-20T19:30:00Z',
city: 'Berlin',
country: 'Germany'
}
]
end
it 'groups consecutive points into single visit' do
result = service.group_points_into_visits(consecutive_points)
expect(result.length).to eq(1)
visit = result.first
expect(visit[:points_count]).to eq(3)
end
it 'calculates visit duration from start to end' do
result = service.group_points_into_visits(consecutive_points)
visit = result.first
expect(visit[:duration_estimate]).to eq('~45 minutes')
expect(visit[:visit_details][:duration_minutes]).to eq(45)
end
it 'uses most accurate point coordinates' do
result = service.group_points_into_visits(consecutive_points)
visit = result.first
# Point with accuracy 8 should be selected
expect(visit[:coordinates]).to eq([52.5201, 13.4051])
expect(visit[:accuracy_meters]).to eq(8)
end
it 'calculates average distance' do
result = service.group_points_into_visits(consecutive_points)
visit = result.first
expected_avg = (45.5 + 48.2 + 42.1) / 3
expect(visit[:distance_meters]).to eq(expected_avg.round(2))
end
it 'sets correct start and end times' do
result = service.group_points_into_visits(consecutive_points)
visit = result.first
expect(visit[:visit_details][:start_time]).to eq('2024-03-20T18:45:00Z')
expect(visit[:visit_details][:end_time]).to eq('2024-03-20T19:30:00Z')
end
end
context 'with separate visits (time gaps)' do
let(:separate_visits_points) do
[
{
id: 1,
timestamp: 1711814700, # 18:45
coordinates: [52.5200, 13.4050],
distance_meters: 45.5,
accuracy: 10,
date: '2024-03-20T18:45:00Z',
city: 'Berlin',
country: 'Germany'
},
{
id: 2,
timestamp: 1711816500, # 19:15 (30 minutes later - within threshold)
coordinates: [52.5201, 13.4051],
distance_meters: 48.2,
accuracy: 8,
date: '2024-03-20T19:15:00Z',
city: 'Berlin',
country: 'Germany'
},
{
id: 3,
timestamp: 1711820100, # 20:15 (60 minutes after last point - exceeds threshold)
coordinates: [52.5199, 13.4049],
distance_meters: 42.1,
accuracy: 12,
date: '2024-03-20T20:15:00Z',
city: 'Berlin',
country: 'Germany'
}
]
end
it 'creates separate visits when time gap exceeds threshold' do
result = service.group_points_into_visits(separate_visits_points)
expect(result.length).to eq(2)
expect(result.first[:points_count]).to eq(1) # Most recent visit (20:15)
expect(result.last[:points_count]).to eq(2) # Earlier visit (18:45-19:15)
end
it 'orders visits by timestamp descending (most recent first)' do
result = service.group_points_into_visits(separate_visits_points)
expect(result.first[:timestamp]).to be > result.last[:timestamp]
end
end
context 'with duration formatting' do
let(:points_with_various_durations) do
# Helper to create points with time differences
base_time = 1711814700
[
# Short visit (25 minutes) - 2 points 25 minutes apart
{ id: 1, timestamp: base_time, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T18:45:00Z' },
{ id: 2, timestamp: base_time + 25 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T19:10:00Z' },
# Long visit (2 hours 15 minutes) - points every 15 minutes to stay within 30min threshold
{ id: 3, timestamp: base_time + 70 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T19:55:00Z' },
{ id: 4, timestamp: base_time + 85 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T20:10:00Z' },
{ id: 5, timestamp: base_time + 100 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T20:25:00Z' },
{ id: 6, timestamp: base_time + 115 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T20:40:00Z' },
{ id: 7, timestamp: base_time + 130 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T20:55:00Z' },
{ id: 8, timestamp: base_time + 145 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T21:10:00Z' },
{ id: 9, timestamp: base_time + 160 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T21:25:00Z' },
{ id: 10, timestamp: base_time + 175 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T21:40:00Z' },
{ id: 11, timestamp: base_time + 190 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T21:55:00Z' },
{ id: 12, timestamp: base_time + 205 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T22:10:00Z' }
]
end
it 'formats duration correctly for minutes only' do
short_visit_points = points_with_various_durations.take(2)
result = service.group_points_into_visits(short_visit_points)
expect(result.first[:duration_estimate]).to eq('~25 minutes')
end
it 'formats duration correctly for hours and minutes' do
long_visit_points = points_with_various_durations.drop(2)
result = service.group_points_into_visits(long_visit_points)
expect(result.first[:duration_estimate]).to eq('~2 hours 15 minutes')
end
it 'formats duration correctly for hours only' do
# Create points within threshold but exactly 2 hours apart from first to last
exact_hour_points = [
{ id: 1, timestamp: 1711814700, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T18:45:00Z' },
{ id: 2, timestamp: 1711814700 + 25 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T19:10:00Z' },
{ id: 3, timestamp: 1711814700 + 50 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T19:35:00Z' },
{ id: 4, timestamp: 1711814700 + 75 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T20:00:00Z' },
{ id: 5, timestamp: 1711814700 + 100 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T20:25:00Z' },
{ id: 6, timestamp: 1711814700 + 120 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T20:45:00Z' }
]
result = service.group_points_into_visits(exact_hour_points)
expect(result.first[:duration_estimate]).to eq('~2 hours')
end
end
context 'with altitude data' do
let(:points_with_altitude) do
[
{
id: 1, timestamp: 1711814700, coordinates: [52.5200, 13.4050],
accuracy: 10, distance_meters: 50, altitude: 100,
date: '2024-03-20T18:45:00Z'
},
{
id: 2, timestamp: 1711815600, coordinates: [52.5201, 13.4051],
accuracy: 10, distance_meters: 50, altitude: 105,
date: '2024-03-20T19:00:00Z'
},
{
id: 3, timestamp: 1711816500, coordinates: [52.5199, 13.4049],
accuracy: 10, distance_meters: 50, altitude: 95,
date: '2024-03-20T19:15:00Z'
}
]
end
it 'includes altitude range in visit details' do
result = service.group_points_into_visits(points_with_altitude)
visit = result.first
expect(visit[:visit_details][:altitude_range]).to eq('95m - 105m')
end
context 'with same altitude for all points' do
before do
points_with_altitude.each { |p| p[:altitude] = 100 }
end
it 'shows single altitude value' do
result = service.group_points_into_visits(points_with_altitude)
visit = result.first
expect(visit[:visit_details][:altitude_range]).to eq('100m')
end
end
context 'with missing altitude data' do
before do
points_with_altitude.each { |p| p.delete(:altitude) }
end
it 'handles missing altitude gracefully' do
result = service.group_points_into_visits(points_with_altitude)
visit = result.first
expect(visit[:visit_details][:altitude_range]).to be_nil
end
end
end
context 'with unordered points' do
let(:unordered_points) do
[
{ id: 3, timestamp: 1711817400, coordinates: [52.5199, 13.4049], accuracy: 10, distance_meters: 50, date: '2024-03-20T19:30:00Z' },
{ id: 1, timestamp: 1711814700, coordinates: [52.5200, 13.4050], accuracy: 10, distance_meters: 50, date: '2024-03-20T18:45:00Z' },
{ id: 2, timestamp: 1711816500, coordinates: [52.5201, 13.4051], accuracy: 10, distance_meters: 50, date: '2024-03-20T19:15:00Z' }
]
end
it 'handles unordered input correctly' do
result = service.group_points_into_visits(unordered_points)
visit = result.first
expect(visit[:visit_details][:start_time]).to eq('2024-03-20T18:45:00Z')
expect(visit[:visit_details][:end_time]).to eq('2024-03-20T19:30:00Z')
end
end
end
end

View file

@ -0,0 +1,223 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe LocationSearch::SpatialMatcher do
let(:service) { described_class.new }
let(:user) { create(:user) }
let(:latitude) { 52.5200 }
let(:longitude) { 13.4050 }
let(:radius_meters) { 100 }
describe '#find_points_near' do
let!(:near_point) do
create(:point,
user: user,
lonlat: "POINT(13.4051 52.5201)",
timestamp: 1.hour.ago.to_i,
city: 'Berlin',
country: 'Germany',
altitude: 100,
accuracy: 5
)
end
let!(:far_point) do
create(:point,
user: user,
lonlat: "POINT(13.5000 52.6000)",
timestamp: 2.hours.ago.to_i
)
end
let!(:other_user_point) do
create(:point,
user: create(:user),
lonlat: "POINT(13.4051 52.5201)",
timestamp: 30.minutes.ago.to_i
)
end
context 'with points within radius' do
it 'returns points within the specified radius' do
results = service.find_points_near(user, latitude, longitude, radius_meters)
expect(results.length).to eq(1)
expect(results.first[:id]).to eq(near_point.id)
end
it 'excludes points outside the radius' do
results = service.find_points_near(user, latitude, longitude, radius_meters)
point_ids = results.map { |r| r[:id] }
expect(point_ids).not_to include(far_point.id)
end
it 'only includes points from the specified user' do
results = service.find_points_near(user, latitude, longitude, radius_meters)
point_ids = results.map { |r| r[:id] }
expect(point_ids).not_to include(other_user_point.id)
end
it 'includes calculated distance' do
results = service.find_points_near(user, latitude, longitude, radius_meters)
expect(results.first[:distance_meters]).to be_a(Float)
expect(results.first[:distance_meters]).to be < radius_meters
end
it 'includes point attributes' do
results = service.find_points_near(user, latitude, longitude, radius_meters)
point = results.first
expect(point).to include(
id: near_point.id,
timestamp: near_point.timestamp,
coordinates: [52.5201, 13.4051],
city: 'Berlin',
country: 'Germany',
altitude: 100,
accuracy: 5
)
end
it 'includes ISO8601 formatted date' do
results = service.find_points_near(user, latitude, longitude, radius_meters)
expect(results.first[:date]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
end
it 'orders results by timestamp descending (most recent first)' do
# Create another nearby point with older timestamp
older_point = create(:point,
user: user,
lonlat: "POINT(13.4049 52.5199)",
timestamp: 3.hours.ago.to_i
)
results = service.find_points_near(user, latitude, longitude, radius_meters)
expect(results.first[:id]).to eq(near_point.id) # More recent
expect(results.last[:id]).to eq(older_point.id) # Older
end
end
context 'with date filtering' do
let(:date_options) do
{
date_from: 2.days.ago.to_date,
date_to: Date.current
}
end
let!(:old_point) do
create(:point,
user: user,
lonlat: "POINT(13.4051 52.5201)",
timestamp: 1.week.ago.to_i
)
end
it 'filters points by date range' do
results = service.find_points_near(user, latitude, longitude, radius_meters, date_options)
point_ids = results.map { |r| r[:id] }
expect(point_ids).to include(near_point.id)
expect(point_ids).not_to include(old_point.id)
end
context 'with only date_from' do
let(:date_options) { { date_from: 2.hours.ago.to_date } }
it 'includes points after date_from' do
results = service.find_points_near(user, latitude, longitude, radius_meters, date_options)
point_ids = results.map { |r| r[:id] }
expect(point_ids).to include(near_point.id)
end
end
context 'with only date_to' do
let(:date_options) { { date_to: 2.days.ago.to_date } }
it 'includes points before date_to' do
results = service.find_points_near(user, latitude, longitude, radius_meters, date_options)
point_ids = results.map { |r| r[:id] }
expect(point_ids).to include(old_point.id)
expect(point_ids).not_to include(near_point.id)
end
end
end
context 'with no points within radius' do
it 'returns empty array' do
results = service.find_points_near(user, 60.0, 30.0, 100) # Far away coordinates
expect(results).to be_empty
end
end
context 'with edge cases' do
it 'handles points at the exact radius boundary' do
# This test would require creating a point at exactly 100m distance
# For simplicity, we'll test with a very small radius that should exclude our test point
results = service.find_points_near(user, latitude, longitude, 1) # 1 meter radius
expect(results).to be_empty
end
it 'handles negative coordinates' do
# Create point with negative coordinates
negative_point = create(:point,
user: user,
lonlat: "POINT(151.2093 -33.8688)",
timestamp: 1.hour.ago.to_i
)
results = service.find_points_near(user, -33.8688, 151.2093, 1000)
expect(results.length).to eq(1)
expect(results.first[:id]).to eq(negative_point.id)
end
it 'handles coordinates near poles' do
# Create point near north pole
polar_point = create(:point,
user: user,
lonlat: "POINT(0.0 89.0)",
timestamp: 1.hour.ago.to_i
)
results = service.find_points_near(user, 89.0, 0.0, 1000)
expect(results.length).to eq(1)
expect(results.first[:id]).to eq(polar_point.id)
end
end
context 'with large datasets' do
before do
# Create many points to test performance
50.times do |i|
create(:point,
user: user,
lonlat: "POINT(#{longitude + (i * 0.0001)} #{latitude + (i * 0.0001)})", # Spread points slightly
timestamp: i.hours.ago.to_i
)
end
end
it 'efficiently queries large datasets' do
start_time = Time.current
results = service.find_points_near(user, latitude, longitude, 1000)
query_time = Time.current - start_time
expect(query_time).to be < 1.0 # Should complete within 1 second
expect(results.length).to be > 40 # Should find most of the points
end
end
end
end

View file

@ -18,7 +18,7 @@ RSpec.describe Points::RawDataLonlatExtractor do
}
}
end
let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) }
let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) }
it 'extracts longitude and latitude correctly' do
expect { described_class.new(point).call }.to \
@ -36,7 +36,7 @@ RSpec.describe Points::RawDataLonlatExtractor do
'latitudeE7' => 512_345_678
}
end
let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) }
let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) }
it 'extracts longitude and latitude correctly' do
expect { described_class.new(point).call }.to \
@ -55,7 +55,7 @@ RSpec.describe Points::RawDataLonlatExtractor do
}
}
end
let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) }
let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) }
it 'extracts longitude and latitude correctly' do
expect { described_class.new(point).call }.to \
@ -74,7 +74,7 @@ RSpec.describe Points::RawDataLonlatExtractor do
}
}
end
let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) }
let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) }
it 'extracts longitude and latitude correctly' do
expect { described_class.new(point).call }.to \
@ -92,7 +92,7 @@ RSpec.describe Points::RawDataLonlatExtractor do
'lat' => 51.2345678
}
end
let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) }
let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) }
it 'extracts longitude and latitude correctly' do
expect { described_class.new(point).call }.to \
@ -111,7 +111,7 @@ RSpec.describe Points::RawDataLonlatExtractor do
}
}
end
let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) }
let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) }
it 'extracts longitude and latitude correctly' do
expect { described_class.new(point).call }.to \
@ -129,7 +129,7 @@ RSpec.describe Points::RawDataLonlatExtractor do
'latitude' => 51.2345678
}
end
let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) }
let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) }
it 'extracts longitude and latitude correctly' do
expect { described_class.new(point).call }.to \
@ -148,7 +148,7 @@ RSpec.describe Points::RawDataLonlatExtractor do
}
}
end
let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) }
let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) }
# Mock the entire call method since service doesn't have nil check
before do