Add specs for hexagon API and public sharing; remove debug logs

This commit is contained in:
Eugene Burmakin 2025-09-12 20:44:53 +02:00
parent 5ff35136f2
commit 34e71b5d17
8 changed files with 465 additions and 27 deletions

View file

@ -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

View file

@ -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

View file

@ -5,7 +5,9 @@
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
</form>
<h3 class="font-bold text-lg mb-4">🔗 Sharing Settings</h3>
<h3 class="font-bold text-lg mb-4 flex items-center gap-2">
<%= icon 'link' %> Sharing Settings
</h3>
<div data-controller="sharing-modal"
data-sharing-modal-url-value="<%= sharing_stats_path(year: @year, month: @month) %>">
@ -16,7 +18,7 @@
<span class="label-text font-medium">Enable public access</span>
<input type="checkbox"
name="enabled"
<%= 'checked' if @stat&.sharing_enabled? %>
<%= 'checked' if @stat.sharing_enabled? %>
class="toggle toggle-primary"
data-action="change->sharing-modal#toggleSharing"
data-sharing-modal-target="enableToggle" />
@ -28,7 +30,7 @@
<!-- Expiration Settings (shown when enabled) -->
<div data-sharing-modal-target="expirationSettings"
class="<%= 'hidden' unless @stat&.sharing_enabled? %>">
class="<%= 'hidden' unless @stat.sharing_enabled? %>">
<div class="form-control mb-4">
<label class="label">
@ -57,11 +59,11 @@
readonly
class="input input-bordered join-item flex-1"
data-sharing-modal-target="sharingLink"
value="<%= @stat&.sharing_enabled? ? public_stat_url(@stat.sharing_uuid) : '' %>" />
value="<%= @stat.sharing_enabled? ? public_stat_url(@stat.sharing_uuid) : '' %>" />
<button type="button"
class="btn btn-outline join-item"
data-action="click->sharing-modal#copyLink">
📋 Copy
<%= icon 'copy' %> Copy
</button>
</div>
<div class="label">
@ -73,14 +75,11 @@
<!-- Privacy Notice (always visible) -->
<div class="alert alert-info mb-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<%= icon 'info' %>
<div>
<h3 class="font-bold">Privacy Protection</h3>
<div class="text-sm">
• Exact coordinates are hidden (approximate locations only)<br>
• Map interaction is disabled<br>
• Exact coordinates are hidden<br>
• Personal information is not included
</div>
</div>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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