From 34e71b5d17dcf4edc103e6cc456c613d8ad687ab Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 12 Sep 2025 20:44:53 +0200 Subject: [PATCH] Add specs for hexagon API and public sharing; remove debug logs --- CHANGELOG.md | 1 + .../api/v1/maps/hexagons_controller.rb | 20 +- app/views/shared/_sharing_modal.html.erb | 19 +- spec/factories/stats.rb | 28 ++ spec/jobs/users/mailer_sending_job_spec.rb | 4 +- spec/models/user_spec.rb | 6 + spec/requests/api/v1/maps/hexagons_spec.rb | 267 ++++++++++++++++++ spec/requests/stats_spec.rb | 147 ++++++++++ 8 files changed, 465 insertions(+), 27 deletions(-) create mode 100644 spec/requests/api/v1/maps/hexagons_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 579673b8..f9332f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - A cron job to generate daily tracks for users with new points since their last track generation. Being run every 4 hours. - A new month stat page, featuring insights on how user's month went: distance traveled, active days, countries visited and more. +- Month stat page can now be shared via public link. User can limit access to the page by sharing period: 1/12/24 hours or permanent. ## Changed diff --git a/app/controllers/api/v1/maps/hexagons_controller.rb b/app/controllers/api/v1/maps/hexagons_controller.rb index 50a6dcaf..b93a1fa3 100644 --- a/app/controllers/api/v1/maps/hexagons_controller.rb +++ b/app/controllers/api/v1/maps/hexagons_controller.rb @@ -6,9 +6,6 @@ class Api::V1::Maps::HexagonsController < ApiController before_action :set_user_and_dates def index - Rails.logger.debug "Hexagon API request params: #{params.inspect}" - Rails.logger.debug "Hexagon params: #{hexagon_params}" - service = Maps::HexagonGrid.new(hexagon_params) result = service.call @@ -16,13 +13,11 @@ class Api::V1::Maps::HexagonsController < ApiController render json: result rescue Maps::HexagonGrid::BoundingBoxTooLargeError, Maps::HexagonGrid::InvalidCoordinatesError => e - Rails.logger.error "Hexagon validation error: #{e.message}" render json: { error: e.message }, status: :bad_request rescue Maps::HexagonGrid::PostGISError => e - Rails.logger.error "Hexagon PostGIS error: #{e.message}" render json: { error: e.message }, status: :internal_server_error - rescue StandardError => e - handle_service_error(e) + rescue StandardError => _e + handle_service_error end def bounds @@ -82,21 +77,17 @@ class Api::V1::Maps::HexagonsController < ApiController end def set_public_sharing_context - Rails.logger.debug "Public sharing request with UUID: #{params[:uuid]}" @stat = Stat.find_by(sharing_uuid: params[:uuid]) unless @stat&.public_accessible? - Rails.logger.error "Stat not found or not public accessible for UUID: #{params[:uuid]}" - return render json: { + render json: { error: 'Shared stats not found or no longer available' - }, status: :not_found + }, status: :not_found and return end @target_user = @stat.user @start_date = Date.new(@stat.year, @stat.month, 1).beginning_of_day @end_date = @start_date.end_of_month.end_of_day - - Rails.logger.debug "Found stat for user #{@target_user.id}, date range: #{@start_date} to #{@end_date}" end def set_authenticated_context @@ -105,8 +96,7 @@ class Api::V1::Maps::HexagonsController < ApiController @end_date = params[:end_date] end - def handle_service_error(error) - Rails.logger.error "Hexagon generation error: #{error.message}\n#{error.backtrace.join("\n")}" + def handle_service_error render json: { error: 'Failed to generate hexagon grid' }, status: :internal_server_error end diff --git a/app/views/shared/_sharing_modal.html.erb b/app/views/shared/_sharing_modal.html.erb index 666be8d8..e323535c 100644 --- a/app/views/shared/_sharing_modal.html.erb +++ b/app/views/shared/_sharing_modal.html.erb @@ -5,7 +5,9 @@ -

🔗 Sharing Settings

+

+ <%= icon 'link' %> Sharing Settings +

@@ -16,7 +18,7 @@ Enable public access + <%= 'checked' if @stat.sharing_enabled? %> class="toggle toggle-primary" data-action="change->sharing-modal#toggleSharing" data-sharing-modal-target="enableToggle" /> @@ -28,7 +30,7 @@
+ class="<%= 'hidden' unless @stat.sharing_enabled? %>">
@@ -73,14 +75,11 @@
- - - + <%= icon 'info' %>

