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