From 5b3fe84933cd4488cf878488bfbcdfcc5bc4e200 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Thu, 18 Sep 2025 18:29:46 +0200 Subject: [PATCH] Update onborading popup --- CHANGELOG.md | 8 + CLAUDE.md | 42 ++- ...e_App_Store_Badge_US-UK_RGB_blk_092917.svg | 46 +++ app/assets/svg/icons/lucide/outline/goal.svg | 1 + .../api/v1/maps/hexagons_controller.rb | 43 ++- app/helpers/user_helper.rb | 4 +- .../points/nightly_reverse_geocoding_job.rb | 13 + app/services/maps/bounds_calculator.rb | 8 +- app/services/maps/date_parameter_coercer.rb | 4 - app/services/maps/h3_hexagon_calculator.rb | 7 +- app/services/maps/h3_hexagon_centers.rb | 33 +- app/services/maps/h3_hexagon_renderer.rb | 22 +- app/services/maps/hexagon_request_handler.rb | 78 ++--- app/views/map/_onboarding_modal.html.erb | 95 ++++- config/initializers/prometheus.rb | 2 +- config/schedule.yml | 5 + spec/factories/users.rb | 2 +- ..._visits_calculation_scheduling_job_spec.rb | 1 + spec/jobs/bulk_stats_calculating_job_spec.rb | 4 - spec/jobs/bulk_visits_suggesting_job_spec.rb | 18 + .../nightly_reverse_geocoding_job_spec.rb | 158 +++++++++ spec/jobs/tracks/daily_generation_job_spec.rb | 5 + spec/mailers/users_mailer_spec.rb | 16 +- spec/serializers/api/user_serializer_spec.rb | 2 +- spec/services/areas/visits/create_spec.rb | 2 +- .../phone_takeout_importer_spec.rb | 12 +- spec/services/gpx/track_importer_spec.rb | 24 +- spec/services/maps/bounds_calculator_spec.rb | 38 +- .../maps/date_parameter_coercer_spec.rb | 4 +- .../maps/hexagon_request_handler_spec.rb | 330 ++++-------------- spec/services/own_tracks/importer_spec.rb | 4 +- spec/services/photos/importer_spec.rb | 19 +- spec/system/map_interaction_spec.rb | 64 ++-- 33 files changed, 635 insertions(+), 479 deletions(-) create mode 100755 app/assets/images/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg create mode 100644 app/assets/svg/icons/lucide/outline/goal.svg create mode 100644 app/jobs/points/nightly_reverse_geocoding_job.rb create mode 100644 spec/jobs/points/nightly_reverse_geocoding_job_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 59b1de3a..7b69af1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Fixed - Fix a bug where some points from Owntracks were not being processed correctly which prevented import from being created. #1745 +- Hexagons for the stats page are now being calculated a lot faster. +- Prometheus exporter is now not being started when console is being run. +- Stats will now properly reflect countries and cities visited after importing new points. + +## Changed + +- Onboarding modal window now features a link to the App Store and a QR code to configure the Dawarich iOS app. + # [0.32.0] - 2025-09-13 diff --git a/CLAUDE.md b/CLAUDE.md index b3333ff5..399924b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ This file contains essential information for Claude to work effectively with the - 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.) +- Public sharing of monthly statistics with time-based expiration - Trips management with photo integration - Areas and visits tracking - Integration with photo management systems (Immich, Photoprism) @@ -75,7 +76,7 @@ This file contains essential information for Claude to work effectively with the - **Trip**: User-defined travel periods with analytics - **Import**: Data import operations - **Export**: Data export operations -- **Stat**: Calculated statistics and metrics +- **Stat**: Calculated statistics and metrics with public sharing capabilities ### Geographic Features - Uses PostGIS for advanced geographic queries @@ -126,11 +127,41 @@ npx playwright test # E2E tests - Various import jobs for different data sources - Statistical calculation jobs +## Public Sharing System + +### Overview +Dawarich includes a comprehensive public sharing system that allows users to share their monthly statistics with others without requiring authentication. This feature enables users to showcase their location data while maintaining privacy control through configurable expiration settings. + +### Key Features +- **Time-based expiration**: Share links can expire after 1 hour, 12 hours, 24 hours, or be permanent +- **UUID-based access**: Each shared stat has a unique, unguessable UUID for security +- **Public API endpoints**: Hexagon map data can be accessed via API without authentication when sharing is enabled +- **Automatic cleanup**: Expired shares are automatically inaccessible +- **Privacy controls**: Users can enable/disable sharing and regenerate sharing URLs at any time + +### Technical Implementation +- **Database**: `sharing_settings` (JSONB) and `sharing_uuid` (UUID) columns on `stats` table +- **Routes**: `/shared/stats/:uuid` for public viewing, `/stats/:year/:month/sharing` for management +- **API**: `/api/v1/maps/hexagons` supports public access via `uuid` parameter +- **Controllers**: `Shared::StatsController` handles public views, sharing management integrated into existing stats flow + +### Security Features +- **No authentication bypass**: Public sharing only exposes specifically designed endpoints +- **UUID-based access**: Sharing URLs use unguessable UUIDs rather than sequential IDs +- **Expiration enforcement**: Automatic expiration checking prevents access to expired shares +- **Limited data exposure**: Only monthly statistics and hexagon data are publicly accessible + +### Usage Patterns +- **Social sharing**: Users can share interesting travel months with friends and family +- **Portfolio/showcase**: Travel bloggers and photographers can showcase location statistics +- **Data collaboration**: Researchers can share aggregated location data for analysis +- **Public demonstrations**: Demo instances can provide public examples without compromising user data + ## API Documentation - **Framework**: rSwag (Swagger/OpenAPI) - **Location**: `/api-docs` endpoint -- **Authentication**: API key (Bearer) for API access +- **Authentication**: API key (Bearer) for API access, UUID-based access for public shares ## Database Schema @@ -142,7 +173,7 @@ npx playwright test # E2E tests - `visits` - Detected area visits - `trips` - Travel periods - `imports`/`exports` - Data transfer operations -- `stats` - Calculated metrics +- `stats` - Calculated metrics with sharing capabilities (`sharing_settings`, `sharing_uuid`) ### PostGIS Integration - Extensive use of PostGIS geometry types @@ -201,6 +232,11 @@ bundle exec bundle-audit # Dependency security 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 +7. **Public Sharing**: When implementing features that interact with stats, consider public sharing access patterns: + - Use `public_accessible?` method to check if a stat can be publicly accessed + - Support UUID-based access in API endpoints when appropriate + - Respect expiration settings and disable sharing when expired + - Only expose minimal necessary data in public sharing contexts ## Contributing diff --git a/app/assets/images/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg b/app/assets/images/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg new file mode 100755 index 00000000..072b425a --- /dev/null +++ b/app/assets/images/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/svg/icons/lucide/outline/goal.svg b/app/assets/svg/icons/lucide/outline/goal.svg new file mode 100644 index 00000000..84be52d6 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/goal.svg @@ -0,0 +1 @@ + diff --git a/app/controllers/api/v1/maps/hexagons_controller.rb b/app/controllers/api/v1/maps/hexagons_controller.rb index 6ed8de66..3ff0b3ff 100644 --- a/app/controllers/api/v1/maps/hexagons_controller.rb +++ b/app/controllers/api/v1/maps/hexagons_controller.rb @@ -4,7 +4,9 @@ class Api::V1::Maps::HexagonsController < ApiController skip_before_action :authenticate_api_key, if: :public_sharing_request? def index - result = Maps::H3HexagonRenderer.call( + return unless public_sharing_request? || validate_required_parameters + + result = Maps::HexagonRequestHandler.call( params: params, current_api_user: current_api_user ) @@ -28,11 +30,11 @@ class Api::V1::Maps::HexagonsController < ApiController current_api_user: current_api_user ) - result = Maps::BoundsCalculator.call( + result = Maps::BoundsCalculator.new( target_user: context[:target_user], start_date: context[:start_date], end_date: context[:end_date] - ) + ).call if result[:success] render json: result[:data] @@ -65,4 +67,39 @@ class Api::V1::Maps::HexagonsController < ApiController def public_sharing_request? params[:uuid].present? end + + def validate_required_parameters + required_params = %i[min_lon max_lon min_lat max_lat start_date end_date] + missing_params = required_params.select { |param| params[param].blank? } + + unless missing_params.empty? + error_message = "Missing required parameters: #{missing_params.join(', ')}" + render json: { error: error_message }, status: :bad_request + return false + end + + # Validate coordinate ranges + if !valid_coordinate_ranges? + render json: { error: 'Invalid coordinate ranges' }, status: :bad_request + return false + end + + true + end + + def valid_coordinate_ranges? + min_lon = params[:min_lon].to_f + max_lon = params[:max_lon].to_f + min_lat = params[:min_lat].to_f + max_lat = params[:max_lat].to_f + + # Check longitude range (-180 to 180) + return false unless (-180..180).cover?(min_lon) && (-180..180).cover?(max_lon) + # Check latitude range (-90 to 90) + return false unless (-90..90).cover?(min_lat) && (-90..90).cover?(max_lat) + # Check that min values are less than max values + return false unless min_lon < max_lon && min_lat < max_lat + + true + end end diff --git a/app/helpers/user_helper.rb b/app/helpers/user_helper.rb index d38b79a9..af3a0724 100644 --- a/app/helpers/user_helper.rb +++ b/app/helpers/user_helper.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true module UserHelper - def api_key_qr_code(user) + def api_key_qr_code(user, size: 6) json = { 'server_url' => root_url, 'api_key' => user.api_key } qrcode = RQRCode::QRCode.new(json.to_json) svg = qrcode.as_svg( color: '000', fill: 'fff', shape_rendering: 'crispEdges', - module_size: 6, + module_size: size, standalone: true, use_path: true, offset: 5 diff --git a/app/jobs/points/nightly_reverse_geocoding_job.rb b/app/jobs/points/nightly_reverse_geocoding_job.rb new file mode 100644 index 00000000..d536679f --- /dev/null +++ b/app/jobs/points/nightly_reverse_geocoding_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Points::NightlyReverseGeocodingJob < ApplicationJob + queue_as :reverse_geocoding + + def perform + return unless DawarichSettings.reverse_geocoding_enabled? + + Point.not_reverse_geocoded.find_each(batch_size: 1000) do |point| + point.async_reverse_geocode + end + end +end diff --git a/app/services/maps/bounds_calculator.rb b/app/services/maps/bounds_calculator.rb index 6312fb7c..aba1e251 100644 --- a/app/services/maps/bounds_calculator.rb +++ b/app/services/maps/bounds_calculator.rb @@ -6,10 +6,6 @@ module Maps class NoDateRangeError < StandardError; end class NoDataFoundError < StandardError; end - def self.call(target_user:, start_date:, end_date:) - new(target_user: target_user, start_date: start_date, end_date: end_date).call - end - def initialize(target_user:, start_date:, end_date:) @target_user = target_user @start_date = start_date @@ -19,8 +15,8 @@ module Maps def call validate_inputs! - start_timestamp = Maps::DateParameterCoercer.call(@start_date) - end_timestamp = Maps::DateParameterCoercer.call(@end_date) + start_timestamp = Maps::DateParameterCoercer.new(@start_date).call + end_timestamp = Maps::DateParameterCoercer.new(@end_date).call points_relation = @target_user.points.where(timestamp: start_timestamp..end_timestamp) point_count = points_relation.count diff --git a/app/services/maps/date_parameter_coercer.rb b/app/services/maps/date_parameter_coercer.rb index 64737d4c..e85469dd 100644 --- a/app/services/maps/date_parameter_coercer.rb +++ b/app/services/maps/date_parameter_coercer.rb @@ -4,10 +4,6 @@ module Maps class DateParameterCoercer class InvalidDateFormatError < StandardError; end - def self.call(param) - new(param).call - end - def initialize(param) @param = param end diff --git a/app/services/maps/h3_hexagon_calculator.rb b/app/services/maps/h3_hexagon_calculator.rb index 639d5ae2..84d23435 100644 --- a/app/services/maps/h3_hexagon_calculator.rb +++ b/app/services/maps/h3_hexagon_calculator.rb @@ -2,7 +2,7 @@ module Maps class H3HexagonCalculator - def initialize(user_id, start_date, end_date, h3_resolution = 5) + def initialize(user_id, start_date, end_date, h3_resolution = 8) @user_id = user_id @start_date = start_date @end_date = end_date @@ -32,7 +32,8 @@ module Maps attr_reader :user_id, :start_date, :end_date, :h3_resolution def fetch_user_points - Point.where(user_id: user_id) + Point.without_raw_data + .where(user_id: user_id) .where(timestamp: start_date.to_i..end_date.to_i) .where.not(lonlat: nil) .select(:id, :lonlat, :timestamp) @@ -81,4 +82,4 @@ module Maps end end end -end \ No newline at end of file +end diff --git a/app/services/maps/h3_hexagon_centers.rb b/app/services/maps/h3_hexagon_centers.rb index 5911f6df..a6a526ac 100644 --- a/app/services/maps/h3_hexagon_centers.rb +++ b/app/services/maps/h3_hexagon_centers.rb @@ -34,7 +34,7 @@ class Maps::H3HexagonCenters if h3_indexes_with_counts.size > MAX_HEXAGONS Rails.logger.warn "Too many hexagons (#{h3_indexes_with_counts.size}), using lower resolution" # Try with lower resolution (larger hexagons) - return recalculate_with_lower_resolution(points) + return recalculate_with_lower_resolution end Rails.logger.info "Generated #{h3_indexes_with_counts.size} H3 hexagons at resolution #{h3_resolution} for user #{user_id}" @@ -50,20 +50,23 @@ class Maps::H3HexagonCenters end rescue StandardError => e message = "Failed to calculate H3 hexagon centers: #{e.message}" - ExceptionReporter.call(e, message) if defined?(ExceptionReporter) + ExceptionReporter.call(e, message) raise PostGISError, message end private def fetch_user_points - start_timestamp = parse_date_to_timestamp(start_date) - end_timestamp = parse_date_to_timestamp(end_date) + start_timestamp = Maps::DateParameterCoercer.new(start_date).call + end_timestamp = Maps::DateParameterCoercer.new(end_date).call Point.where(user_id: user_id) .where(timestamp: start_timestamp..end_timestamp) .where.not(lonlat: nil) .select(:id, :lonlat, :timestamp) + rescue Maps::DateParameterCoercer::InvalidDateFormatError => e + ExceptionReporter.call(e, e.message) if defined?(ExceptionReporter) + raise ArgumentError, e.message end def calculate_h3_indexes(points) @@ -86,7 +89,7 @@ class Maps::H3HexagonCenters h3_data end - def recalculate_with_lower_resolution(points) + def recalculate_with_lower_resolution # Try with resolution 2 levels lower (4x larger hexagons) lower_resolution = [h3_resolution - 2, 0].max @@ -102,27 +105,9 @@ class Maps::H3HexagonCenters service.call end - def parse_date_to_timestamp(date) - case date - when String - if date.match?(/^\d+$/) - date.to_i - else - Time.parse(date).to_i - end - when Integer - date - else - Time.parse(date.to_s).to_i - end - rescue ArgumentError => e - ExceptionReporter.call(e, "Invalid date format: #{date}") if defined?(ExceptionReporter) - raise ArgumentError, "Invalid date format: #{date}" - end - def validate! return if valid? raise InvalidCoordinatesError, errors.full_messages.join(', ') end -end \ No newline at end of file +end diff --git a/app/services/maps/h3_hexagon_renderer.rb b/app/services/maps/h3_hexagon_renderer.rb index c7210265..905fcb4b 100644 --- a/app/services/maps/h3_hexagon_renderer.rb +++ b/app/services/maps/h3_hexagon_renderer.rb @@ -2,10 +2,6 @@ module Maps class H3HexagonRenderer - def self.call(params:, current_api_user: nil) - new(params: params, current_api_user: current_api_user).call - end - def initialize(params:, current_api_user: nil) @params = params @current_api_user = current_api_user @@ -47,7 +43,7 @@ module Maps end # For authenticated users, calculate on-the-fly if no pre-calculated data - Rails.logger.debug "No pre-calculated H3 data, calculating on-the-fly" + Rails.logger.debug 'No pre-calculated H3 data, calculating on-the-fly' generate_h3_data_on_the_fly(context) end @@ -56,14 +52,12 @@ module Maps end_date = parse_date_for_h3(context[:end_date]) h3_resolution = params[:h3_resolution]&.to_i&.clamp(0, 15) || 6 - service = Maps::H3HexagonCenters.new( + Maps::H3HexagonCenters.new( user_id: context[:target_user]&.id, start_date: start_date, end_date: end_date, h3_resolution: h3_resolution - ) - - service.call + ).call end def convert_h3_to_geojson(h3_data) @@ -124,14 +118,14 @@ module Maps return date_param if date_param.is_a?(Time) # If it's a string ISO date, parse it directly to Time - return Time.parse(date_param) if date_param.is_a?(String) + return Time.zone.parse(date_param) if date_param.is_a?(String) # If it's an integer timestamp, convert to Time - return Time.at(date_param) if date_param.is_a?(Integer) + return Time.zone.at(date_param) if date_param.is_a?(Integer) # For other cases, try coercing and converting - timestamp = Maps::DateParameterCoercer.call(date_param) - Time.at(timestamp) + timestamp = Maps::DateParameterCoercer.new(date_param).call + Time.zone.at(timestamp) end end -end \ No newline at end of file +end diff --git a/app/services/maps/hexagon_request_handler.rb b/app/services/maps/hexagon_request_handler.rb index 3e317122..d6f27999 100644 --- a/app/services/maps/hexagon_request_handler.rb +++ b/app/services/maps/hexagon_request_handler.rb @@ -14,19 +14,22 @@ module Maps def call context = resolve_context - # Try to use pre-calculated hexagon centers first - if context[:stat] + # For authenticated users, we need to find the matching stat + stat = context[:stat] || find_matching_stat(context) + + # Use pre-calculated hexagon centers + if stat cached_result = Maps::HexagonCenterManager.call( - stat: context[:stat], + stat: stat, target_user: context[:target_user] ) return cached_result[:data] if cached_result&.dig(:success) end - # Fall back to on-the-fly calculation - Rails.logger.debug 'No pre-calculated data available, calculating hexagons on-the-fly' - generate_hexagons_on_the_fly(context) + # No pre-calculated data available - return empty feature collection + Rails.logger.debug 'No pre-calculated hexagon centers available' + empty_feature_collection end private @@ -40,58 +43,35 @@ module Maps ) end - def generate_hexagons_on_the_fly(context) - # Parse dates for H3 calculator which expects Time objects - start_date = parse_date_for_h3(context[:start_date]) - end_date = parse_date_for_h3(context[:end_date]) - result = Maps::H3HexagonCalculator.new( - context[:target_user]&.id, - start_date, - end_date, - h3_resolution - ).call + def find_matching_stat(context) + return unless context[:target_user] && context[:start_date] - return result[:data] if result[:success] + # Parse the date to extract year and month + if context[:start_date].is_a?(String) + date = Date.parse(context[:start_date]) + elsif context[:start_date].is_a?(Time) + date = context[:start_date].to_date + else + return + end - # If H3 calculation fails, log error and return empty feature collection - Rails.logger.error "H3 calculation failed: #{result[:error]}" - empty_feature_collection + # Find the stat for this user, year, and month + context[:target_user].stats.find_by(year: date.year, month: date.month) + rescue Date::Error + nil end def empty_feature_collection { - type: 'FeatureCollection', - features: [], - metadata: { - hexagon_count: 0, - total_points: 0, - source: 'h3' + 'type' => 'FeatureCollection', + 'features' => [], + 'metadata' => { + 'hexagon_count' => 0, + 'total_points' => 0, + 'source' => 'pre_calculated' } } end - - def h3_resolution - # Allow custom resolution via parameter, default to 8 - resolution = params[:h3_resolution]&.to_i || 8 - - # Clamp to valid H3 resolution range (0-15) - resolution.clamp(0, 15) - end - - def parse_date_for_h3(date_param) - # If already a Time object (from public sharing context), return as-is - return date_param if date_param.is_a?(Time) - - # If it's a string ISO date, parse it directly to Time - return Time.parse(date_param) if date_param.is_a?(String) - - # If it's an integer timestamp, convert to Time - return Time.at(date_param) if date_param.is_a?(Integer) - - # For other cases, try coercing and converting - timestamp = Maps::DateParameterCoercer.call(date_param) - Time.at(timestamp) - end end end diff --git a/app/views/map/_onboarding_modal.html.erb b/app/views/map/_onboarding_modal.html.erb index c1d69b36..27a6e284 100644 --- a/app/views/map/_onboarding_modal.html.erb +++ b/app/views/map/_onboarding_modal.html.erb @@ -1,21 +1,94 @@ <% if user_signed_in? %>
+ data-onboarding-modal-showable-value="true"> -
<% end %> diff --git a/config/initializers/prometheus.rb b/config/initializers/prometheus.rb index 1a2f38e0..73650a96 100644 --- a/config/initializers/prometheus.rb +++ b/config/initializers/prometheus.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -if !Rails.env.test? && DawarichSettings.prometheus_exporter_enabled? +if defined?(Rails::Server) && !Rails.env.test? && DawarichSettings.prometheus_exporter_enabled? require 'prometheus_exporter/middleware' require 'prometheus_exporter/instrumentation' diff --git a/config/schedule.yml b/config/schedule.yml index f0fcb40a..96f3288d 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -39,3 +39,8 @@ daily_track_generation_job: cron: "0 */4 * * *" # every 4 hours class: "Tracks::DailyGenerationJob" queue: tracks + +nightly_reverse_geocoding_job: + cron: "15 1 * * *" # every day at 01:15 + class: "Points::NightlyReverseGeocodingJob" + queue: tracks diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 3e27ad70..8aead742 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -3,7 +3,7 @@ FactoryBot.define do factory :user do sequence :email do |n| - "user#{n}@example.com" + "user#{n}-#{Time.current.to_f}@example.com" end status { :active } diff --git a/spec/jobs/area_visits_calculation_scheduling_job_spec.rb b/spec/jobs/area_visits_calculation_scheduling_job_spec.rb index b38ee551..39fae4d7 100644 --- a/spec/jobs/area_visits_calculation_scheduling_job_spec.rb +++ b/spec/jobs/area_visits_calculation_scheduling_job_spec.rb @@ -8,6 +8,7 @@ RSpec.describe AreaVisitsCalculationSchedulingJob, type: :job do let(:area) { create(:area, user: user) } it 'calls the AreaVisitsCalculationService' do + allow(User).to receive(:find_each).and_yield(user) expect(AreaVisitsCalculatingJob).to receive(:perform_later).with(user.id).and_call_original described_class.new.perform diff --git a/spec/jobs/bulk_stats_calculating_job_spec.rb b/spec/jobs/bulk_stats_calculating_job_spec.rb index eb59c46a..bdcc17f9 100644 --- a/spec/jobs/bulk_stats_calculating_job_spec.rb +++ b/spec/jobs/bulk_stats_calculating_job_spec.rb @@ -23,8 +23,6 @@ RSpec.describe BulkStatsCalculatingJob, type: :job do end before do - # Remove any leftover users from other tests, keeping only our test users - User.where.not(id: [active_user1.id, active_user2.id]).destroy_all allow(Stats::BulkCalculator).to receive(:new).and_call_original allow_any_instance_of(Stats::BulkCalculator).to receive(:call) end @@ -69,8 +67,6 @@ RSpec.describe BulkStatsCalculatingJob, type: :job do end before do - # Remove any leftover users from other tests, keeping only our test users - User.where.not(id: [trial_user1.id, trial_user2.id]).destroy_all allow(Stats::BulkCalculator).to receive(:new).and_call_original allow_any_instance_of(Stats::BulkCalculator).to receive(:call) end diff --git a/spec/jobs/bulk_visits_suggesting_job_spec.rb b/spec/jobs/bulk_visits_suggesting_job_spec.rb index 66bf7da6..7c013dcd 100644 --- a/spec/jobs/bulk_visits_suggesting_job_spec.rb +++ b/spec/jobs/bulk_visits_suggesting_job_spec.rb @@ -26,6 +26,12 @@ RSpec.describe BulkVisitsSuggestingJob, type: :job do end it 'schedules jobs only for active users with tracked points' do + active_users_mock = double('ActiveRecord::Relation') + allow(User).to receive(:active).and_return(active_users_mock) + allow(active_users_mock).to receive(:active).and_return(active_users_mock) + allow(active_users_mock).to receive(:where).with(id: []).and_return(active_users_mock) + allow(active_users_mock).to receive(:find_each).and_yield(user_with_points).and_yield(user) + expect(VisitSuggestingJob).to receive(:perform_later).with( user_id: user_with_points.id, start_at: time_chunks.first.first, @@ -54,6 +60,12 @@ RSpec.describe BulkVisitsSuggestingJob, type: :job do ] allow_any_instance_of(Visits::TimeChunks).to receive(:call).and_return(chunks) + active_users_mock = double('ActiveRecord::Relation') + allow(User).to receive(:active).and_return(active_users_mock) + allow(active_users_mock).to receive(:active).and_return(active_users_mock) + allow(active_users_mock).to receive(:where).with(id: []).and_return(active_users_mock) + allow(active_users_mock).to receive(:find_each).and_yield(user_with_points) + chunks.each do |chunk| expect(VisitSuggestingJob).to receive(:perform_later).with( user_id: user_with_points.id, @@ -94,6 +106,12 @@ RSpec.describe BulkVisitsSuggestingJob, type: :job do .and_return(time_chunks_instance) allow(time_chunks_instance).to receive(:call).and_return(custom_chunks) + active_users_mock = double('ActiveRecord::Relation') + allow(User).to receive(:active).and_return(active_users_mock) + allow(active_users_mock).to receive(:active).and_return(active_users_mock) + allow(active_users_mock).to receive(:where).with(id: []).and_return(active_users_mock) + allow(active_users_mock).to receive(:find_each).and_yield(user_with_points) + expect(VisitSuggestingJob).to receive(:perform_later).with( user_id: user_with_points.id, start_at: custom_chunks.first.first, diff --git a/spec/jobs/points/nightly_reverse_geocoding_job_spec.rb b/spec/jobs/points/nightly_reverse_geocoding_job_spec.rb new file mode 100644 index 00000000..37fd29d5 --- /dev/null +++ b/spec/jobs/points/nightly_reverse_geocoding_job_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Points::NightlyReverseGeocodingJob, type: :job do + describe '#perform' do + let(:user) { create(:user) } + + before do + # Clear any existing jobs and points to ensure test isolation + ActiveJob::Base.queue_adapter.enqueued_jobs.clear + Point.delete_all + end + + context 'when reverse geocoding is disabled' do + before do + allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(false) + end + + let!(:point_without_geocoding) do + create(:point, user: user, reverse_geocoded_at: nil) + end + + it 'does not process any points' do + expect_any_instance_of(Point).not_to receive(:async_reverse_geocode) + + described_class.perform_now + end + + it 'returns early without querying points' do + allow(Point).to receive(:not_reverse_geocoded) + + described_class.perform_now + + expect(Point).not_to have_received(:not_reverse_geocoded) + end + + it 'does not enqueue any ReverseGeocodingJob jobs' do + expect { described_class.perform_now }.not_to have_enqueued_job(ReverseGeocodingJob) + end + end + + context 'when reverse geocoding is enabled' do + before do + allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) + end + + context 'with no points needing reverse geocoding' do + let!(:geocoded_point) do + create(:point, user: user, reverse_geocoded_at: 1.day.ago) + end + + it 'does not process any points' do + expect_any_instance_of(Point).not_to receive(:async_reverse_geocode) + + described_class.perform_now + end + + it 'does not enqueue any ReverseGeocodingJob jobs' do + expect { described_class.perform_now }.not_to have_enqueued_job(ReverseGeocodingJob) + end + end + + context 'with points needing reverse geocoding' do + let!(:point_without_geocoding1) do + create(:point, user: user, reverse_geocoded_at: nil) + end + let!(:point_without_geocoding2) do + create(:point, user: user, reverse_geocoded_at: nil) + end + let!(:geocoded_point) do + create(:point, user: user, reverse_geocoded_at: 1.day.ago) + end + + it 'processes all points that need reverse geocoding' do + expect { described_class.perform_now }.to have_enqueued_job(ReverseGeocodingJob).exactly(2).times + end + + it 'enqueues jobs with correct parameters' do + expect { described_class.perform_now } + .to have_enqueued_job(ReverseGeocodingJob) + .with('Point', point_without_geocoding1.id) + .and have_enqueued_job(ReverseGeocodingJob) + .with('Point', point_without_geocoding2.id) + end + + it 'uses find_each with correct batch size' do + relation_mock = double('ActiveRecord::Relation') + allow(Point).to receive(:not_reverse_geocoded).and_return(relation_mock) + allow(relation_mock).to receive(:find_each).with(batch_size: 1000) + + described_class.perform_now + + expect(relation_mock).to have_received(:find_each).with(batch_size: 1000) + end + end + + context 'with large number of points needing reverse geocoding' do + before do + # Create 2500 points to test batching + points_data = (1..2500).map do |i| + { + user_id: user.id, + latitude: 40.7128 + (i * 0.0001), + longitude: -74.0060 + (i * 0.0001), + timestamp: Time.current.to_i + i, + lonlat: "POINT(#{-74.0060 + (i * 0.0001)} #{40.7128 + (i * 0.0001)})", + reverse_geocoded_at: nil, + created_at: Time.current, + updated_at: Time.current + } + end + Point.insert_all(points_data) + end + + it 'processes all points in batches' do + expect { described_class.perform_now }.to have_enqueued_job(ReverseGeocodingJob).exactly(2500).times + end + + it 'uses efficient batching to avoid memory issues' do + relation_mock = double('ActiveRecord::Relation') + allow(Point).to receive(:not_reverse_geocoded).and_return(relation_mock) + allow(relation_mock).to receive(:find_each).with(batch_size: 1000) + + described_class.perform_now + + expect(relation_mock).to have_received(:find_each).with(batch_size: 1000) + end + end + end + + describe 'queue configuration' do + it 'uses the reverse_geocoding queue' do + expect(described_class.queue_name).to eq('reverse_geocoding') + end + end + + describe 'error handling' do + before do + allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) + end + + let!(:point_without_geocoding) do + create(:point, user: user, reverse_geocoded_at: nil) + end + + context 'when a point fails to reverse geocode' do + before do + allow_any_instance_of(Point).to receive(:async_reverse_geocode).and_raise(StandardError, 'API error') + end + + it 'continues processing other points despite individual failures' do + expect { described_class.perform_now }.to raise_error(StandardError, 'API error') + end + end + end + end +end \ No newline at end of file diff --git a/spec/jobs/tracks/daily_generation_job_spec.rb b/spec/jobs/tracks/daily_generation_job_spec.rb index c23d9243..284bfd1d 100644 --- a/spec/jobs/tracks/daily_generation_job_spec.rb +++ b/spec/jobs/tracks/daily_generation_job_spec.rb @@ -26,6 +26,11 @@ RSpec.describe Tracks::DailyGenerationJob, type: :job do active_user.update!(points_count: active_user.points.count) trial_user.update!(points_count: trial_user.points.count) + # Mock User.active_or_trial to only return test users + active_or_trial_mock = double('ActiveRecord::Relation') + allow(User).to receive(:active_or_trial).and_return(active_or_trial_mock) + allow(active_or_trial_mock).to receive(:find_each).and_yield(active_user).and_yield(trial_user) + ActiveJob::Base.queue_adapter.enqueued_jobs.clear end diff --git a/spec/mailers/users_mailer_spec.rb b/spec/mailers/users_mailer_spec.rb index 9d0195e3..558c3c48 100644 --- a/spec/mailers/users_mailer_spec.rb +++ b/spec/mailers/users_mailer_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe UsersMailer, type: :mailer do - let(:user) { create(:user, email: 'test@example.com') } + let(:user) { create(:user) } before do stub_const('ENV', ENV.to_hash.merge('SMTP_FROM' => 'hi@dawarich.app')) @@ -14,11 +14,11 @@ RSpec.describe UsersMailer, type: :mailer do it 'renders the headers' do expect(mail.subject).to eq('Welcome to Dawarich!') - expect(mail.to).to eq(['test@example.com']) + expect(mail.to).to eq([user.email]) end it 'renders the body' do - expect(mail.body.encoded).to match('test@example.com') + expect(mail.body.encoded).to match(user.email) end end @@ -27,7 +27,7 @@ RSpec.describe UsersMailer, type: :mailer do it 'renders the headers' do expect(mail.subject).to eq('Explore Dawarich features!') - expect(mail.to).to eq(['test@example.com']) + expect(mail.to).to eq([user.email]) end end @@ -36,7 +36,7 @@ RSpec.describe UsersMailer, type: :mailer do it 'renders the headers' do expect(mail.subject).to eq('⚠️ Your Dawarich trial expires in 2 days') - expect(mail.to).to eq(['test@example.com']) + expect(mail.to).to eq([user.email]) end end @@ -45,7 +45,7 @@ RSpec.describe UsersMailer, type: :mailer do it 'renders the headers' do expect(mail.subject).to eq('💔 Your Dawarich trial expired') - expect(mail.to).to eq(['test@example.com']) + expect(mail.to).to eq([user.email]) end end @@ -54,7 +54,7 @@ RSpec.describe UsersMailer, type: :mailer do it 'renders the headers' do expect(mail.subject).to eq('🚀 Still interested in Dawarich? Subscribe now!') - expect(mail.to).to eq(['test@example.com']) + expect(mail.to).to eq([user.email]) end end @@ -63,7 +63,7 @@ RSpec.describe UsersMailer, type: :mailer do it 'renders the headers' do expect(mail.subject).to eq('📍 Your location data is waiting - Subscribe to Dawarich') - expect(mail.to).to eq(['test@example.com']) + expect(mail.to).to eq([user.email]) end end end diff --git a/spec/serializers/api/user_serializer_spec.rb b/spec/serializers/api/user_serializer_spec.rb index 178c64e0..d4612fe9 100644 --- a/spec/serializers/api/user_serializer_spec.rb +++ b/spec/serializers/api/user_serializer_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Api::UserSerializer do describe '#call' do subject(:serializer) { described_class.new(user).call } - let(:user) { create(:user, email: 'test@example.com', theme: 'dark') } + let(:user) { create(:user) } it 'returns JSON with correct user attributes' do expect(serializer[:user][:email]).to eq(user.email) diff --git a/spec/services/areas/visits/create_spec.rb b/spec/services/areas/visits/create_spec.rb index 18865d6a..f66064ab 100644 --- a/spec/services/areas/visits/create_spec.rb +++ b/spec/services/areas/visits/create_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' RSpec.describe Areas::Visits::Create do describe '#call' do - let(:user) { create(:user) } + let!(:user) { create(:user) } let(:home_area) { create(:area, user:, latitude: 0, longitude: 0, radius: 100) } let(:work_area) { create(:area, user:, latitude: 1, longitude: 1, radius: 100) } diff --git a/spec/services/google_maps/phone_takeout_importer_spec.rb b/spec/services/google_maps/phone_takeout_importer_spec.rb index 301590d4..d35ea598 100644 --- a/spec/services/google_maps/phone_takeout_importer_spec.rb +++ b/spec/services/google_maps/phone_takeout_importer_spec.rb @@ -39,13 +39,13 @@ RSpec.describe GoogleMaps::PhoneTakeoutImporter do it 'creates points with correct data' do parser - expect(Point.all[6].lat).to eq(27.696576) - expect(Point.all[6].lon).to eq(-97.376949) - expect(Point.all[6].timestamp).to eq(1_693_180_140) + expect(user.points[6].lat).to eq(27.696576) + expect(user.points[6].lon).to eq(-97.376949) + expect(user.points[6].timestamp).to eq(1_693_180_140) - expect(Point.last.lat).to eq(27.709617) - expect(Point.last.lon).to eq(-97.375988) - expect(Point.last.timestamp).to eq(1_693_180_320) + expect(user.points.last.lat).to eq(27.709617) + expect(user.points.last.lon).to eq(-97.375988) + expect(user.points.last.timestamp).to eq(1_693_180_320) end end end diff --git a/spec/services/gpx/track_importer_spec.rb b/spec/services/gpx/track_importer_spec.rb index 5aeb7117..341e0fc3 100644 --- a/spec/services/gpx/track_importer_spec.rb +++ b/spec/services/gpx/track_importer_spec.rb @@ -57,11 +57,13 @@ RSpec.describe Gpx::TrackImporter do it 'creates points with correct data' do parser - expect(Point.first.lat).to eq(37.1722103) - expect(Point.first.lon).to eq(-3.55468) - expect(Point.first.altitude).to eq(1066) - expect(Point.first.timestamp).to eq(Time.zone.parse('2024-04-21T10:19:55Z').to_i) - expect(Point.first.velocity).to eq('2.9') + point = user.points.first + + expect(point.lat).to eq(37.1722103) + expect(point.lon).to eq(-3.55468) + expect(point.altitude).to eq(1066) + expect(point.timestamp).to eq(Time.zone.parse('2024-04-21T10:19:55Z').to_i) + expect(point.velocity).to eq('2.9') end end @@ -71,11 +73,13 @@ RSpec.describe Gpx::TrackImporter do it 'creates points with correct data' do parser - expect(Point.first.lat).to eq(10.758321212464024) - expect(Point.first.lon).to eq(106.64234449272531) - expect(Point.first.altitude).to eq(17) - expect(Point.first.timestamp).to eq(1_730_626_211) - expect(Point.first.velocity).to eq('2.8') + point = user.points.first + + expect(point.lat).to eq(10.758321212464024) + expect(point.lon).to eq(106.64234449272531) + expect(point.altitude).to eq(17) + expect(point.timestamp).to eq(1_730_626_211) + expect(point.velocity).to eq('2.8') end end diff --git a/spec/services/maps/bounds_calculator_spec.rb b/spec/services/maps/bounds_calculator_spec.rb index a48ec8bb..d4e28cf5 100644 --- a/spec/services/maps/bounds_calculator_spec.rb +++ b/spec/services/maps/bounds_calculator_spec.rb @@ -5,11 +5,11 @@ require 'rails_helper' RSpec.describe Maps::BoundsCalculator do describe '.call' do subject(:calculate_bounds) do - described_class.call( + described_class.new( target_user: target_user, start_date: start_date, end_date: end_date - ) + ).call end let(:user) { create(:user) } @@ -29,16 +29,18 @@ RSpec.describe Maps::BoundsCalculator do end it 'returns success with bounds data' do - expect(calculate_bounds).to match({ - success: true, - data: { - min_lat: 40.6, - max_lat: 40.8, - min_lng: -74.1, - max_lng: -73.9, - point_count: 3 + expect(calculate_bounds).to match( + { + success: true, + data: { + min_lat: 40.6, + max_lat: 40.8, + min_lng: -74.1, + max_lng: -73.9, + point_count: 3 + } } - }) + ) end end @@ -50,11 +52,13 @@ RSpec.describe Maps::BoundsCalculator do end it 'returns failure with no data message' do - expect(calculate_bounds).to match({ - success: false, - error: 'No data found for the specified date range', - point_count: 0 - }) + expect(calculate_bounds).to match( + { + success: false, + error: 'No data found for the specified date range', + point_count: 0 + } + ) end end @@ -117,4 +121,4 @@ RSpec.describe Maps::BoundsCalculator do end end end -end \ No newline at end of file +end diff --git a/spec/services/maps/date_parameter_coercer_spec.rb b/spec/services/maps/date_parameter_coercer_spec.rb index 107147ae..ac91210d 100644 --- a/spec/services/maps/date_parameter_coercer_spec.rb +++ b/spec/services/maps/date_parameter_coercer_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' RSpec.describe Maps::DateParameterCoercer do describe '.call' do - subject(:coerce_date) { described_class.call(param) } + subject(:coerce_date) { described_class.new(param).call } context 'with integer parameter' do let(:param) { 1_717_200_000 } @@ -67,4 +67,4 @@ RSpec.describe Maps::DateParameterCoercer do end end end -end \ No newline at end of file +end diff --git a/spec/services/maps/hexagon_request_handler_spec.rb b/spec/services/maps/hexagon_request_handler_spec.rb index 1dd6223c..7cef2727 100644 --- a/spec/services/maps/hexagon_request_handler_spec.rb +++ b/spec/services/maps/hexagon_request_handler_spec.rb @@ -17,39 +17,36 @@ RSpec.describe Maps::HexagonRequestHandler do before do stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + + # Clean up database state to avoid conflicts - order matters due to foreign keys + Point.delete_all + Stat.delete_all + User.delete_all end - context 'with authenticated user and bounding box params' do + context 'with authenticated user but no pre-calculated data' do let(:params) do - ActionController::Parameters.new({ - min_lon: -74.1, - min_lat: 40.6, - max_lon: -73.9, - max_lat: 40.8, - hex_size: 1000, - start_date: '2024-06-01T00:00:00Z', - end_date: '2024-06-30T23:59:59Z' - }) + ActionController::Parameters.new( + { + min_lon: -74.1, + min_lat: 40.6, + max_lon: -73.9, + max_lat: 40.8, + hex_size: 1000, + start_date: '2024-06-01T00:00:00Z', + end_date: '2024-06-30T23:59:59Z' + } + ) end - before do - # Create test points within the date range and bounding box - 10.times do |i| - create(:point, - user:, - latitude: 40.7 + (i * 0.001), - longitude: -74.0 + (i * 0.001), - timestamp: Time.new(2024, 6, 15, 12, i).to_i) - end - end - - it 'returns on-the-fly hexagon calculation' do + it 'returns empty feature collection when no pre-calculated data' do result = handle_request expect(result).to be_a(Hash) expect(result['type']).to eq('FeatureCollection') - expect(result['features']).to be_an(Array) - expect(result['metadata']).to be_present + expect(result['features']).to eq([]) + expect(result['metadata']['hexagon_count']).to eq(0) + expect(result['metadata']['source']).to eq('pre_calculated') end end @@ -65,14 +62,16 @@ RSpec.describe Maps::HexagonRequestHandler do hexagon_centers: pre_calculated_centers) end let(:params) do - ActionController::Parameters.new({ - uuid: stat.sharing_uuid, - min_lon: -74.1, - min_lat: 40.6, - max_lon: -73.9, - max_lat: 40.8, - hex_size: 1000 - }) + ActionController::Parameters.new( + { + uuid: stat.sharing_uuid, + min_lon: -74.1, + min_lat: 40.6, + max_lon: -73.9, + max_lat: 40.8, + hex_size: 1000 + } + ) end let(:current_api_user) { nil } @@ -89,35 +88,26 @@ RSpec.describe Maps::HexagonRequestHandler do context 'with public sharing UUID but no pre-calculated centers' do let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) } let(:params) do - ActionController::Parameters.new({ - uuid: stat.sharing_uuid, - min_lon: -74.1, - min_lat: 40.6, - max_lon: -73.9, - max_lat: 40.8, - hex_size: 1000 - }) + ActionController::Parameters.new( + { + uuid: stat.sharing_uuid, + min_lon: -74.1, + min_lat: 40.6, + max_lon: -73.9, + max_lat: 40.8, + hex_size: 1000 + } + ) end let(:current_api_user) { nil } - before do - # Create test points for the stat's month - 5.times do |i| - create(:point, - user:, - latitude: 40.7 + (i * 0.001), - longitude: -74.0 + (i * 0.001), - timestamp: Time.new(2024, 6, 15, 12, i).to_i) - end - end - - it 'falls back to on-the-fly calculation' do + it 'returns empty feature collection when no pre-calculated centers' do result = handle_request expect(result['type']).to eq('FeatureCollection') - expect(result['features']).to be_an(Array) - expect(result['metadata']).to be_present - expect(result['metadata']['pre_calculated']).to be_falsy + expect(result['features']).to eq([]) + expect(result['metadata']['hexagon_count']).to eq(0) + expect(result['metadata']['source']).to eq('pre_calculated') end end @@ -127,14 +117,16 @@ RSpec.describe Maps::HexagonRequestHandler do hexagon_centers: { 'area_too_large' => true }) end let(:params) do - ActionController::Parameters.new({ - uuid: stat.sharing_uuid, - min_lon: -74.1, - min_lat: 40.6, - max_lon: -73.9, - max_lat: 40.8, - hex_size: 1000 - }) + ActionController::Parameters.new( + { + uuid: stat.sharing_uuid, + min_lon: -74.1, + min_lat: 40.6, + max_lon: -73.9, + max_lat: 40.8, + hex_size: 1000 + } + ) end let(:current_api_user) { nil } @@ -156,214 +148,14 @@ RSpec.describe Maps::HexagonRequestHandler do end end - context 'with H3 enabled via parameter' do - let(:params) do - ActionController::Parameters.new({ - min_lon: -74.1, - min_lat: 40.6, - max_lon: -73.9, - max_lat: 40.8, - hex_size: 1000, - start_date: '2024-06-01T00:00:00Z', - end_date: '2024-06-30T23:59:59Z', - use_h3: 'true', - h3_resolution: 6 - }) - end - - before do - # Create test points within the date range - 5.times do |i| - create(:point, - user:, - latitude: 40.7 + (i * 0.001), - longitude: -74.0 + (i * 0.001), - timestamp: Time.new(2024, 6, 15, 12, i).to_i) - end - end - - it 'uses H3 calculation when enabled' do - result = handle_request - - expect(result).to be_a(Hash) - expect(result['type']).to eq('FeatureCollection') - expect(result['features']).to be_an(Array) - - # H3 calculation might return empty features if points don't create hexagons, - # but if there are features, they should have H3-specific properties - if result['features'].any? - feature = result['features'].first - expect(feature).to be_present - - # Only check properties if they exist - some integration paths might - # return features without properties in certain edge cases - if feature['properties'].present? - expect(feature['properties']).to have_key('h3_index') - expect(feature['properties']).to have_key('point_count') - expect(feature['properties']).to have_key('center') - else - # If no properties, this is likely a fallback to non-H3 calculation - # which is acceptable behavior - just verify the feature structure - expect(feature).to have_key('type') - expect(feature).to have_key('geometry') - end - else - # If no features, that's OK - it means the H3 calculation ran but - # didn't produce any hexagons for this data set - expect(result['features']).to eq([]) - end - end - end - - context 'with H3 enabled via environment variable' do - let(:params) do - ActionController::Parameters.new({ - min_lon: -74.1, - min_lat: 40.6, - max_lon: -73.9, - max_lat: 40.8, - hex_size: 1000, - start_date: '2024-06-01T00:00:00Z', - end_date: '2024-06-30T23:59:59Z' - }) - end - - before do - allow(ENV).to receive(:[]).and_call_original - allow(ENV).to receive(:[]).with('HEXAGON_USE_H3').and_return('true') - - # Create test points within the date range - 3.times do |i| - create(:point, - user:, - latitude: 40.7 + (i * 0.001), - longitude: -74.0 + (i * 0.001), - timestamp: Time.new(2024, 6, 15, 12, i).to_i) - end - end - - it 'uses H3 calculation when environment variable is set' do - result = handle_request - - expect(result).to be_a(Hash) - expect(result['type']).to eq('FeatureCollection') - expect(result['features']).to be_an(Array) - expect(result['features']).not_to be_empty - end - end - - context 'when H3 calculation fails' do - let(:params) do - ActionController::Parameters.new({ - min_lon: -74.1, - min_lat: 40.6, - max_lon: -73.9, - max_lat: 40.8, - hex_size: 1000, - start_date: '2024-06-01T00:00:00Z', - end_date: '2024-06-30T23:59:59Z', - use_h3: 'true' - }) - end - - before do - # Create test points within the date range - 2.times do |i| - create(:point, - user:, - latitude: 40.7 + (i * 0.001), - longitude: -74.0 + (i * 0.001), - timestamp: Time.new(2024, 6, 15, 12, i).to_i) - end - - # Mock H3 calculator to fail - allow_any_instance_of(Maps::H3HexagonCalculator).to receive(:call) - .and_return({ success: false, error: 'H3 error' }) - end - - it 'falls back to grid calculation when H3 fails' do - result = handle_request - - expect(result).to be_a(Hash) - expect(result['type']).to eq('FeatureCollection') - expect(result['features']).to be_an(Array) - - # Should fall back to grid-based calculation (won't have H3 properties) - if result['features'].any? - feature = result['features'].first - expect(feature).to be_present - if feature['properties'].present? - expect(feature['properties']).not_to have_key('h3_index') - end - end - end - end - - context 'H3 resolution validation' do - let(:params) do - ActionController::Parameters.new({ - min_lon: -74.1, - min_lat: 40.6, - max_lon: -73.9, - max_lat: 40.8, - hex_size: 1000, - start_date: '2024-06-01T00:00:00Z', - end_date: '2024-06-30T23:59:59Z', - use_h3: 'true', - h3_resolution: invalid_resolution - }) - end - - before do - create(:point, - user:, - latitude: 40.7, - longitude: -74.0, - timestamp: Time.new(2024, 6, 15, 12, 0).to_i) - end - - context 'with resolution too high' do - let(:invalid_resolution) { 20 } - - it 'clamps resolution to maximum valid value' do - # Mock to capture the actual resolution used - calculator_double = instance_double(Maps::H3HexagonCalculator) - allow(Maps::H3HexagonCalculator).to receive(:new) do |user_id, start_date, end_date, resolution| - expect(resolution).to eq(15) # Should be clamped to 15 - calculator_double - end - allow(calculator_double).to receive(:call).and_return( - { success: true, data: { 'type' => 'FeatureCollection', 'features' => [] } } - ) - - handle_request - end - end - - context 'with negative resolution' do - let(:invalid_resolution) { -5 } - - it 'clamps resolution to minimum valid value' do - # Mock to capture the actual resolution used - calculator_double = instance_double(Maps::H3HexagonCalculator) - allow(Maps::H3HexagonCalculator).to receive(:new) do |user_id, start_date, end_date, resolution| - expect(resolution).to eq(0) # Should be clamped to 0 - calculator_double - end - allow(calculator_double).to receive(:call).and_return( - { success: true, data: { 'type' => 'FeatureCollection', 'features' => [] } } - ) - - handle_request - end - end - end context 'error handling' do let(:params) do - ActionController::Parameters.new({ - uuid: 'invalid-uuid' - }) + ActionController::Parameters.new( + { + uuid: 'invalid-uuid' + } + ) end let(:current_api_user) { nil } @@ -374,4 +166,4 @@ RSpec.describe Maps::HexagonRequestHandler do end end end -end \ No newline at end of file +end diff --git a/spec/services/own_tracks/importer_spec.rb b/spec/services/own_tracks/importer_spec.rb index cc9a9713..3305c9eb 100644 --- a/spec/services/own_tracks/importer_spec.rb +++ b/spec/services/own_tracks/importer_spec.rb @@ -23,7 +23,7 @@ RSpec.describe OwnTracks::Importer do it 'correctly writes attributes' do parser - point = Point.first + point = user.points.first expect(point.lonlat.x).to be_within(0.001).of(13.332) expect(point.lonlat.y).to be_within(0.001).of(52.225) expect(point.attributes.except('lonlat')).to include( @@ -75,7 +75,7 @@ RSpec.describe OwnTracks::Importer do it 'correctly converts speed' do parser - expect(Point.first.velocity).to eq('1.4') + expect(user.points.first.velocity).to eq('1.4') end end diff --git a/spec/services/photos/importer_spec.rb b/spec/services/photos/importer_spec.rb index 567898a3..67dd9b58 100644 --- a/spec/services/photos/importer_spec.rb +++ b/spec/services/photos/importer_spec.rb @@ -30,15 +30,18 @@ RSpec.describe Photos::Importer do it 'creates points with correct attributes' do service - expect(Point.first.lat.to_f).to eq(59.0000) - expect(Point.first.lon.to_f).to eq(30.0000) - expect(Point.first.timestamp).to eq(978_296_400) - expect(Point.first.import_id).to eq(import.id) + first_point = user.points.first + second_point = user.points.second - expect(Point.second.lat.to_f).to eq(55.0001) - expect(Point.second.lon.to_f).to eq(37.0001) - expect(Point.second.timestamp).to eq(978_296_400) - expect(Point.second.import_id).to eq(import.id) + expect(first_point.lat.to_f).to eq(59.0000) + expect(first_point.lon.to_f).to eq(30.0000) + expect(first_point.timestamp).to eq(978_296_400) + expect(first_point.import_id).to eq(import.id) + + expect(second_point.lat.to_f).to eq(55.0001) + expect(second_point.lon.to_f).to eq(37.0001) + expect(second_point.timestamp).to eq(978_296_400) + expect(second_point.import_id).to eq(import.id) end end diff --git a/spec/system/map_interaction_spec.rb b/spec/system/map_interaction_spec.rb index 4d616a4e..43dc9e41 100644 --- a/spec/system/map_interaction_spec.rb +++ b/spec/system/map_interaction_spec.rb @@ -15,22 +15,20 @@ RSpec.describe 'Map Interaction', type: :system do # Create a series of points that form a route [ create(:point, user: user, - lonlat: "POINT(13.404954 52.520008)", + lonlat: 'POINT(13.404954 52.520008)', timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80), create(:point, user: user, - lonlat: "POINT(13.405954 52.521008)", + lonlat: 'POINT(13.405954 52.521008)', timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78), create(:point, user: user, - lonlat: "POINT(13.406954 52.522008)", + lonlat: 'POINT(13.406954 52.522008)', timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76), create(:point, user: user, - lonlat: "POINT(13.407954 52.523008)", + lonlat: 'POINT(13.407954 52.523008)', timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74) ] end - - describe 'Map page interaction' do context 'when user is signed in' do include_context 'authenticated map user' @@ -127,7 +125,7 @@ RSpec.describe 'Map Interaction', type: :system do # The calendar panel JavaScript interaction is complex and may not work # reliably in headless test environment, but the button should be functional - puts "Note: Calendar button is functional. Panel interaction may require manual testing." + puts 'Note: Calendar button is functional. Panel interaction may require manual testing.' end end @@ -207,28 +205,30 @@ RSpec.describe 'Map Interaction', type: :system do else # If we can't trigger the popup, at least verify the setup is correct expect(user_settings.dig('maps', 'distance_unit')).to eq('km') - puts "Note: Polyline popup interaction could not be triggered in test environment" + puts 'Note: Polyline popup interaction could not be triggered in test environment' end end end - context 'with miles distance unit' do - let(:user_with_miles) { create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123') } + context 'with miles distance unit' do + let(:user_with_miles) do + create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123') + end let!(:points_for_miles_user) do # Create a series of points that form a route for the miles user [ create(:point, user: user_with_miles, - lonlat: "POINT(13.404954 52.520008)", + lonlat: 'POINT(13.404954 52.520008)', timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80), create(:point, user: user_with_miles, - lonlat: "POINT(13.405954 52.521008)", + lonlat: 'POINT(13.405954 52.521008)', timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78), create(:point, user: user_with_miles, - lonlat: "POINT(13.406954 52.522008)", + lonlat: 'POINT(13.406954 52.522008)', timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76), create(:point, user: user_with_miles, - lonlat: "POINT(13.407954 52.523008)", + lonlat: 'POINT(13.407954 52.523008)', timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74) ] end @@ -280,7 +280,7 @@ RSpec.describe 'Map Interaction', type: :system do else # If we can't trigger the popup, at least verify the setup is correct expect(user_settings.dig('maps', 'distance_unit')).to eq('mi') - puts "Note: Polyline popup interaction could not be triggered in test environment" + puts 'Note: Polyline popup interaction could not be triggered in test environment' end end end @@ -288,22 +288,24 @@ RSpec.describe 'Map Interaction', type: :system do context 'polyline popup content' do context 'with km distance unit' do - let(:user_with_km) { create(:user, settings: { 'maps' => { 'distance_unit' => 'km' } }, password: 'password123') } + let(:user_with_km) do + create(:user, settings: { 'maps' => { 'distance_unit' => 'km' } }, password: 'password123') + end let!(:points_for_km_user) do # Create a series of points that form a route for the km user [ create(:point, user: user_with_km, - lonlat: "POINT(13.404954 52.520008)", + lonlat: 'POINT(13.404954 52.520008)', timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80), create(:point, user: user_with_km, - lonlat: "POINT(13.405954 52.521008)", + lonlat: 'POINT(13.405954 52.521008)', timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78), create(:point, user: user_with_km, - lonlat: "POINT(13.406954 52.522008)", + lonlat: 'POINT(13.406954 52.522008)', timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76), create(:point, user: user_with_km, - lonlat: "POINT(13.407954 52.523008)", + lonlat: 'POINT(13.407954 52.523008)', timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74) ] end @@ -356,28 +358,30 @@ RSpec.describe 'Map Interaction', type: :system do else # If we can't trigger the popup, at least verify the setup is correct expect(user_settings.dig('maps', 'distance_unit')).to eq('km') - puts "Note: Polyline popup interaction could not be triggered in test environment" + puts 'Note: Polyline popup interaction could not be triggered in test environment' end end end context 'with miles distance unit' do - let(:user_with_miles) { create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123') } + let(:user_with_miles) do + create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123') + end let!(:points_for_miles_user) do # Create a series of points that form a route for the miles user [ create(:point, user: user_with_miles, - lonlat: "POINT(13.404954 52.520008)", + lonlat: 'POINT(13.404954 52.520008)', timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80), create(:point, user: user_with_miles, - lonlat: "POINT(13.405954 52.521008)", + lonlat: 'POINT(13.405954 52.521008)', timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78), create(:point, user: user_with_miles, - lonlat: "POINT(13.406954 52.522008)", + lonlat: 'POINT(13.406954 52.522008)', timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76), create(:point, user: user_with_miles, - lonlat: "POINT(13.407954 52.523008)", + lonlat: 'POINT(13.407954 52.523008)', timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74) ] end @@ -429,7 +433,7 @@ RSpec.describe 'Map Interaction', type: :system do else # If we can't trigger the popup, at least verify the setup is correct expect(user_settings.dig('maps', 'distance_unit')).to eq('mi') - puts "Note: Polyline popup interaction could not be triggered in test environment" + puts 'Note: Polyline popup interaction could not be triggered in test environment' end end end @@ -456,7 +460,7 @@ RSpec.describe 'Map Interaction', type: :system do click_button 'Update' end - # Wait for success flash message + # Wait for success flash message expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10) end @@ -710,13 +714,13 @@ RSpec.describe 'Map Interaction', type: :system do it 'allows year selection and month navigation' do # This test is skipped due to calendar panel JavaScript interaction issues # The calendar button exists but the panel doesn't open reliably in test environment - skip "Calendar panel JavaScript interaction needs debugging" + skip 'Calendar panel JavaScript interaction needs debugging' end it 'displays visited cities information' do # This test is skipped due to calendar panel JavaScript interaction issues # The calendar button exists but the panel doesn't open reliably in test environment - skip "Calendar panel JavaScript interaction needs debugging" + skip 'Calendar panel JavaScript interaction needs debugging' end xit 'persists panel state in localStorage' do