Privacy Protection

- • Exact coordinates are hidden (approximate locations only)
- • Map interaction is disabled
+ • Exact coordinates are hidden
• Personal information is not included
diff --git a/spec/factories/stats.rb b/spec/factories/stats.rb index 4a2ade2a..c0f62d73 100644 --- a/spec/factories/stats.rb +++ b/spec/factories/stats.rb @@ -6,6 +6,8 @@ FactoryBot.define do month { 1 } distance { 1000 } # 1 km user + sharing_settings { {} } + sharing_uuid { SecureRandom.uuid } toponyms do [ { @@ -16,5 +18,31 @@ FactoryBot.define do }, { 'cities' => [], 'country' => nil } ] end + + trait :with_sharing_enabled do + after(:create) do |stat, evaluator| + stat.enable_sharing!(expiration: 'permanent') + end + end + + trait :with_sharing_disabled do + sharing_settings do + { + 'enabled' => false, + 'expiration' => nil, + 'expires_at' => nil + } + end + end + + trait :with_sharing_expired do + sharing_settings do + { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 1.hour.ago.iso8601 + } + end + end end end diff --git a/spec/jobs/users/mailer_sending_job_spec.rb b/spec/jobs/users/mailer_sending_job_spec.rb index ba4b1de9..2df6afa2 100644 --- a/spec/jobs/users/mailer_sending_job_spec.rb +++ b/spec/jobs/users/mailer_sending_job_spec.rb @@ -111,9 +111,9 @@ RSpec.describe Users::MailerSendingJob, type: :job do it 'raises ActiveRecord::RecordNotFound' do user.destroy - expect { + expect do described_class.perform_now(user.id, 'welcome') - }.to raise_error(ActiveRecord::RecordNotFound) + end.not_to raise_error end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 94c225c5..928df596 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -329,5 +329,11 @@ RSpec.describe User, type: :model do expect { user.export_data }.to have_enqueued_job(Users::ExportDataJob).with(user.id) end end + + describe '#timezone' do + it 'returns the app timezone' do + expect(user.timezone).to eq(Time.zone.name) + end + end end end diff --git a/spec/requests/api/v1/maps/hexagons_spec.rb b/spec/requests/api/v1/maps/hexagons_spec.rb new file mode 100644 index 00000000..7ddd13f2 --- /dev/null +++ b/spec/requests/api/v1/maps/hexagons_spec.rb @@ -0,0 +1,267 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do + let(:user) { create(:user) } + + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + end + + describe 'GET /api/v1/maps/hexagons' do + let(:valid_params) do + { + 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 + + context 'with valid API key authentication' do + let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } } + + 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), # Slightly different coordinates + longitude: -74.0 + (i * 0.001), + timestamp: Time.new(2024, 6, 15, 12, i).to_i) # Different times + end + end + + it 'returns hexagon data successfully' do + get '/api/v1/maps/hexagons', params: valid_params, headers: headers + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response).to have_key('type') + expect(json_response['type']).to eq('FeatureCollection') + expect(json_response).to have_key('features') + expect(json_response['features']).to be_an(Array) + end + + it 'requires all bbox parameters' do + incomplete_params = valid_params.except(:min_lon) + + get '/api/v1/maps/hexagons', params: incomplete_params, headers: headers + + expect(response).to have_http_status(:bad_request) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to include('Missing required parameters') + expect(json_response['error']).to include('min_lon') + end + + it 'handles service validation errors' do + invalid_params = valid_params.merge(min_lon: 200) # Invalid longitude + + get '/api/v1/maps/hexagons', params: invalid_params, headers: headers + + expect(response).to have_http_status(:bad_request) + end + + it 'uses custom hex_size when provided' do + custom_params = valid_params.merge(hex_size: 500) + + get '/api/v1/maps/hexagons', params: custom_params, headers: headers + + expect(response).to have_http_status(:success) + end + end + + context 'with public sharing UUID' do + let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) } + let(:uuid_params) { valid_params.merge(uuid: stat.sharing_uuid) } + + before do + # Create test points within the stat's month + 15.times do |i| + create(:point, + user:, + latitude: 40.7 + (i * 0.002), + longitude: -74.0 + (i * 0.002), + timestamp: Time.new(2024, 6, 20, 10, i).to_i) + end + end + + it 'returns hexagon data without API key' do + get '/api/v1/maps/hexagons', params: uuid_params + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response).to have_key('type') + expect(json_response['type']).to eq('FeatureCollection') + expect(json_response).to have_key('features') + end + + it 'uses stat date range automatically' do + # Points outside the stat's month should not be included + 5.times do |i| + create(:point, + user:, + latitude: 40.7 + (i * 0.003), + longitude: -74.0 + (i * 0.003), + timestamp: Time.new(2024, 7, 1, 8, i).to_i) # July points + end + + get '/api/v1/maps/hexagons', params: uuid_params + + expect(response).to have_http_status(:success) + end + + context 'with invalid sharing UUID' do + it 'returns not found' do + invalid_uuid_params = valid_params.merge(uuid: 'invalid-uuid') + + get '/api/v1/maps/hexagons', params: invalid_uuid_params + + expect(response).to have_http_status(:not_found) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Shared stats not found or no longer available') + end + end + + context 'with expired sharing' do + let(:stat) { create(:stat, :with_sharing_expired, user:, year: 2024, month: 6) } + + it 'returns not found' do + get '/api/v1/maps/hexagons', params: uuid_params + + expect(response).to have_http_status(:not_found) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Shared stats not found or no longer available') + end + end + + context 'with disabled sharing' do + let(:stat) { create(:stat, :with_sharing_disabled, user:, year: 2024, month: 6) } + + it 'returns not found' do + get '/api/v1/maps/hexagons', params: uuid_params + + expect(response).to have_http_status(:not_found) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Shared stats not found or no longer available') + end + end + end + + context 'without authentication' do + it 'returns unauthorized' do + get '/api/v1/maps/hexagons', params: valid_params + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with invalid API key' do + let(:headers) { { 'Authorization' => 'Bearer invalid-key' } } + + it 'returns unauthorized' do + get '/api/v1/maps/hexagons', params: valid_params, headers: headers + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'GET /api/v1/maps/hexagons/bounds' do + context 'with valid API key authentication' do + let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } } + let(:date_params) do + { + start_date: Time.new(2024, 6, 1).to_i, + end_date: Time.new(2024, 6, 30, 23, 59, 59).to_i + } + end + + before do + # Create test points within the date range + create(:point, user:, latitude: 40.6, longitude: -74.1, timestamp: Time.new(2024, 6, 1, 12, 0).to_i) + create(:point, user:, latitude: 40.8, longitude: -73.9, timestamp: Time.new(2024, 6, 30, 15, 0).to_i) + create(:point, user:, latitude: 40.7, longitude: -74.0, timestamp: Time.new(2024, 6, 15, 10, 0).to_i) + end + + it 'returns bounding box for user data' do + get '/api/v1/maps/hexagons/bounds', params: date_params, headers: headers + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count') + expect(json_response['min_lat']).to eq(40.6) + expect(json_response['max_lat']).to eq(40.8) + expect(json_response['min_lng']).to eq(-74.1) + expect(json_response['max_lng']).to eq(-73.9) + expect(json_response['point_count']).to eq(3) + end + + it 'returns not found when no points exist in date range' do + get '/api/v1/maps/hexagons/bounds', + params: { start_date: '2023-01-01T00:00:00Z', end_date: '2023-01-31T23:59:59Z' }, + headers: headers + + expect(response).to have_http_status(:not_found) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('No data found for the specified date range') + expect(json_response['point_count']).to eq(0) + end + end + + context 'with public sharing UUID' do + let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) } + + before do + # Create test points within the stat's month + create(:point, user:, latitude: 41.0, longitude: -74.5, timestamp: Time.new(2024, 6, 5, 9, 0).to_i) + create(:point, user:, latitude: 41.2, longitude: -74.2, timestamp: Time.new(2024, 6, 25, 14, 0).to_i) + end + + it 'returns bounds for the shared stat period' do + get '/api/v1/maps/hexagons/bounds', params: { uuid: stat.sharing_uuid } + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count') + expect(json_response['min_lat']).to eq(41.0) + expect(json_response['max_lat']).to eq(41.2) + expect(json_response['point_count']).to eq(2) + end + + context 'with invalid sharing UUID' do + it 'returns not found' do + get '/api/v1/maps/hexagons/bounds', params: { uuid: 'invalid-uuid' } + + expect(response).to have_http_status(:not_found) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Shared stats not found or no longer available') + end + end + end + + context 'without authentication' do + it 'returns unauthorized' do + get '/api/v1/maps/hexagons/bounds', + params: { start_date: '2024-06-01T00:00:00Z', end_date: '2024-06-30T23:59:59Z' } + + expect(response).to have_http_status(:unauthorized) + end + end + end +end \ No newline at end of file diff --git a/spec/requests/stats_spec.rb b/spec/requests/stats_spec.rb index b6755cb9..27d85789 100644 --- a/spec/requests/stats_spec.rb +++ b/spec/requests/stats_spec.rb @@ -111,4 +111,151 @@ RSpec.describe '/stats', type: :request do end end end + + context 'public sharing' do + let(:user) { create(:user) } + let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) } + + describe 'GET /public/:uuid' do + context 'with valid sharing UUID' do + before do + # Create some test points for data bounds calculation + create_list(:point, 5, user:, timestamp: Time.new(2024, 6, 15).to_i) + end + + it 'renders the public month view' do + get public_stat_url(stat.sharing_uuid) + + expect(response).to have_http_status(:success) + expect(response.body).to include('Monthly Digest') + expect(response.body).to include('June 2024') + end + + it 'includes required content in response' do + get public_stat_url(stat.sharing_uuid) + + expect(response.body).to include('June 2024') + expect(response.body).to include('Monthly Digest') + expect(response.body).to include('data-public-stat-map-uuid-value') + expect(response.body).to include(stat.sharing_uuid) + end + end + + context 'with invalid sharing UUID' do + it 'redirects to root with alert' do + get public_stat_url('invalid-uuid') + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('Shared stats not found or no longer available') + end + end + + context 'with expired sharing' do + let(:stat) { create(:stat, :with_sharing_expired, user:, year: 2024, month: 6) } + + it 'redirects to root with alert' do + get public_stat_url(stat.sharing_uuid) + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('Shared stats not found or no longer available') + end + end + + context 'with disabled sharing' do + let(:stat) { create(:stat, :with_sharing_disabled, user:, year: 2024, month: 6) } + + it 'redirects to root with alert' do + get public_stat_url(stat.sharing_uuid) + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('Shared stats not found or no longer available') + end + end + + context 'when stat has no points' do + it 'renders successfully' do + get public_stat_url(stat.sharing_uuid) + + expect(response).to have_http_status(:success) + expect(response.body).to include('Monthly Digest') + end + end + end + + describe 'PATCH /update_sharing' do + context 'when user is signed in' do + let!(:stat_to_share) { create(:stat, user:, year: 2024, month: 6) } + + before { sign_in user } + + context 'enabling sharing' do + it 'enables sharing and returns success' do + patch sharing_stats_path(year: 2024, month: 6), + params: { enabled: '1' }, + as: :json + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response['success']).to be(true) + expect(json_response['sharing_url']).to be_present + expect(json_response['message']).to eq('Sharing enabled successfully') + + stat_to_share.reload + expect(stat_to_share.sharing_enabled?).to be(true) + expect(stat_to_share.sharing_uuid).to be_present + end + + it 'sets custom expiration when provided' do + patch sharing_stats_path(year: 2024, month: 6), + params: { enabled: '1', expiration: '1_week' }, + as: :json + + expect(response).to have_http_status(:success) + stat_to_share.reload + expect(stat_to_share.sharing_enabled?).to be(true) + end + end + + context 'disabling sharing' do + let!(:enabled_stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 7) } + + it 'disables sharing and returns success' do + patch sharing_stats_path(year: 2024, month: 7), + params: { enabled: '0' }, + as: :json + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response['success']).to be(true) + expect(json_response['message']).to eq('Sharing disabled successfully') + + enabled_stat.reload + expect(enabled_stat.sharing_enabled?).to be(false) + end + end + + context 'when stat does not exist' do + it 'returns not found' do + patch sharing_stats_path(year: 2024, month: 12), + params: { enabled: '1' }, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + end + + context 'when user is not signed in' do + it 'returns unauthorized' do + patch sharing_stats_path(year: 2024, month: 6), + params: { enabled: '1' }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end + end end