diff --git a/docs/plans/test-coverage-implementation/tracker.md b/docs/plans/test-coverage-implementation/tracker.md index 5ca2fe33..65eacb85 100644 --- a/docs/plans/test-coverage-implementation/tracker.md +++ b/docs/plans/test-coverage-implementation/tracker.md @@ -10,11 +10,11 @@ | ---------- | ------- | ------------- | ----------- | --------- | | CRITICAL | 8 | 0 | 8 | 0 | | HIGH | 6 | 0 | 6 | 0 | -| MEDIUM | 7 | 0 | 0 | 0 | +| MEDIUM | 7 | 0 | 7 | 0 | | LOW (Models) | 5 | 0 | 0 | 0 | | LOW (Components) | 13 | 0 | 0 | 0 | | SYSTEM | 1 | 0 | 0 | 0 | -| **TOTAL** | **43** | **0** | **17** | **0** | +| **TOTAL** | **43** | **0** | **24** | **0** | --- @@ -50,13 +50,13 @@ | # | Item | Status | Notes | | --- | ------ | -------- | ------- | -| 15 | Translator Service | ⬜ Not Started | File: `spec/services/translator_spec.rb` | -| 16 | Locations Searcher Service | ⬜ Not Started | File: `spec/services/locations/searcher_spec.rb` | -| 17 | Google Maps Services | ⬜ Not Started | File: `spec/services/locations/google_maps_services_spec.rb` | -| 18 | Vancouver City Syncer Service | ⬜ Not Started | File: `spec/services/external/vancouver_city/syncer_spec.rb` | -| 19 | Analytics Visit Model | ⬜ Not Started | File: `spec/models/analytics/visit_spec.rb` | -| 20 | Analytics Event Model | ⬜ Not Started | File: `spec/models/analytics/event_spec.rb` | -| 21 | Analytics Impression Model | ⬜ Not Started | File: `spec/models/analytics/impression_spec.rb` | +| 15 | Translator Service | ✅ Completed | File: `spec/services/translator_spec.rb` (42 examples) | +| 16 | Locations Searcher Service | ✅ Completed | File: `spec/services/locations/searcher_spec.rb` (38 examples) | +| 17 | Google Maps Services | ✅ Completed | File: `spec/services/locations/google_maps_services_spec.rb` (55 examples) | +| 18 | Vancouver City Syncer Service | ✅ Completed | File: `spec/services/external/vancouver_city/syncer_spec.rb` (63 examples) | +| 19 | Analytics Visit Model | ✅ Completed | File: `spec/models/analytics/visit_spec.rb` (47 examples) + factory created | +| 20 | Analytics Event Model | ✅ Completed | File: `spec/models/analytics/event_spec.rb` (51 examples) | +| 21 | Analytics Impression Model | ✅ Completed | File: `spec/models/analytics/impression_spec.rb` (72 examples) | --- @@ -120,9 +120,9 @@ Track creation of needed FactoryBot factories: | `zones.rb` | ✅ Completed | For Zone model specs | | `facility_schedule.rb` | ✅ Exists | Update if needed | | `facility_time_slot.rb` | ✅ Exists | Update if needed | -| `analytics/visit.rb` | ⬜ Not Started | For Analytics::Visit specs | -| `analytics/event.rb` | ⬜ Not Started | For Analytics::Event specs | -| `analytics/impression.rb` | ⬜ Not Started | For Analytics::Impression specs | +| `analytics/visit.rb` | ✅ Completed | For Analytics::Visit specs | +| `analytics/event.rb` | ✅ Completed | For Analytics::Event specs | +| `analytics/impression.rb` | ✅ Completed | For Analytics::Impression specs | --- @@ -153,7 +153,7 @@ Track creation of shared example groups: ```plain CRITICAL: ██████████ 8/8 (100%) HIGH: ██████████ 6/6 (100%) -MEDIUM: ░░░░░░░░░░ 0/7 (0%) +MEDIUM: ██████████ 7/7 (100%) LOW: ░░░░░░░░░░ 0/18 (0%) SYSTEM: ░░░░░░░░░░ 0/1 (0%) ``` @@ -161,7 +161,7 @@ SYSTEM: ░░░░░░░░░░ 0/1 (0%) ### Overall Progress ```plain -TOTAL: ████████████████████████░░░░░ 17/43 (40%) +TOTAL: █████████████████████████████████░░░░░ 24/43 (56%) ``` --- @@ -179,6 +179,9 @@ TOTAL: ████████████████████████ | Date | Item # | Action | Notes | | ------ | -------- | -------- | ------- | +| 2026-01-25 | 15-21 | Completed | Phase 1 (MEDIUM priority) completed - 7 service and analytics model tests with 368 examples | +| 2026-01-25 | All | Coverage | Improved coverage from 64.3% to 71.33% with Phase 1 completion | +| 2026-01-25 | Factories | Completed | Created analytics factories: `visit.rb`, `event.rb`, `impression.rb` | | 2026-01-18 | 41 | Completed | Fixed critical bugs in Facility model (`this.user_id` → `user_id`, distance method parameter handling) | | 2026-01-18 | 42 | Completed | Added SimpleCov to Gemfile and configured coverage reporting | | 2026-01-18 | 43 | Completed | Achieved 64.3% overall code coverage with detailed HTML reports | diff --git a/spec/factories/analytics/event.rb b/spec/factories/analytics/event.rb new file mode 100644 index 00000000..59d125d0 --- /dev/null +++ b/spec/factories/analytics/event.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :analytics_event, class: "Analytics::Event" do + association :visit, factory: :analytics_visit + + controller_name { "facilities" } + action_name { "index" } + request_url { "https://example.com/facilities" } + + trait :show_action do + action_name { "show" } + request_url { "https://example.com/facilities/1" } + end + + trait :create_action do + action_name { "create" } + request_url { "https://example.com/facilities" } + end + + trait :update_action do + action_name { "update" } + request_url { "https://example.com/facilities/1" } + end + end +end diff --git a/spec/factories/analytics/impression.rb b/spec/factories/analytics/impression.rb new file mode 100644 index 00000000..c8d413da --- /dev/null +++ b/spec/factories/analytics/impression.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :analytics_impression, class: "Analytics::Impression" do + association :event, factory: :analytics_event + association :impressionable, factory: :facility + + trait :for_service do + association :impressionable, factory: :service + end + + trait :for_zone do + association :impressionable, factory: :zone + end + end +end diff --git a/spec/factories/analytics/visit.rb b/spec/factories/analytics/visit.rb new file mode 100644 index 00000000..9e88cbb2 --- /dev/null +++ b/spec/factories/analytics/visit.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :analytics_visit, class: "Analytics::Visit" do + sequence(:uuid, 1000) { |n| "visit-#{n}-#{SecureRandom.hex(8)}" } + sequence(:session_id, "aa") { |n| "session-#{n}-#{SecureRandom.hex(4)}" } + + # Coordinates are nullable - default to nil for basic factory + lat { nil } + long { nil } + + # Factory with coordinates + trait :with_coordinates do + lat { 49.2827 + ((rand - 0.5) * 0.1) } # Vancouver area with some variation + long { -123.1207 + ((rand - 0.5) * 0.1) } + end + + # Factory with specific Vancouver coordinates + trait :vancouver_center do + lat { 49.2827 } + long { -123.1207 } + end + + # Factory with downtown Vancouver coordinates + trait :downtown_vancouver do + lat { 49.2848 } + long { -123.1228 } + end + + # Factory with coordinates but slightly outside Vancouver + trait :outside_vancouver do + lat { 49.0 + (rand * 2) } # Random latitude around Vancouver area + long { -123.0 + (rand * 2) } # Random longitude around Vancouver area + end + + # Factory with invalid coordinates (negative latitude, positive longitude - wrong hemisphere) + trait :invalid_coordinates do + lat { -33.8688 } # Sydney, Australia + long { 151.2093 } + end + + # Trait for visits that need manual event creation + trait :requires_events do + # This trait indicates that events should be created manually in tests + # Useful when you want to test associations without depending on event factories + end + + # Trait for new session visits (different creation time) + trait :new_session do + transient do + session_start_time { 1.hour.ago } + end + + created_at { session_start_time } + updated_at { session_start_time } + end + + # Trait for returning session visits (updated later) + trait :returning_session do + transient do + initial_visit_time { 1.day.ago } + return_time { 10.minutes.ago } + end + + created_at { initial_visit_time } + updated_at { return_time } + end + + # Trait for mobile session patterns + trait :mobile_session do + with_coordinates + # Mobile sessions typically have coordinates and are updated more frequently + end + + # Trait for desktop session patterns + trait :desktop_session do + # Desktop sessions typically don't have coordinates + lat { nil } + long { nil } + end + end +end diff --git a/spec/models/analytics/event_spec.rb b/spec/models/analytics/event_spec.rb new file mode 100644 index 00000000..968c185b --- /dev/null +++ b/spec/models/analytics/event_spec.rb @@ -0,0 +1,588 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Analytics::Event, type: :model do + # Use the factory for clean test setup + subject(:event) { build(:analytics_event) } + + describe "Factory" do + it "creates a valid event with default factory" do + expect(create(:analytics_event)).to be_valid + end + + context "with traits" do + it "creates a valid event with show_action trait" do + event = create(:analytics_event, :show_action) + expect(event).to be_valid + expect(event.action_name).to eq("show") + expect(event.request_url).to eq("https://example.com/facilities/1") + end + + it "creates a valid event with create_action trait" do + event = create(:analytics_event, :create_action) + expect(event).to be_valid + expect(event.action_name).to eq("create") + expect(event.request_url).to eq("https://example.com/facilities") + end + + it "creates a valid event with update_action trait" do + event = create(:analytics_event, :update_action) + expect(event).to be_valid + expect(event.action_name).to eq("update") + expect(event.request_url).to eq("https://example.com/facilities/1") + end + + it "creates valid events with combined traits" do + event = create(:analytics_event, :show_action) + expect(event.controller_name).to eq("facilities") + expect(event.action_name).to eq("show") + end + end + + context "with association to visit" do + it "creates valid event with associated visit" do + visit = create(:analytics_visit) + event = create(:analytics_event, visit: visit) + + expect(event).to be_valid + expect(event.visit).to eq(visit) + expect(visit.events).to include(event) + end + end + end + + describe "Validations" do + it { is_expected.to validate_presence_of(:controller_name) } + it { is_expected.to validate_presence_of(:action_name) } + it { is_expected.to validate_presence_of(:request_url) } + + context "when controller_name is missing" do + it "is invalid" do + event = build(:analytics_event, controller_name: nil) + expect(event).not_to be_valid + expect(event.errors[:controller_name]).to include("can't be blank") + end + end + + context "when action_name is missing" do + it "is invalid" do + event = build(:analytics_event, action_name: nil) + expect(event).not_to be_valid + expect(event.errors[:action_name]).to include("can't be blank") + end + end + + context "when request_url is missing" do + it "is invalid" do + event = build(:analytics_event, request_url: nil) + expect(event).not_to be_valid + expect(event.errors[:request_url]).to include("can't be blank") + end + end + + context "with optional fields" do + it "allows nil latitude" do + event = build(:analytics_event, lat: nil) + expect(event).to be_valid + end + + it "allows nil longitude" do + event = build(:analytics_event, long: nil) + expect(event).to be_valid + end + + it "allows nil request_ip" do + event = build(:analytics_event, request_ip: nil) + expect(event).to be_valid + end + + it "allows nil request_user_agent" do + event = build(:analytics_event, request_user_agent: nil) + expect(event).to be_valid + end + + it "allows nil request_params" do + event = build(:analytics_event, request_params: nil) + expect(event).to be_valid + end + end + + context "with coordinate fields" do + it "allows valid latitude" do + event = build(:analytics_event, lat: 49.2827, long: -123.1207) + expect(event).to be_valid + end + + it "allows negative latitude" do + event = build(:analytics_event, lat: -33.8688, long: 151.2093) + expect(event).to be_valid + end + + it "allows positive longitude" do + event = build(:analytics_event, lat: 49.2827, long: 151.2093) + expect(event).to be_valid + end + + it "allows zero coordinates" do + event = build(:analytics_event, lat: 0, long: 0) + expect(event).to be_valid + end + + it "allows very small coordinates" do + event = build(:analytics_event, lat: 0.000001, long: -0.000001) + expect(event).to be_valid + end + + it "allows very large coordinates" do + event = build(:analytics_event, lat: 90, long: 180) + expect(event).to be_valid + end + end + end + + describe "Associations" do + it { is_expected.to belong_to(:visit) } + it { is_expected.to have_many(:impressions).dependent(:destroy) } + it { is_expected.to have_many(:facilities).through(:impressions).source(:impressionable) } + + context "with dependent destroy for impressions" do + it "destroys associated impressions when event is destroyed" do + event = create(:analytics_event) + impression1 = create(:analytics_impression, event: event) + impression2 = create(:analytics_impression, event: event) + + expect { event.destroy }.to change(Analytics::Impression, :count).by(-2) + expect(Analytics::Impression.find_by(id: impression1.id)).to be_nil + expect(Analytics::Impression.find_by(id: impression2.id)).to be_nil + end + end + + context "belongs_to visit" do + it "can access associated visit" do + visit = create(:analytics_visit) + event = create(:analytics_event, visit: visit) + + expect(event.visit).to eq(visit) + expect(visit.events).to include(event) + end + + it "is invalid without associated visit" do + event = build(:analytics_event, visit: nil) + expect(event).not_to be_valid + end + end + + context "has_many impressions" do + let(:event) { create(:analytics_event) } + let!(:impression1) { create(:analytics_impression, event: event) } + let!(:impression2) { create(:analytics_impression, event: event) } + + it "can access associated impressions" do + expect(event.impressions).to contain_exactly(impression1, impression2) + end + + it "orders impressions correctly" do + # Test that impressions are returned in the expected order + expect(event.impressions.first).to eq(impression1) + expect(event.impressions.last).to eq(impression2) + end + end + + context "has_many facilities through impressions" do + let(:event) { create(:analytics_event) } + let!(:facility1) { create(:facility) } + let!(:facility2) { create(:facility) } + let!(:impression1) { create(:analytics_impression, event: event, impressionable: facility1) } + let!(:impression2) { create(:analytics_impression, event: event, impressionable: facility2) } + + it "can access facilities through impressions" do + expect(event.facilities).to contain_exactly(facility1, facility2) + end + + it "correctly filters by source_type Facility" do + # This tests the source_type specification in the through association + service = create(:service) + create(:analytics_impression, event: event, impressionable: service) + + # Should only return facilities, not services + expect(event.facilities).to contain_exactly(facility1, facility2) + expect(event.facilities).not_to include(service) + + # Verify that the association works correctly by checking the source_type + # The through association should only return records where impressionable_type = 'Facility' + expect(event.impressions.where(impressionable_type: "Facility").count).to eq(2) + expect(event.impressions.where(impressionable_type: "Service").count).to eq(1) + end + + it "returns empty array when no facility impressions exist" do + event = create(:analytics_event) + service = create(:service) + create(:analytics_impression, event: event, impressionable: service) + + expect(event.facilities).to be_empty + end + + it "handles duplicate facility impressions" do + # Start with a clean event for this test + clean_event = create(:analytics_event) + + # Create multiple impressions for the same facility + facility = create(:facility) + create(:analytics_impression, event: clean_event, impressionable: facility) + + # Creating a second impression for the same facility/event will fail due to uniqueness constraint + expect do + create(:analytics_impression, event: clean_event, impressionable: facility) + end.to raise_error(ActiveRecord::RecordInvalid, /Impressionable has already been taken/) + + # Should still return the facility once - reload to ensure we're getting fresh data + clean_event.reload + expect(clean_event.facilities).to contain_exactly(facility) + end + end + end + + describe "JSON request_params handling" do + it "accepts hash for request_params" do + params = { search: "test", page: 1, filters: { category: "sports" } } + event = build(:analytics_event, request_params: params) + + expect(event).to be_valid + # Rails converts symbol keys to strings in JSON columns + expected_params = { "search" => "test", "page" => 1, "filters" => { "category" => "sports" } } + expect(event.request_params).to eq(expected_params) + end + + it "accepts string for request_params" do + params = '{"search":"test","page":1}' + event = build(:analytics_event, request_params: params) + + expect(event).to be_valid + expect(event.request_params).to eq(params) + end + + it "accepts empty hash for request_params" do + event = build(:analytics_event, request_params: {}) + expect(event).to be_valid + expect(event.request_params).to eq({}) + end + + it "accepts nil for request_params" do + event = build(:analytics_event, request_params: nil) + expect(event).to be_valid + expect(event.request_params).to be_nil + end + + it "accepts array for request_params" do + params = [1, 2, 3] + event = build(:analytics_event, request_params: params) + + expect(event).to be_valid + expect(event.request_params).to eq(params) + end + + it "accepts nested structures in request_params" do + params = { + search: "test", + filters: { + category: "sports", + location: { + lat: 49.2827, + long: -123.1207 + } + }, + sort: [{ field: "name", direction: "asc" }] + } + event = build(:analytics_event, request_params: params) + + expect(event).to be_valid + # Rails converts symbol keys to strings in JSON columns + expected_params = { + "search" => "test", + "filters" => { + "category" => "sports", + "location" => { + "lat" => 49.2827, + "long" => -123.1207 + } + }, + "sort" => [{ "field" => "name", "direction" => "asc" }] + } + expect(event.request_params).to eq(expected_params) + end + end + + describe "URL format handling" do + it "accepts HTTP URLs" do + event = build(:analytics_event, request_url: "http://example.com/facilities") + expect(event).to be_valid + end + + it "accepts HTTPS URLs" do + event = build(:analytics_event, request_url: "https://example.com/facilities") + expect(event).to be_valid + end + + it "accepts URLs with query parameters" do + event = build(:analytics_event, request_url: "https://example.com/facilities?search=test&page=1") + expect(event).to be_valid + end + + it "accepts URLs with fragments" do + event = build(:analytics_event, request_url: "https://example.com/facilities#section") + expect(event).to be_valid + end + + it "accepts localhost URLs" do + event = build(:analytics_event, request_url: "http://localhost:3000/facilities") + expect(event).to be_valid + end + + it "accepts URLs with ports" do + event = build(:analytics_event, request_url: "https://example.com:8080/facilities") + expect(event).to be_valid + end + + it "accepts relative URLs" do + event = build(:analytics_event, request_url: "/facilities") + expect(event).to be_valid + end + + it "accepts root URL" do + event = build(:analytics_event, request_url: "/") + expect(event).to be_valid + end + end + + describe "User agent handling" do + it "accepts typical browser user agents" do + user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + event = build(:analytics_event, request_user_agent: user_agent) + expect(event).to be_valid + end + + it "accepts mobile user agents" do + user_agent = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1" + event = build(:analytics_event, request_user_agent: user_agent) + expect(event).to be_valid + end + + it "accepts API client user agents" do + user_agent = "MyApp/1.0.0 (iOS; iPhone 13)" + event = build(:analytics_event, request_user_agent: user_agent) + expect(event).to be_valid + end + + it "accepts empty string user agent" do + event = build(:analytics_event, request_user_agent: "") + expect(event).to be_valid + end + + it "accepts very long user agents" do + user_agent = "A" * 500 + event = build(:analytics_event, request_user_agent: user_agent) + expect(event).to be_valid + end + + it "accepts user agents with special characters" do + user_agent = "Mozilla/5.0 (compatible; MyBot/1.0; +http://example.com/bot)" + event = build(:analytics_event, request_user_agent: user_agent) + expect(event).to be_valid + end + end + + describe "IP address handling" do + it "accepts IPv4 addresses" do + event = build(:analytics_event, request_ip: "192.168.1.1") + expect(event).to be_valid + end + + it "accepts IPv6 addresses" do + event = build(:analytics_event, request_ip: "2001:0db8:85a3:0000:0000:8a2e:0370:7334") + expect(event).to be_valid + end + + it "accepts localhost IPv4" do + event = build(:analytics_event, request_ip: "127.0.0.1") + expect(event).to be_valid + end + + it "accepts localhost IPv6" do + event = build(:analytics_event, request_ip: "::1") + expect(event).to be_valid + end + + it "accepts private IP ranges" do + event = build(:analytics_event, request_ip: "10.0.0.1") + expect(event).to be_valid + end + + it "accepts empty string IP" do + event = build(:analytics_event, request_ip: "") + expect(event).to be_valid + end + end + + describe "Timestamp behavior" do + it "sets created_at and updated_at on creation" do + event = create(:analytics_event) + + expect(event.created_at).to be_present + expect(event.updated_at).to be_present + expect(event.created_at).to be_within(1.second).of(event.updated_at) + end + + it "updates updated_at on attribute update" do + event = create(:analytics_event) + original_updated_at = event.updated_at + + travel_to(1.minute.from_now) do + event.update!(action_name: "edit") + event.reload + + expect(event.updated_at).to be > original_updated_at + end + end + + it "does not update updated_at when no attributes change" do + event = create(:analytics_event) + original_updated_at = event.updated_at + + travel_to(1.minute.from_now) do + event.reload + expect(event.updated_at).to eq(original_updated_at) + end + end + end + + describe "Edge cases" do + it "handles very long controller names" do + long_name = "a" * 255 + event = build(:analytics_event, controller_name: long_name) + expect(event).to be_valid + end + + it "handles very long action names" do + long_name = "a" * 255 + event = build(:analytics_event, action_name: long_name) + expect(event).to be_valid + end + + it "handles very long URLs" do + long_url = "https://example.com/#{'a' * 1000}" + event = build(:analytics_event, request_url: long_url) + expect(event).to be_valid + end + + it "handles special characters in controller name" do + event = build(:analytics_event, controller_name: "admin/api/v1/facilities") + expect(event).to be_valid + end + + it "handles special characters in action name" do + event = build(:analytics_event, action_name: "bulk_update_status") + expect(event).to be_valid + end + + it "handles numeric strings in request_params" do + params = { page: "1", limit: "10", price: "99.99" } + event = build(:analytics_event, request_params: params) + expect(event).to be_valid + # Rails converts symbol keys to strings in JSON columns + expected_params = { "page" => "1", "limit" => "10", "price" => "99.99" } + expect(event.request_params).to eq(expected_params) + end + + it "handles boolean values in request_params" do + params = { active: true, featured: false } + event = build(:analytics_event, request_params: params) + expect(event).to be_valid + # Rails converts symbol keys to strings in JSON columns + expected_params = { "active" => true, "featured" => false } + expect(event.request_params).to eq(expected_params) + end + + it "handles null values in request_params hash" do + params = { search: "test", category: nil } + event = build(:analytics_event, request_params: params) + expect(event).to be_valid + # Rails converts symbol keys to strings in JSON columns + expected_params = { "search" => "test", "category" => nil } + expect(event.request_params).to eq(expected_params) + end + end + + describe "Database behavior" do + it "persists event with all attributes" do + visit = create(:analytics_visit) + params = { search: "test", page: 1 } + event = create(:analytics_event, + visit: visit, + controller_name: "facilities", + action_name: "show", + request_url: "https://example.com/facilities/1", + lat: 49.2827, + long: -123.1207, + request_ip: "192.168.1.1", + request_user_agent: "Test Browser", + request_params: params) + + persisted = Analytics::Event.find(event.id) + + expect(persisted.visit).to eq(visit) + expect(persisted.controller_name).to eq("facilities") + expect(persisted.action_name).to eq("show") + expect(persisted.request_url).to eq("https://example.com/facilities/1") + expect(persisted.lat).to eq(49.2827) + expect(persisted.long).to eq(-123.1207) + expect(persisted.request_ip).to eq("192.168.1.1") + expect(persisted.request_user_agent).to eq("Test Browser") + # Rails converts symbol keys to strings in JSON columns + expected_params = { "search" => "test", "page" => 1 } + expect(persisted.request_params).to eq(expected_params) + end + + it "handles decimal precision for coordinates" do + event = create(:analytics_event, lat: 49.2827345, long: -123.1207456) + persisted = Analytics::Event.find(event.id) + + expect(persisted.lat).to eq(49.2827345) + expect(persisted.long).to eq(-123.1207456) + end + end + + describe "Querying and scopes" do + let(:visit1) { create(:analytics_visit) } + let(:visit2) { create(:analytics_visit) } + + before do + create(:analytics_event, visit: visit1, controller_name: "facilities", action_name: "index") + create(:analytics_event, visit: visit1, controller_name: "facilities", action_name: "show") + create(:analytics_event, visit: visit2, controller_name: "services", action_name: "index") + end + + it "can find events by controller_name" do + events = Analytics::Event.where(controller_name: "facilities") + expect(events.count).to eq(2) + expect(events.pluck(:action_name)).to contain_exactly("index", "show") + end + + it "can find events by action_name" do + events = Analytics::Event.where(action_name: "index") + expect(events.count).to eq(2) + expect(events.pluck(:controller_name)).to contain_exactly("facilities", "services") + end + + it "can find events by visit" do + events = Analytics::Event.where(visit: visit1) + expect(events.count).to eq(2) + end + + it "can chain queries" do + events = Analytics::Event.where(controller_name: "facilities", action_name: "index") + expect(events.count).to eq(1) + expect(events.first.visit).to eq(visit1) + end + end +end diff --git a/spec/models/analytics/impression_spec.rb b/spec/models/analytics/impression_spec.rb new file mode 100644 index 00000000..79c7037e --- /dev/null +++ b/spec/models/analytics/impression_spec.rb @@ -0,0 +1,558 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Analytics::Impression, type: :model do + # Use the factory for clean test setup + subject(:impression) { build(:analytics_impression) } + + describe "Factory" do + it "creates a valid impression with default factory" do + expect(create(:analytics_impression)).to be_valid + end + + context "with traits" do + it "creates a valid impression with for_service trait" do + impression = create(:analytics_impression, :for_service) + expect(impression).to be_valid + expect(impression.impressionable_type).to eq("Service") + expect(impression.impressionable).to be_a(Service) + end + + it "creates a valid impression with for_zone trait" do + impression = create(:analytics_impression, :for_zone) + expect(impression).to be_valid + expect(impression.impressionable_type).to eq("Zone") + expect(impression.impressionable).to be_a(Zone) + end + end + + context "with association to event" do + it "creates valid impression with associated event" do + event = create(:analytics_event) + impression = create(:analytics_impression, event: event) + + expect(impression).to be_valid + expect(impression.event).to eq(event) + expect(event.impressions).to include(impression) + end + end + + context "with different impressionable types" do + it "creates valid impression with facility" do + facility = create(:facility) + impression = create(:analytics_impression, impressionable: facility) + + expect(impression).to be_valid + expect(impression.impressionable).to eq(facility) + expect(impression.impressionable_type).to eq("Facility") + expect(impression.impressionable_id).to eq(facility.id) + end + + it "creates valid impression with service" do + service = create(:service) + impression = create(:analytics_impression, impressionable: service) + + expect(impression).to be_valid + expect(impression.impressionable).to eq(service) + expect(impression.impressionable_type).to eq("Service") + expect(impression.impressionable_id).to eq(service.id) + end + + it "creates valid impression with zone" do + zone = create(:zone) + impression = create(:analytics_impression, impressionable: zone) + + expect(impression).to be_valid + expect(impression.impressionable).to eq(zone) + expect(impression.impressionable_type).to eq("Zone") + expect(impression.impressionable_id).to eq(zone.id) + end + end + end + + describe "Validations" do + it { is_expected.to validate_uniqueness_of(:impressionable_id).scoped_to(%i[impressionable_type event_id]) } + + context "uniqueness validation" do + let(:event) { create(:analytics_event) } + let(:facility) { create(:facility) } + + it "prevents duplicate impressions for same facility in same event" do + create(:analytics_impression, event: event, impressionable: facility) + + duplicate_impression = build(:analytics_impression, event: event, impressionable: facility) + expect(duplicate_impression).not_to be_valid + expect(duplicate_impression.errors[:impressionable_id]).to include("has already been taken") + end + + it "allows same facility in different events" do + event1 = create(:analytics_event) + event2 = create(:analytics_event) + + create(:analytics_impression, event: event1, impressionable: facility) + second_impression = build(:analytics_impression, event: event2, impressionable: facility) + + expect(second_impression).to be_valid + end + + it "allows different facilities in same event" do + event = create(:analytics_event) + facility1 = create(:facility) + facility2 = create(:facility) + + create(:analytics_impression, event: event, impressionable: facility1) + second_impression = build(:analytics_impression, event: event, impressionable: facility2) + + expect(second_impression).to be_valid + end + + it "allows same service in different events" do + event1 = create(:analytics_event) + event2 = create(:analytics_event) + service = create(:service) + + create(:analytics_impression, event: event1, impressionable: service) + second_impression = build(:analytics_impression, event: event2, impressionable: service) + + expect(second_impression).to be_valid + end + + it "prevents duplicate impressions for same service in same event" do + event = create(:analytics_event) + service = create(:service) + + create(:analytics_impression, event: event, impressionable: service) + duplicate_impression = build(:analytics_impression, event: event, impressionable: service) + + expect(duplicate_impression).not_to be_valid + expect(duplicate_impression.errors[:impressionable_id]).to include("has already been taken") + end + + it "prevents duplicate impressions for same zone in same event" do + event = create(:analytics_event) + zone = create(:zone) + + create(:analytics_impression, event: event, impressionable: zone) + duplicate_impression = build(:analytics_impression, event: event, impressionable: zone) + + expect(duplicate_impression).not_to be_valid + expect(duplicate_impression.errors[:impressionable_id]).to include("has already been taken") + end + + it "allows different impressionable types with same ID in same event" do + # This test demonstrates the scoped nature of the uniqueness validation + # by showing that different impressionable types can coexist in the same event + event = create(:analytics_event) + facility = create(:facility) + service = create(:service) + + # Create impressions for different types in the same event + facility_impression = create(:analytics_impression, event: event, impressionable: facility) + service_impression = create(:analytics_impression, event: event, impressionable: service) + + # Both should be valid since they have different impressionable_type values + expect(facility_impression).to be_valid + expect(service_impression).to be_valid + expect(facility_impression.event).to eq(service_impression.event) + expect(facility_impression.impressionable_type).to eq("Facility") + expect(service_impression.impressionable_type).to eq("Service") + + # The uniqueness constraint is scoped by impressionable_type and event_id + # So these two impressions don't conflict even if they had the same impressionable_id + expect(facility_impression.event_id).to eq(service_impression.event_id) + expect(facility_impression.impressionable_type).not_to eq(service_impression.impressionable_type) + end + end + end + + describe "Associations" do + it { is_expected.to belong_to(:event) } + it { is_expected.to belong_to(:impressionable) } + it { is_expected.to have_one(:visit).through(:event) } + + context "belongs_to event" do + it "can access associated event" do + event = create(:analytics_event) + impression = create(:analytics_impression, event: event) + + expect(impression.event).to eq(event) + expect(event.impressions).to include(impression) + end + + it "is invalid without associated event" do + impression = build(:analytics_impression, event: nil) + expect(impression).not_to be_valid + end + end + + context "belongs_to impressionable (polymorphic)" do + it "can access facility as impressionable" do + facility = create(:facility) + impression = create(:analytics_impression, impressionable: facility) + + expect(impression.impressionable).to eq(facility) + expect(impression.impressionable_type).to eq("Facility") + end + + it "can access service as impressionable" do + service = create(:service) + impression = create(:analytics_impression, impressionable: service) + + expect(impression.impressionable).to eq(service) + expect(impression.impressionable_type).to eq("Service") + end + + it "can access zone as impressionable" do + zone = create(:zone) + impression = create(:analytics_impression, impressionable: zone) + + expect(impression.impressionable).to eq(zone) + expect(impression.impressionable_type).to eq("Zone") + end + + it "is invalid without impressionable" do + impression = build(:analytics_impression, impressionable: nil) + expect(impression).not_to be_valid + end + + it "is invalid without impressionable_type" do + impression = build(:analytics_impression) + impression.impressionable_type = nil + impression.impressionable_id = 1 + + expect(impression).not_to be_valid + end + + it "is invalid without impressionable_id" do + event = create(:analytics_event) + impression = Analytics::Impression.new( + event: event, + impressionable_type: "Facility", + impressionable_id: nil + ) + + # Polymorphic associations don't automatically validate presence of foreign keys + # The record may validate but won't be able to find the associated object + expect(impression.event).to be_present + expect(impression.impressionable_type).to eq("Facility") + expect(impression.impressionable_id).to be_nil + + # The polymorphic association returns nil when ID is nil + expect(impression.impressionable).to be_nil + end + end + + context "has_one visit through event" do + it "can access visit through event" do + visit = create(:analytics_visit) + event = create(:analytics_event, visit: visit) + impression = create(:analytics_impression, event: event) + + expect(impression.visit).to eq(visit) + end + + it "returns nil when event has no visit" do + # This scenario should not happen with proper foreign key constraints, + # but we test the association behavior + event = create(:analytics_event) + impression = create(:analytics_impression, event: event) + + expect(impression.visit).to eq(event.visit) + end + end + end + + describe "Scopes" do + describe ".facilities" do + it "returns only facility impressions" do + event = create(:analytics_event) + facility = create(:facility) + service = create(:service) + zone = create(:zone) + + facility_impression = create(:analytics_impression, event: event, impressionable: facility) + service_impression = create(:analytics_impression, event: event, impressionable: service) + zone_impression = create(:analytics_impression, event: event, impressionable: zone) + + facilities = Analytics::Impression.facilities + + expect(facilities).to contain_exactly(facility_impression) + expect(facilities).not_to include(service_impression, zone_impression) + end + + it "returns empty array when no facility impressions exist" do + event = create(:analytics_event) + service = create(:service) + create(:analytics_impression, event: event, impressionable: service) + + expect(Analytics::Impression.facilities).to be_empty + end + + it "chains with other scopes" do + event1 = create(:analytics_event) + event2 = create(:analytics_event) + facility1 = create(:facility) + facility2 = create(:facility) + + create(:analytics_impression, event: event1, impressionable: facility1) + create(:analytics_impression, event: event2, impressionable: facility2) + + facilities_in_event1 = Analytics::Impression.facilities.where(event: event1) + expect(facilities_in_event1).to contain_exactly(Analytics::Impression.find_by(event: event1, impressionable: facility1)) + end + end + end + + describe "Polymorphic Behavior" do + it "handles polymorphic association correctly for facilities" do + facility = create(:facility) + impression = create(:analytics_impression, impressionable: facility) + + expect(impression.impressionable).to respond_to(:name) + expect(impression.impressionable.class.name).to eq("Facility") + end + + it "handles polymorphic association correctly for services" do + service = create(:service) + impression = create(:analytics_impression, impressionable: service) + + expect(impression.impressionable).to respond_to(:name) + expect(impression.impressionable.class.name).to eq("Service") + end + + it "handles polymorphic association correctly for zones" do + zone = create(:zone) + impression = create(:analytics_impression, impressionable: zone) + + expect(impression.impressionable).to respond_to(:name) + expect(impression.impressionable.class.name).to eq("Zone") + end + + it "allows querying by polymorphic type" do + event = create(:analytics_event) + facility = create(:facility) + service = create(:service) + + create(:analytics_impression, event: event, impressionable: facility) + create(:analytics_impression, event: event, impressionable: service) + + facility_impressions = Analytics::Impression.where(impressionable_type: "Facility") + service_impressions = Analytics::Impression.where(impressionable_type: "Service") + + expect(facility_impressions.count).to eq(1) + expect(service_impressions.count).to eq(1) + expect(facility_impressions.first.impressionable).to eq(facility) + expect(service_impressions.first.impressionable).to eq(service) + end + + it "allows querying by polymorphic ID" do + event = create(:analytics_event) + facility = create(:facility) + + impression = create(:analytics_impression, event: event, impressionable: facility) + + found_impression = Analytics::Impression.where(impressionable_id: facility.id).first + expect(found_impression).to eq(impression) + expect(found_impression.impressionable).to eq(facility) + end + + it "handles type and ID queries together" do + event = create(:analytics_event) + facility1 = create(:facility) + facility2 = create(:facility) + service = create(:service) + + create(:analytics_impression, event: event, impressionable: facility1) + create(:analytics_impression, event: event, impressionable: facility2) + create(:analytics_impression, event: event, impressionable: service) + + specific_facility = Analytics::Impression.where( + impressionable_type: "Facility", + impressionable_id: facility1.id + ).first + + expect(specific_facility.impressionable).to eq(facility1) + end + end + + describe "Database Behavior" do + it "persists polymorphic associations correctly" do + event = create(:analytics_event) + facility = create(:facility) + + impression = create(:analytics_impression, event: event, impressionable: facility) + persisted = Analytics::Impression.find(impression.id) + + expect(persisted.event).to eq(event) + expect(persisted.impressionable).to eq(facility) + expect(persisted.impressionable_type).to eq("Facility") + expect(persisted.impressionable_id).to eq(facility.id) + end + + it "handles composite unique constraint at database level" do + event = create(:analytics_event) + facility = create(:facility) + + create(:analytics_impression, event: event, impressionable: facility) + + expect do + create(:analytics_impression, event: event, impressionable: facility) + end.to raise_error(ActiveRecord::RecordInvalid, /Impressionable has already been taken/) + end + + it "sets created_at and updated_at on creation" do + impression = create(:analytics_impression) + + expect(impression.created_at).to be_present + expect(impression.updated_at).to be_present + expect(impression.created_at).to be_within(1.second).of(impression.updated_at) + end + + it "updates updated_at on attribute update" do + impression = create(:analytics_impression) + original_updated_at = impression.updated_at + facility = create(:facility) + + travel_to(1.minute.from_now) do + impression.update!(impressionable: facility) + impression.reload + + expect(impression.updated_at).to be > original_updated_at + end + end + + it "does not update updated_at when no attributes change" do + impression = create(:analytics_impression) + original_updated_at = impression.updated_at + + travel_to(1.minute.from_now) do + impression.reload + expect(impression.updated_at).to eq(original_updated_at) + end + end + + it "handles deletion of impressionable object" do + event = create(:analytics_event) + facility = create(:facility) + impression = create(:analytics_impression, event: event, impressionable: facility) + + # Delete the facility + facility.destroy + + # The impression should still exist but impressionable should be nil + # depending on dependent options in the actual models + expect(Analytics::Impression.find_by(id: impression.id)).to be_present + end + + it "handles deletion of event with dependent impressions" do + event = create(:analytics_event) + create(:analytics_impression, event: event) + + expect { event.destroy }.to change(Analytics::Impression, :count).by(-1) + end + end + + describe "Querying and Relationships" do + let(:visit) { create(:analytics_visit) } + let(:event1) { create(:analytics_event, visit: visit) } + let(:event2) { create(:analytics_event, visit: visit) } + let(:facility1) { create(:facility) } + let(:facility2) { create(:facility) } + let(:service) { create(:service) } + + before do + create(:analytics_impression, event: event1, impressionable: facility1) + create(:analytics_impression, event: event1, impressionable: service) + create(:analytics_impression, event: event2, impressionable: facility2) + end + + it "can find impressions by event" do + impressions = Analytics::Impression.where(event: event1) + expect(impressions.count).to eq(2) + end + + it "can find impressions by visit through event" do + event_ids = [event1.id, event2.id] + impressions = Analytics::Impression.where(event_id: event_ids) + expect(impressions.count).to eq(3) + end + + it "can count impressions by type" do + facility_count = Analytics::Impression.where(impressionable_type: "Facility").count + service_count = Analytics::Impression.where(impressionable_type: "Service").count + + expect(facility_count).to eq(2) + expect(service_count).to eq(1) + end + + it "can query complex conditions" do + # Find all facility impressions for the first event + impressions = Analytics::Impression.where( + event: event1, + impressionable_type: "Facility" + ) + expect(impressions.count).to eq(1) + expect(impressions.first.impressionable).to eq(facility1) + end + end + + describe "Edge Cases" do + it "handles nil impressionable_id gracefully" do + impression = build(:analytics_impression) + impression.impressionable_id = nil + impression.impressionable_type = "Facility" + + # The model doesn't validate presence of polymorphic foreign keys directly + # but it won't be able to save without proper associations + expect(impression.event).to be_present + expect(impression.impressionable_type).to eq("Facility") + expect(impression.impressionable_id).to be_nil + end + + it "handles nil impressionable_type gracefully" do + impression = build(:analytics_impression) + impression.impressionable_id = 1 + impression.impressionable_type = nil + + expect(impression).not_to be_valid + end + + it "handles empty string impressionable_type" do + impression = build(:analytics_impression) + impression.impressionable_type = "" + + # The model doesn't validate presence of impressionable_type directly + # Empty string is technically valid at the validation level + expect(impression.event).to be_present + expect(impression.impressionable_type).to eq("") + end + + it "handles zero impressionable_id" do + impression = build(:analytics_impression) + impression.impressionable_id = 0 + impression.impressionable_type = "Facility" + + # This might be valid depending on foreign key constraints + # The test shows the behavior, not necessarily the expected outcome + expect(impression.event).to be_present + end + + it "handles very large impressionable_id" do + impression = build(:analytics_impression) + impression.impressionable_id = (2**31) - 1 # Max 32-bit signed int + impression.impressionable_type = "Facility" + + expect(impression.event).to be_present + end + + it "handles invalid impressionable_type values" do + impression = build(:analytics_impression) + impression.impressionable_type = "NonExistentModel" + + # This should be valid at the model level but fail at database level + # when trying to associate with an actual record + expect(impression.event).to be_present + end + end +end diff --git a/spec/models/analytics/visit_spec.rb b/spec/models/analytics/visit_spec.rb new file mode 100644 index 00000000..d32e5e4c --- /dev/null +++ b/spec/models/analytics/visit_spec.rb @@ -0,0 +1,432 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Analytics::Visit, type: :model do + # Use the factory for clean test setup + subject(:visit) { build(:analytics_visit) } + + describe "Factory" do + it "creates a valid visit with default factory" do + expect(create(:analytics_visit)).to be_valid + end + + context "with traits" do + it "creates a valid visit with coordinates" do + visit = create(:analytics_visit, :with_coordinates) + expect(visit).to be_valid + expect(visit.lat).to be_present + expect(visit.long).to be_present + end + + it "creates a valid visit with vancouver_center trait" do + visit = create(:analytics_visit, :vancouver_center) + expect(visit).to be_valid + expect(visit.lat).to eq(49.2827) + expect(visit.long).to eq(-123.1207) + end + + it "creates a valid visit with downtown_vancouver trait" do + visit = create(:analytics_visit, :downtown_vancouver) + expect(visit).to be_valid + expect(visit.lat).to eq(49.2848) + expect(visit.long).to eq(-123.1228) + end + + it "creates a valid visit with outside_vancouver trait" do + visit = create(:analytics_visit, :outside_vancouver) + expect(visit).to be_valid + expect(visit.lat).to be_present + expect(visit.long).to be_present + end + + it "creates a valid visit with invalid_coordinates trait" do + visit = create(:analytics_visit, :invalid_coordinates) + expect(visit).to be_valid + expect(visit.lat).to eq(-33.8688) + expect(visit.long).to eq(151.2093) + end + + it "creates a valid visit with new_session trait" do + visit = create(:analytics_visit, :new_session) + expect(visit).to be_valid + expect(visit.created_at).to be_within(1.minute).of(1.hour.ago) + end + + it "creates a valid visit with returning_session trait" do + visit = create(:analytics_visit, :returning_session) + expect(visit).to be_valid + expect(visit.created_at).to be_within(1.minute).of(1.day.ago) + expect(visit.updated_at).to be_within(1.minute).of(10.minutes.ago) + end + + it "creates a valid visit with mobile_session trait" do + visit = create(:analytics_visit, :mobile_session) + expect(visit).to be_valid + expect(visit.lat).to be_present + expect(visit.long).to be_present + end + + it "creates a valid visit with desktop_session trait" do + visit = create(:analytics_visit, :desktop_session) + expect(visit).to be_valid + expect(visit.lat).to be_nil + expect(visit.long).to be_nil + end + end + end + + describe "Validations" do + it { is_expected.to validate_presence_of(:uuid) } + it { is_expected.to validate_presence_of(:session_id) } + + it "validates uniqueness of session_id scoped to uuid" do + create(:analytics_visit, uuid: "test-uuid-123", session_id: "test-session-123") + new_visit = build(:analytics_visit, uuid: "test-uuid-123", session_id: "test-session-123") + + expect(new_visit).not_to be_valid + expect(new_visit.errors[:session_id]).to include("has already been taken") + end + + it "allows same session_id with different uuid" do + create(:analytics_visit, uuid: "uuid-1", session_id: "same-session") + new_visit = build(:analytics_visit, uuid: "uuid-2", session_id: "same-session") + + expect(new_visit).to be_valid + end + + it "allows same uuid with different session_id" do + create(:analytics_visit, uuid: "same-uuid", session_id: "session-1") + new_visit = build(:analytics_visit, uuid: "same-uuid", session_id: "session-2") + + expect(new_visit).to be_valid + end + + context "with coordinates" do + it "allows nil coordinates" do + visit = build(:analytics_visit, lat: nil, long: nil) + expect(visit).to be_valid + end + + it "allows valid latitude" do + visit = build(:analytics_visit, lat: 49.2827, long: -123.1207) + expect(visit).to be_valid + end + + it "allows negative latitude" do + visit = build(:analytics_visit, lat: -33.8688, long: 151.2093) + expect(visit).to be_valid + end + + it "allows positive longitude" do + visit = build(:analytics_visit, lat: 49.2827, long: 151.2093) + expect(visit).to be_valid + end + end + end + + describe "Associations" do + it { is_expected.to have_many(:events).dependent(:destroy) } + it { is_expected.to have_many(:impressions).through(:events) } + + context "with dependent destroy" do + it "destroys associated events when visit is destroyed" do + visit = create(:analytics_visit) + event1 = create(:analytics_event, visit: visit) + event2 = create(:analytics_event, visit: visit) + + expect { visit.destroy }.to change(Analytics::Event, :count).by(-2) + expect(Analytics::Event.find_by(id: event1.id)).to be_nil + expect(Analytics::Event.find_by(id: event2.id)).to be_nil + end + end + + context "through associations" do + let(:visit) { create(:analytics_visit) } + let(:event) { create(:analytics_event, visit: visit) } + let!(:impression1) { create(:analytics_impression, event: event) } + let!(:impression2) { create(:analytics_impression, event: event) } + + it "can access impressions through events" do + expect(visit.impressions).to contain_exactly(impression1, impression2) + end + end + end + + describe "#attempt_update_coordinates" do + context "when coordinates are already set" do + let(:visit) { create(:analytics_visit, :vancouver_center) } + + it "does not update coordinates" do + original_lat = visit.lat + original_long = visit.long + params = { lat: 50.0, long: -124.0 } + + result = visit.attempt_update_coordinates(params) + + expect(result).to eq(visit) + expect(visit.lat).to eq(original_lat) + expect(visit.long).to eq(original_long) + end + + it "updates when one coordinate is blank" do + visit.update!(lat: 49.2827, long: nil) + params = { lat: 50.0, long: -124.0 } + + result = visit.attempt_update_coordinates(params) + + expect(result).to eq(visit) + # Updates both because the condition checks if ANY coordinate is blank + expect(visit.lat).to eq(50.0) + expect(visit.long).to eq(-124.0) + end + end + + context "when coordinates are not set" do + let(:visit) { create(:analytics_visit, lat: nil, long: nil) } + + it "updates coordinates with valid params" do + params = { lat: 49.2827, long: -123.1207 } + + result = visit.attempt_update_coordinates(params) + + expect(result).to eq(visit) + expect(visit.lat).to eq(49.2827) + expect(visit.long).to eq(-123.1207) + end + + it "updates coordinates with only lat provided" do + params = { lat: 49.2827 } + + result = visit.attempt_update_coordinates(params) + + expect(result).to eq(visit) + expect(visit.lat).to eq(49.2827) + expect(visit.long).to be_nil + end + + it "updates coordinates with only long provided" do + params = { long: -123.1207 } + + result = visit.attempt_update_coordinates(params) + + expect(result).to eq(visit) + expect(visit.lat).to be_nil + expect(visit.long).to eq(-123.1207) + end + + it "handles string keys in params" do + params = { "lat" => "49.2827", "long" => "-123.1207" } + + result = visit.attempt_update_coordinates(params) + + expect(result).to eq(visit) + # Rails converts strings to BigDecimal for numeric columns + expect(visit.lat).to be_a(BigDecimal) + expect(visit.long).to be_a(BigDecimal) + expect(visit.lat.to_f).to eq(49.2827) + expect(visit.long.to_f).to eq(-123.1207) + end + + it "handles nil params" do + result = visit.attempt_update_coordinates(nil) + + expect(result).to eq(visit) + expect(visit.lat).to be_nil + expect(visit.long).to be_nil + end + + it "handles empty hash params" do + result = visit.attempt_update_coordinates({}) + + expect(result).to eq(visit) + expect(visit.lat).to be_nil + expect(visit.long).to be_nil + end + + it "ignores non-coordinate params" do + params = { lat: 49.2827, long: -123.1207, other_param: "value" } + + result = visit.attempt_update_coordinates(params) + + expect(result).to eq(visit) + expect(visit.lat).to eq(49.2827) + expect(visit.long).to eq(-123.1207) + end + end + + context "when only one coordinate is blank" do + let(:visit) { create(:analytics_visit, lat: 49.2827, long: nil) } + + it "updates both coordinates when one is blank" do + params = { lat: 50.0, long: -124.0 } + + result = visit.attempt_update_coordinates(params) + + expect(result).to eq(visit) + # Updates both because the condition checks if ANY coordinate is blank + expect(visit.lat).to eq(50.0) + expect(visit.long).to eq(-124.0) + end + end + + context "edge cases" do + let(:visit) { create(:analytics_visit, lat: nil, long: nil) } + + it "handles negative coordinates" do + params = { lat: -33.8688, long: 151.2093 } + + result = visit.attempt_update_coordinates(params) + + expect(result).to eq(visit) + expect(visit.lat).to eq(-33.8688) + expect(visit.long).to eq(151.2093) + end + + it "handles zero coordinates" do + params = { lat: 0, long: 0 } + + result = visit.attempt_update_coordinates(params) + + expect(result).to eq(visit) + expect(visit.lat).to eq(0) + expect(visit.long).to eq(0) + end + + it "handles very small coordinates" do + params = { lat: 0.000001, long: -0.000001 } + + result = visit.attempt_update_coordinates(params) + + expect(result).to eq(visit) + expect(visit.lat).to eq(0.000001) + expect(visit.long).to eq(-0.000001) + end + + it "handles very large coordinates" do + params = { lat: 90, long: 180 } + + result = visit.attempt_update_coordinates(params) + + expect(result).to eq(visit) + expect(visit.lat).to eq(90) + expect(visit.long).to eq(180) + end + + it "handles symbol keys" do + params = { lat: 49.2827, long: -123.1207 } + + result = visit.attempt_update_coordinates(params) + + expect(result).to eq(visit) + expect(visit.lat).to eq(49.2827) + expect(visit.long).to eq(-123.1207) + end + end + end + + describe "private methods" do + describe "#extract_coordinates_from" do + let(:visit) { create(:analytics_visit) } + + it "extracts lat and long from params" do + params = { lat: 49.2827, long: -123.1207, other: "value" } + + # Use send to call private method for testing + result = visit.send(:extract_coordinates_from, params) + + expect(result).to eq({ "lat" => 49.2827, "long" => -123.1207 }) + end + + it "handles nil params" do + result = visit.send(:extract_coordinates_from, nil) + + expect(result).to eq({}) + end + + it "handles params with only lat" do + params = { lat: 49.2827, other: "value" } + + result = visit.send(:extract_coordinates_from, params) + + expect(result).to eq({ "lat" => 49.2827 }) + end + + it "handles params with only long" do + params = { long: -123.1207, other: "value" } + + result = visit.send(:extract_coordinates_from, params) + + expect(result).to eq({ "long" => -123.1207 }) + end + + it "handles string keys" do + params = { "lat" => 49.2827, "long" => -123.1207 } + + result = visit.send(:extract_coordinates_from, params) + + expect(result).to eq({ "lat" => 49.2827, "long" => -123.1207 }) + end + + it "handles empty params" do + result = visit.send(:extract_coordinates_from, {}) + + expect(result).to eq({}) + end + end + end + + describe "scopes and class methods" do + context "when searching by uuid" do + let!(:visit1) { create(:analytics_visit, uuid: "test-uuid-1") } + let!(:visit2) { create(:analytics_visit, uuid: "test-uuid-2") } + + it "can find visits by uuid" do + expect(Analytics::Visit.find_by(uuid: "test-uuid-1")).to eq(visit1) + end + end + + context "when searching by session_id" do + let!(:visit1) { create(:analytics_visit, session_id: "session-1") } + let!(:visit2) { create(:analytics_visit, session_id: "session-2") } + + it "can find visits by session_id" do + expect(Analytics::Visit.find_by(session_id: "session-1")).to eq(visit1) + end + end + end + + describe "timestamp behavior" do + it "sets created_at and updated_at on creation" do + visit = create(:analytics_visit) + + expect(visit.created_at).to be_present + expect(visit.updated_at).to be_present + expect(visit.created_at).to be_within(1.second).of(visit.updated_at) + end + + it "updates updated_at on coordinate update" do + visit = create(:analytics_visit, lat: nil, long: nil) + original_updated_at = visit.updated_at + + travel_to(1.minute.from_now) do + visit.attempt_update_coordinates({ lat: 49.2827, long: -123.1207 }) + visit.reload + + expect(visit.updated_at).to be > original_updated_at + end + end + + it "does not update updated_at when coordinates are not updated" do + visit = create(:analytics_visit, :vancouver_center) + original_updated_at = visit.updated_at + + travel_to(1.minute.from_now) do + visit.attempt_update_coordinates({ lat: 50.0, long: -124.0 }) + visit.reload + + expect(visit.updated_at).to eq(original_updated_at) + end + end + end +end diff --git a/spec/services/external/vancouver_city/syncer_spec.rb b/spec/services/external/vancouver_city/syncer_spec.rb new file mode 100644 index 00000000..8dbda4e1 --- /dev/null +++ b/spec/services/external/vancouver_city/syncer_spec.rb @@ -0,0 +1,514 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe External::VancouverCity::Syncer, type: :service do + subject(:syncer) { described_class.new(api_key: api_key, api_client: api_client) } + + let(:api_key) { "drinking-fountains" } + let(:api_client) do + client = double("VancouverApiClient") + allow(client).to receive(:is_a?).with(External::VancouverCity::VancouverApiClient).and_return(true) + client + end + let(:page_size) { described_class::PAGE_SIZE } + + # Mock Rails.logger + before do + allow(Rails).to receive(:logger).and_return(logger) + end + + let(:logger) { instance_double(ActiveSupport::Logger) } + + describe "#initialize" do + it "sets api_key and api_client attributes" do + expect(syncer.api_key).to eq(api_key) + expect(syncer.api_client).to eq(api_client) + end + + it "inherits from ApplicationService" do + expect(syncer).to be_a(ApplicationService) + end + + it "responds to call method" do + expect(syncer).to respond_to(:call) + end + end + + describe "#validate" do + context "with valid parameters" do + it "returns no errors" do + expect(External::ApiHelper).to receive(:supported_api?).with(api_key).and_return(true) + + errors = syncer.validate + expect(errors).to be_empty + end + end + + context "with unsupported API key" do + let(:api_key) { "unsupported-api" } + + before do + allow(External::ApiHelper).to receive(:supported_api?).with(api_key).and_return(false) + end + + it "adds API validation error" do + errors = syncer.validate + expect(errors).to include("Unsupported API: unsupported-api") + end + end + + context "with nil API client" do + let(:api_client) { nil } + + before do + allow(External::ApiHelper).to receive(:supported_api?).with(api_key).and_return(true) + end + + it "adds API client validation error" do + errors = syncer.validate + expect(errors).to include("API client is required") + end + end + + context "with wrong API client type" do + let(:api_client) { "wrong_type" } + + before do + allow(External::ApiHelper).to receive(:supported_api?).with(api_key).and_return(true) + end + + it "adds API client type validation error" do + errors = syncer.validate + expect(errors).to include("API client must be an instance of VancouverApiClient") + end + end + + context "with multiple validation errors" do + let(:api_key) { "unsupported-api" } + let(:api_client) { nil } + + before do + allow(External::ApiHelper).to receive(:supported_api?).with(api_key).and_return(false) + end + + it "adds all validation errors" do + errors = syncer.validate + expect(errors).to include( + "Unsupported API: unsupported-api", + "API client is required" + ) + end + end + end + + describe "#call" do + context "when validation fails" do + before do + allow(syncer).to receive(:invalid?).and_return(true) + allow(syncer).to receive(:errors).and_return(["Validation error"]) + end + + it "returns failure result with validation errors" do + result = syncer.call + expect(result.success?).to be false + expect(result.errors).to include("Validation error") + expect(result.data).to be_nil + end + end + + context "when validation succeeds" do + let(:sample_records) do + [ + { "name" => "Fountain 1", "lat" => 49.2827, "long" => -123.1207 }, + { "name" => "Fountain 2", "lat" => 49.2828, "long" => -123.1208 } + ] + end + + let(:sample_facility) { instance_double(Facility) } + let(:syncer_result) do + ApplicationService::Result.new( + data: { facility: sample_facility }, + errors: [] + ) + end + + let(:api_client) do + client = double("VancouverApiClient") + allow(client).to receive(:is_a?).with(External::VancouverCity::VancouverApiClient).and_return(true) + client + end + + before do + allow(External::ApiHelper).to receive(:supported_api?).with(api_key).and_return(true) + allow(External::VancouverCity::FacilitySyncer).to receive(:call).and_return(syncer_result) + allow(logger).to receive(:info) + allow(logger).to receive(:warn) + end + + context "with empty API response" do + before do + empty_response = instance_double(Faraday::Response, body: { "results" => [] }) + allow(api_client).to receive(:get_dataset_records) + .with(api_key, limit: page_size, offset: 0) + .and_return(empty_response) + end + + it "logs fetch request and processes no facilities" do + expect(logger).to receive(:info).with("Fetching facilities from #{api_key} API (offset: 0, limit: #{page_size})") + expect(logger).to receive(:info).with("Successfully processed 0 facilities from #{api_key} API") + + result = syncer.call + + expect(result.success?).to be true + expect(result.data[:facilities]).to be_empty + expect(result.data[:total_count]).to eq(0) + expect(result.data[:api_key]).to eq(api_key) + end + end + + context "with single page of results" do + let(:response) do + instance_double(Faraday::Response, body: { "results" => sample_records }) + end + + before do + allow(api_client).to receive(:get_dataset_records) + .with(api_key, limit: page_size, offset: 0) + .and_return(response) + end + + it "processes records and returns success result" do + expect(logger).to receive(:info).with("Fetching facilities from #{api_key} API (offset: 0, limit: #{page_size})") + expect(External::VancouverCity::FacilitySyncer).to receive(:call).twice + expect(logger).to receive(:info).with("Successfully processed 2 facilities from #{api_key} API") + + result = syncer.call + + expect(result.success?).to be true + expect(result.data[:facilities]).to contain_exactly(sample_facility, sample_facility) + expect(result.data[:total_count]).to eq(2) + expect(result.data[:api_key]).to eq(api_key) + end + end + + context "with multiple pages of results" do + let(:first_response) do + instance_double(Faraday::Response, body: { "results" => full_page_records }) + end + + let(:full_page_records) { Array.new(page_size) { |i| { "name" => "Fountain #{i}" } } } + + let(:second_response) do + instance_double(Faraday::Response, body: { "results" => [] }) + end + + before do + allow(api_client).to receive(:get_dataset_records) + .with(api_key, limit: page_size, offset: 0) + .and_return(first_response) + + allow(api_client).to receive(:get_dataset_records) + .with(api_key, limit: page_size, offset: page_size) + .and_return(second_response) + end + + it "fetches all pages and processes all records" do + expect(logger).to receive(:info).with("Fetching facilities from #{api_key} API (offset: 0, limit: #{page_size})") + expect(logger).to receive(:info).with("Fetching facilities from #{api_key} API (offset: #{page_size}, limit: #{page_size})") + expect(External::VancouverCity::FacilitySyncer).to receive(:call).exactly(page_size).times + expect(logger).to receive(:info).with("Successfully processed #{page_size} facilities from #{api_key} API") + + result = syncer.call + + expect(result.success?).to be true + expect(result.data[:total_count]).to eq(page_size) + end + end + + context "when exactly PAGE_SIZE records are returned" do + let(:full_page_records) { Array.new(page_size) { |i| { "name" => "Fountain #{i}" } } } + let(:full_page_response) do + instance_double(Faraday::Response, body: { "results" => full_page_records }) + end + + let(:empty_response) do + instance_double(Faraday::Response, body: { "results" => [] }) + end + + before do + allow(api_client).to receive(:get_dataset_records) + .with(api_key, limit: page_size, offset: 0) + .and_return(full_page_response) + + allow(api_client).to receive(:get_dataset_records) + .with(api_key, limit: page_size, offset: page_size) + .and_return(empty_response) + + # Mock FacilitySyncer for all records + allow(External::VancouverCity::FacilitySyncer).to receive(:call).and_return(syncer_result) + end + + it "continues pagination when full page is received" do + expect(api_client).to receive(:get_dataset_records) + .with(api_key, limit: page_size, offset: page_size) + + syncer.call + end + end + + context "when fewer than PAGE_SIZE records are returned" do + let(:partial_page_records) { sample_records } + let(:partial_page_response) do + instance_double(Faraday::Response, body: { "results" => partial_page_records }) + end + + before do + allow(api_client).to receive(:get_dataset_records) + .with(api_key, limit: page_size, offset: 0) + .and_return(partial_page_response) + end + + it "stops pagination when partial page is received" do + expect(api_client).not_to receive(:get_dataset_records) + .with(api_key, limit: page_size, offset: page_size) + + syncer.call + end + end + end + + context "error handling" do + let(:api_client) do + client = double("VancouverApiClient") + allow(client).to receive(:is_a?).with(External::VancouverCity::VancouverApiClient).and_return(true) + client + end + + before do + allow(External::ApiHelper).to receive(:supported_api?).with(api_key).and_return(true) + allow(logger).to receive(:info) + end + + context "when VancouverApiError is raised" do + let(:api_error) do + External::VancouverCity::VancouverApiError.new("API rate limit exceeded", 429, "Rate limit") + end + + before do + allow(api_client).to receive(:get_dataset_records) + .with(api_key, limit: page_size, offset: 0) + .and_raise(api_error) + end + + it "handles API error and returns failure result" do + result = syncer.call + + expect(result.success?).to be false + expect(result.errors).to include("API request failed: API rate limit exceeded") + expect(result.data[:facilities]).to be_empty + expect(result.data[:total_count]).to eq(0) + end + end + + context "when StandardError is raised" do + before do + allow(api_client).to receive(:get_dataset_records) + .with(api_key, limit: page_size, offset: 0) + .and_raise(StandardError.new("Unexpected network error")) + end + + it "handles unexpected error and returns failure result" do + result = syncer.call + + expect(result.success?).to be false + expect(result.errors).to include("Unexpected error during sync: Unexpected network error") + expect(result.data[:facilities]).to be_empty + expect(result.data[:total_count]).to eq(0) + end + end + + context "when FacilitySyncer fails for some records" do + let(:sample_facility) { instance_double(Facility) } + let(:syncer_result) do + ApplicationService::Result.new( + data: { facility: sample_facility }, + errors: [] + ) + end + let(:failed_syncer_result) do + ApplicationService::Result.new( + data: nil, + errors: ["Invalid facility data"] + ) + end + + let(:mixed_records) do + [ + { "name" => "Valid Facility", "lat" => 49.2827, "long" => -123.1207 }, + { "name" => "Invalid Facility" } + ] + end + + let(:response) do + instance_double(Faraday::Response, body: { "results" => mixed_records }) + end + + before do + allow(api_client).to receive(:get_dataset_records) + .with(api_key, limit: page_size, offset: 0) + .and_return(response) + + allow(External::VancouverCity::FacilitySyncer).to receive(:call) + .with(record: mixed_records[0], api_key: api_key) + .and_return(syncer_result) + + allow(External::VancouverCity::FacilitySyncer).to receive(:call) + .with(record: mixed_records[1], api_key: api_key) + .and_return(failed_syncer_result) + end + + it "processes successful records and includes errors for failed ones" do + result = syncer.call + + expect(result.success?).to be false # Failure because some records failed + expect(result.data[:facilities]).to contain_exactly(sample_facility) + expect(result.data[:total_count]).to eq(1) + expect(result.errors).to include("Invalid facility data") + end + end + end + + context "logging behavior" do + let(:sample_records) { [{ "name" => "Test Fountain" }] } + let(:response) do + instance_double(Faraday::Response, body: { "results" => sample_records }) + end + let(:sample_facility) { instance_double(Facility) } + let(:syncer_result) do + ApplicationService::Result.new( + data: { facility: sample_facility }, + errors: [] + ) + end + + before do + allow(External::ApiHelper).to receive(:supported_api?).with(api_key).and_return(true) + allow(api_client).to receive(:get_dataset_records) + .with(api_key, limit: page_size, offset: 0) + .and_return(response) + allow(External::VancouverCity::FacilitySyncer).to receive(:call).and_return(syncer_result) + end + + it "logs fetch progress with correct offset and limit" do + expect(logger).to receive(:info).with("Fetching facilities from #{api_key} API (offset: 0, limit: #{page_size})") + expect(logger).to receive(:info).with("Successfully processed 1 facilities from #{api_key} API") + + syncer.call + end + + it "logs final processing summary" do + expect(logger).to receive(:info).with("Fetching facilities from #{api_key} API (offset: 0, limit: #{page_size})") + expect(logger).to receive(:info).with(/Successfully processed \d+ facilities from #{api_key} API/) + + syncer.call + end + end + + context "result structure" do + let(:sample_records) { [{ "name" => "Test Fountain" }] } + let(:response) do + instance_double(Faraday::Response, body: { "results" => sample_records }) + end + let(:sample_facility) { instance_double(Facility) } + let(:syncer_result) do + ApplicationService::Result.new( + data: { facility: sample_facility }, + errors: [] + ) + end + + before do + allow(External::ApiHelper).to receive(:supported_api?).with(api_key).and_return(true) + allow(api_client).to receive(:get_dataset_records) + .with(api_key, limit: page_size, offset: 0) + .and_return(response) + allow(External::VancouverCity::FacilitySyncer).to receive(:call).and_return(syncer_result) + allow(logger).to receive(:info) + end + + it "returns properly structured result data" do + result = syncer.call + + expect(result.data).to be_a(Hash) + expect(result.data).to have_key(:facilities) + expect(result.data).to have_key(:total_count) + expect(result.data).to have_key(:api_key) + expect(result.data[:facilities]).to be_an(Array) + expect(result.data[:total_count]).to be_an(Integer) + expect(result.data[:api_key]).to eq(api_key) + end + end + end + + describe "private methods" do + describe "#process_records" do + let(:sample_records) { [{ "name" => "Test Fountain" }] } + let(:syncer) { described_class.new(api_key: api_key, api_client: api_client) } + let(:sample_facility) { instance_double(Facility) } + let(:syncer_result) do + ApplicationService::Result.new( + data: { facility: sample_facility }, + errors: [] + ) + end + + before do + allow(External::VancouverCity::FacilitySyncer).to receive(:call).and_return(syncer_result) + end + + it "processes records and returns array of facilities" do + # Use send to access private method + facilities = syncer.send(:process_records, sample_records) + + expect(facilities).to be_an(Array) + expect(facilities).to contain_exactly(sample_facility) + expect(External::VancouverCity::FacilitySyncer).to have_received(:call) + .with(record: sample_records[0], api_key: api_key) + end + + it "handles multiple records" do + multiple_records = sample_records * 3 + + facilities = syncer.send(:process_records, multiple_records) + + expect(facilities.size).to eq(3) + expect(facilities).to all(eq(sample_facility)) + expect(External::VancouverCity::FacilitySyncer).to have_received(:call).exactly(3).times + end + + context "when some record processing fails" do + let(:failed_result) do + ApplicationService::Result.new( + data: nil, + errors: ["Processing failed"] + ) + end + + before do + allow(External::VancouverCity::FacilitySyncer).to receive(:call) + .and_return(syncer_result, failed_result, syncer_result) + end + + it "processes successful records and collects errors" do + mixed_records = sample_records * 3 + + facilities = syncer.send(:process_records, mixed_records) + + expect(facilities.size).to eq(2) # Only successful ones + expect(syncer.send(:errors)).to include("Processing failed") + end + end + end + end +end diff --git a/spec/services/locations/google_maps/embed_map_service_spec.rb b/spec/services/locations/google_maps/embed_map_service_spec.rb new file mode 100644 index 00000000..48ae4e4b --- /dev/null +++ b/spec/services/locations/google_maps/embed_map_service_spec.rb @@ -0,0 +1,494 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Locations::GoogleMaps::EmbedMapService, type: :service do + before(:each) do + stub_const("Locations::GoogleMaps::EmbedMapService::GOOGLE_KEY", "test_google_key") + stub_const("Locations::GoogleMaps::EmbedMapService::GOOGLE_SIGNATURE", nil) + end + + let(:latitude) { 49.243463359535 } + let(:longitude) { -123.106431021296 } + let(:service) { described_class.new(latitude, longitude) } + + describe "initialization" do + it "initializes with latitude and longitude" do + expect(service.latitude).to eq(latitude) + expect(service.longitude).to eq(longitude) + end + + it "creates a URI object with the correct base URL" do + expect(service.uri).to be_a(URI::HTTPS) + expect(service.uri.to_s).to start_with("https://maps.googleapis.com/maps/embed/v1/place") + end + + it "handles integer coordinates" do + int_service = described_class.new(49, -123) + expect(int_service.latitude).to eq(49) + expect(int_service.longitude).to eq(-123) + end + + it "handles float coordinates" do + float_service = described_class.new(49.5, -123.5) + expect(float_service.latitude).to eq(49.5) + expect(float_service.longitude).to eq(-123.5) + end + + it "handles string coordinates that can be converted to numbers" do + string_service = described_class.new("49.243463", "-123.106431") + expect(string_service.latitude).to eq("49.243463") + expect(string_service.longitude).to eq("-123.106431") + end + + it "handles nil coordinates" do + nil_service = described_class.new(nil, nil) + expect(nil_service.latitude).to be_nil + expect(nil_service.longitude).to be_nil + end + end + + describe "#call" do + let(:result) { service.call } + + it "returns a URI object" do + expect(result).to be_a(URI::HTTPS) + end + + it "has the correct hostname" do + expect(result.hostname).to eq("maps.googleapis.com") + end + + it "has the correct path" do + expect(result.path).to eq("/maps/embed/v1/place") + end + + it "has the correct scheme" do + expect(result.scheme).to eq("https") + end + + it "sets query parameters" do + expect(result.query).not_to be_nil + expect(result.query).not_to be_empty + end + + describe "query parameters" do + let(:query_params) do + URI.decode_www_form(result.query).to_h + end + + it "includes center parameter with rounded coordinates" do + expect(query_params["center"]).to eq("49.243463,-123.106431") + end + + it "includes zoom parameter from MAP_CONFIG" do + expect(query_params["zoom"]).to eq("14") + end + + it "includes maptype parameter from MAP_CONFIG" do + expect(query_params["maptype"]).to eq("roadmap") + end + + it "includes q parameter with coordinates (instead of markers)" do + expect(query_params["q"]).to eq("49.243463,-123.106431") + end + + it "includes key parameter with GOOGLE_KEY from environment" do + expect(query_params["key"]).to eq(described_class::GOOGLE_KEY) + end + + it "does not include size parameter (commented out in service)" do + expect(query_params).not_to have_key("size") + end + + it "does not include markers parameter (commented out in service)" do + expect(query_params).not_to have_key("markers") + end + + it "does not include signature parameter when GOOGLE_SIGNATURE is nil" do + expect(query_params).not_to have_key("signature") + end + + context "when GOOGLE_SIGNATURE is present" do + before do + stub_const("Locations::GoogleMaps::EmbedMapService::GOOGLE_SIGNATURE", "test_signature") + end + + it "includes signature parameter" do + result = service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["signature"]).to eq("test_signature") + end + end + end + + describe "coordinate rounding behavior" do + context "with many decimal places" do + let(:high_precision_lat) { 49.243463359535123456789 } + let(:high_precision_long) { -123.106431021296123456789 } + let(:high_precision_service) { described_class.new(high_precision_lat, high_precision_long) } + + it "rounds to 6 decimal places in center parameter" do + result = high_precision_service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq("49.243463,-123.106431") + end + + it "rounds to 6 decimal places in q parameter" do + result = high_precision_service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["q"]).to eq("49.243463,-123.106431") + end + end + + context "with coordinates that need rounding up" do + let(:round_up_service) { described_class.new(49.2434635, -123.1064315) } + + it "rounds correctly" do + result = round_up_service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq("49.243464,-123.106432") + expect(query_params["q"]).to eq("49.243464,-123.106432") + end + end + + context "with coordinates that need rounding down" do + let(:round_down_service) { described_class.new(49.2434634, -123.1064314) } + + it "rounds correctly" do + result = round_down_service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq("49.243463,-123.106431") + expect(query_params["q"]).to eq("49.243463,-123.106431") + end + end + + context "with negative coordinates" do + let(:negative_service) { described_class.new(-49.243463359535, 123.106431021296) } + + it "handles negative coordinates correctly" do + result = negative_service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq("-49.243463,123.106431") + expect(query_params["q"]).to eq("-49.243463,123.106431") + end + end + + context "with zero coordinates" do + let(:zero_service) { described_class.new(0, 0) } + + it "handles zero coordinates correctly" do + result = zero_service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq("0,0") + expect(query_params["q"]).to eq("0,0") + end + end + end + + describe "query parameter behavior" do + it "uses q parameter instead of markers" do + result = service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params).to have_key("q") + expect(query_params).not_to have_key("markers") + end + + it "q parameter contains coordinates" do + result = service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["q"]).to eq("49.243463,-123.106431") + end + + it "center and q parameters use the same coordinates" do + result = service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq(query_params["q"]) + end + end + + describe "edge cases" do + context "with nil coordinates" do + let(:nil_service) { described_class.new(nil, nil) } + + it "raises error for nil coordinates" do + expect { nil_service.call }.to raise_error(NoMethodError, /undefined method 'round' for nil/) + end + end + + context "with empty coordinates" do + let(:empty_service) { described_class.new("", "") } + + it "raises error for empty coordinates" do + expect do + empty_service.call + end.to raise_error(NoMethodError, /undefined method 'round' for an instance of String/) + end + end + + context "with very large coordinates" do + let(:large_service) { described_class.new(999.999999, -999.999999) } + + it "handles large coordinates" do + result = large_service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq("999.999999,-999.999999") + expect(query_params["q"]).to eq("999.999999,-999.999999") + end + end + + context "with very small coordinates" do + let(:small_service) { described_class.new(0.0000001, -0.0000001) } + + it "handles very small coordinates" do + result = small_service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq("0.0,-0.0") + expect(query_params["q"]).to eq("0.0,-0.0") + end + end + end + + describe "URL encoding" do + it "properly encodes query parameters" do + result = service.call + expect(result.query).to include("center=49.243463%2C-123.106431") + expect(result.query).to include("q=49.243463%2C-123.106431") + end + + it "creates a valid URI that can be parsed" do + result = service.call + parsed_uri = URI.parse(result.to_s) + expect(parsed_uri).to eq(result) + end + + it "creates a URI that can be accessed via HTTP" do + result = service.call + expect(result.to_s).to start_with("https://maps.googleapis.com") + expect(result.to_s).to include("?") + end + end + end + + describe "environment variable handling" do + it "has the stubbed GOOGLE_KEY" do + expect(described_class::GOOGLE_KEY).to eq("test_google_key") + end + + it "includes the stubbed token in query parameters" do + result = service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["key"]).to eq("test_google_key") + end + end + + describe "private methods" do + describe "#coordinates" do + it "returns an array with rounded latitude and longitude" do + coordinates = service.send(:coordinates) + expect(coordinates).to eq([49.243463, -123.106431]) + end + + it "rounds to 6 decimal places" do + high_precision_service = described_class.new(49.243463359535, -123.106431021296) + coordinates = high_precision_service.send(:coordinates) + expect(coordinates).to eq([49.243463, -123.106431]) + end + end + + describe "#markers" do + it "returns an array with marker components (though not used in embed API)" do + markers = service.send(:markers) + expect(markers).to eq(["color:red", "label:F", "49.243463,-123.106431"]) + end + + it "uses rounded coordinates" do + high_precision_service = described_class.new(49.243463359535, -123.106431021296) + markers = high_precision_service.send(:markers) + expect(markers[2]).to eq("49.243463,-123.106431") + end + end + + describe "#query_params" do + let(:query_params) { service.send(:query_params) } + + it "returns a hash with symbolized keys" do + expect(query_params).to be_a(Hash) + expect(query_params.keys).to all(be_a(Symbol)) + end + + it "includes all required parameters" do + expect(query_params).to have_key(:center) + expect(query_params).to have_key(:zoom) + expect(query_params).to have_key(:maptype) + expect(query_params).to have_key(:q) + expect(query_params).to have_key(:key) + end + + it "does not include commented out parameters" do + expect(query_params).not_to have_key(:size) + expect(query_params).not_to have_key(:markers) + end + + it "uses correct values from MAP_CONFIG" do + expect(query_params[:zoom]).to eq(14) + expect(query_params[:maptype]).to eq("roadmap") + end + + it "uses coordinates for center parameter" do + expect(query_params[:center]).to eq("49.243463,-123.106431") + end + + it "uses coordinates for q parameter" do + expect(query_params[:q]).to eq("49.243463,-123.106431") + end + + it "center and q parameters are identical" do + expect(query_params[:center]).to eq(query_params[:q]) + end + end + end + + describe "class method interface" do + it "can be called using .call class method" do + result = described_class.call(latitude, longitude) + expect(result).to be_a(URI::HTTPS) + end + + it "class method returns same result as instance method" do + instance_result = service.call + class_result = described_class.call(latitude, longitude) + + expect(instance_result.to_s).to eq(class_result.to_s) + end + + it "class method handles multiple arguments correctly" do + result = described_class.call(40.7128, -74.0060) + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq("40.7128,-74.006") + expect(query_params["q"]).to eq("40.7128,-74.006") + end + end + + describe "integration with URI handling" do + it "handles URI with no existing query parameters" do + # This is the normal case + result = service.call + query_params = URI.decode_www_form(result.query).to_h + + expect(query_params).to have_key("center") + expect(query_params).to have_key("zoom") + expect(query_params).to have_key("q") + expect(query_params).to have_key("key") + end + end + + describe "error handling and validation" do + context "when latitude is not numeric" do + let(:invalid_lat_service) { described_class.new("invalid", -123.106431) } + + it "raises error for non-numeric latitude" do + expect do + invalid_lat_service.call + end.to raise_error(NoMethodError, /undefined method 'round' for an instance of String/) + end + end + + context "when longitude is not numeric" do + let(:invalid_long_service) { described_class.new(49.243463, "invalid") } + + it "raises error for non-numeric longitude" do + expect do + invalid_long_service.call + end.to raise_error(NoMethodError, /undefined method 'round' for an instance of String/) + end + end + + context "when coordinates are extremely large" do + let(:extreme_service) { described_class.new(Float::INFINITY, -Float::INFINITY) } + + it "handles extreme values" do + expect { extreme_service.call }.not_to raise_error + result = extreme_service.call + expect(result).to be_a(URI::HTTPS) + end + end + end + + describe "configuration independence" do + it "uses the actual MAP_CONFIG values" do + result = described_class.call(latitude, longitude) + query_params = URI.decode_www_form(result.query).to_h + + expect(query_params["zoom"]).to eq("14") + expect(query_params["maptype"]).to eq("roadmap") + # Shouldn't have size since it's commented out + expect(query_params).not_to have_key("size") + end + end + + describe "comparison with StaticMapService" do + let(:static_service) { Locations::GoogleMaps::StaticMapService.new(latitude, longitude) } + + it "uses different base URL" do + static_result = static_service.call + embed_result = service.call + + expect(static_result.path).to eq("/maps/api/staticmap") + expect(embed_result.path).to eq("/maps/embed/v1/place") + end + + it "uses different query parameters structure" do + static_result = static_service.call + embed_result = service.call + + static_params = URI.decode_www_form(static_result.query).to_h + embed_params = URI.decode_www_form(embed_result.query).to_h + + expect(static_params).to have_key("markers") + expect(static_params).not_to have_key("q") + + expect(embed_params).to have_key("q") + expect(embed_params).not_to have_key("markers") + end + + it "both use the same coordinate rounding" do + static_result = static_service.call + embed_result = service.call + + static_params = URI.decode_www_form(static_result.query).to_h + embed_params = URI.decode_www_form(embed_result.query).to_h + + expect(static_params["center"]).to eq(embed_params["center"]) + end + end + + describe "real-world usage scenarios" do + it "handles Vancouver coordinates" do + vancouver_service = described_class.new(49.2827, -123.1207) + result = vancouver_service.call + query_params = URI.decode_www_form(result.query).to_h + + expect(query_params["center"]).to eq("49.2827,-123.1207") + expect(query_params["q"]).to eq("49.2827,-123.1207") + end + + it "handles New York coordinates" do + ny_service = described_class.new(40.7128, -74.0060) + result = ny_service.call + query_params = URI.decode_www_form(result.query).to_h + + expect(query_params["center"]).to eq("40.7128,-74.006") + expect(query_params["q"]).to eq("40.7128,-74.006") + end + + it "handles London coordinates" do + london_service = described_class.new(51.5074, -0.1278) + result = london_service.call + query_params = URI.decode_www_form(result.query).to_h + + expect(query_params["center"]).to eq("51.5074,-0.1278") + expect(query_params["q"]).to eq("51.5074,-0.1278") + end + end +end diff --git a/spec/services/locations/google_maps/static_map_service_spec.rb b/spec/services/locations/google_maps/static_map_service_spec.rb new file mode 100644 index 00000000..abca4a70 --- /dev/null +++ b/spec/services/locations/google_maps/static_map_service_spec.rb @@ -0,0 +1,401 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Locations::GoogleMaps::StaticMapService, type: :service do + before(:each) do + stub_const("Locations::GoogleMaps::GOOGLE_KEY", "test_google_key") + stub_const("Locations::GoogleMaps::GOOGLE_SIGNATURE", "") + end + + let(:latitude) { 49.243463359535 } + let(:longitude) { -123.106431021296 } + let(:service) { described_class.new(latitude, longitude) } + + describe "initialization" do + it "initializes with latitude and longitude" do + expect(service.latitude).to eq(latitude) + expect(service.longitude).to eq(longitude) + end + + it "creates a URI object with the correct base URL" do + expect(service.uri).to be_a(URI::HTTPS) + expect(service.uri.to_s).to start_with("https://maps.googleapis.com/maps/api/staticmap") + end + + it "handles integer coordinates" do + int_service = described_class.new(49, -123) + expect(int_service.latitude).to eq(49) + expect(int_service.longitude).to eq(-123) + end + + it "handles float coordinates" do + float_service = described_class.new(49.5, -123.5) + expect(float_service.latitude).to eq(49.5) + expect(float_service.longitude).to eq(-123.5) + end + + it "handles string coordinates that can be converted to numbers" do + string_service = described_class.new("49.243463", "-123.106431") + expect(string_service.latitude).to eq("49.243463") + expect(string_service.longitude).to eq("-123.106431") + end + + it "handles nil coordinates" do + nil_service = described_class.new(nil, nil) + expect(nil_service.latitude).to be_nil + expect(nil_service.longitude).to be_nil + end + end + + describe "#call" do + let(:result) { service.call } + + it "returns a URI object" do + expect(result).to be_a(URI::HTTPS) + end + + it "has the correct hostname" do + expect(result.hostname).to eq("maps.googleapis.com") + end + + it "has the correct path" do + expect(result.path).to eq("/maps/api/staticmap") + end + + it "has the correct scheme" do + expect(result.scheme).to eq("https") + end + + it "sets query parameters" do + expect(result.query).not_to be_nil + expect(result.query).not_to be_empty + end + + describe "query parameters" do + let(:query_params) do + URI.decode_www_form(result.query).to_h + end + + it "includes center parameter with rounded coordinates" do + expect(query_params["center"]).to eq("49.243463,-123.106431") + end + + it "includes zoom parameter from MAP_CONFIG" do + expect(query_params["zoom"]).to eq("14") + end + + it "includes maptype parameter from MAP_CONFIG" do + expect(query_params["maptype"]).to eq("roadmap") + end + + it "includes size parameter from MAP_CONFIG" do + expect(query_params["size"]).to eq("400x400") + end + + it "includes markers parameter with correct format" do + expect(query_params["markers"]).to eq("color:red|label:F|49.243463,-123.106431") + end + + it "includes key parameter with GOOGLE_KEY" do + expect(query_params["key"]).to eq("test_google_key") + end + + it "does not include signature parameter when GOOGLE_SIGNATURE is blank" do + expect(query_params).not_to have_key("signature") + end + + context "when GOOGLE_SIGNATURE is present" do + before do + stub_const("Locations::GoogleMaps::GOOGLE_SIGNATURE", "test_signature") + end + + it "includes signature parameter" do + expect(query_params["signature"]).to eq("test_signature") + end + end + end + + describe "coordinate rounding behavior" do + context "with many decimal places" do + let(:high_precision_lat) { 49.243463359535123456789 } + let(:high_precision_long) { -123.106431021296123456789 } + let(:high_precision_service) { described_class.new(high_precision_lat, high_precision_long) } + + it "rounds to 6 decimal places in center parameter" do + result = high_precision_service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq("49.243463,-123.106431") + end + + it "rounds to 6 decimal places in markers parameter" do + result = high_precision_service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["markers"]).to eq("color:red|label:F|49.243463,-123.106431") + end + end + + context "with coordinates that need rounding up" do + let(:round_up_service) { described_class.new(49.2434635, -123.1064315) } + + it "rounds correctly" do + result = round_up_service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq("49.243464,-123.106432") + end + end + + context "with coordinates that need rounding down" do + let(:round_down_service) { described_class.new(49.2434634, -123.1064314) } + + it "rounds correctly" do + result = round_down_service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq("49.243463,-123.106431") + end + end + + context "with negative coordinates" do + let(:negative_service) { described_class.new(-49.243463359535, 123.106431021296) } + + it "handles negative coordinates correctly" do + result = negative_service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq("-49.243463,123.106431") + end + end + + context "with zero coordinates" do + let(:zero_service) { described_class.new(0, 0) } + + it "handles zero coordinates correctly" do + result = zero_service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq("0,0") + end + end + end + + describe "marker behavior" do + it "includes color marker" do + result = service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["markers"]).to include("color:red") + end + + it "includes label marker" do + result = service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["markers"]).to include("label:F") + end + + it "includes coordinates in markers" do + result = service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["markers"]).to include("49.243463,-123.106431") + end + + it "separates marker components with pipes" do + result = service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["markers"]).to eq("color:red|label:F|49.243463,-123.106431") + end + end + + describe "edge cases" do + context "with nil coordinates" do + let(:nil_service) { described_class.new(nil, nil) } + + it "raises error for nil coordinates" do + expect { nil_service.call }.to raise_error(NoMethodError, /undefined method 'round' for nil/) + end + end + + context "with empty coordinates" do + let(:empty_service) { described_class.new("", "") } + + it "raises error for empty coordinates" do + expect do + empty_service.call + end.to raise_error(NoMethodError, /undefined method 'round' for an instance of String/) + end + end + + context "with very large coordinates" do + let(:large_service) { described_class.new(999.999999, -999.999999) } + + it "handles large coordinates" do + result = large_service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq("999.999999,-999.999999") + end + end + + context "with very small coordinates" do + let(:small_service) { described_class.new(0.0000001, -0.0000001) } + + it "handles very small coordinates" do + result = small_service.call + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq("0.0,-0.0") + end + end + end + + describe "URL encoding" do + it "properly encodes query parameters" do + result = service.call + expect(result.query).to include("center=49.243463%2C-123.106431") + expect(result.query).to include("markers=color%3Ared%7Clabel%3AF%7C49.243463%2C-123.106431") + end + + it "creates a valid URI that can be parsed" do + result = service.call + parsed_uri = URI.parse(result.to_s) + expect(parsed_uri).to eq(result) + end + + it "creates a URI that can be accessed via HTTP" do + result = service.call + expect(result.to_s).to start_with("https://maps.googleapis.com") + expect(result.to_s).to include("?") + end + end + end + + describe "private methods" do + describe "#coordinates" do + it "returns an array with rounded latitude and longitude" do + coordinates = service.send(:coordinates) + expect(coordinates).to eq([49.243463, -123.106431]) + end + + it "rounds to 6 decimal places" do + high_precision_service = described_class.new(49.243463359535, -123.106431021296) + coordinates = high_precision_service.send(:coordinates) + expect(coordinates).to eq([49.243463, -123.106431]) + end + end + + describe "#markers" do + it "returns an array with marker components" do + markers = service.send(:markers) + expect(markers).to eq(["color:red", "label:F", "49.243463,-123.106431"]) + end + + it "uses rounded coordinates" do + high_precision_service = described_class.new(49.243463359535, -123.106431021296) + markers = high_precision_service.send(:markers) + expect(markers[2]).to eq("49.243463,-123.106431") + end + end + + describe "#query_params" do + let(:query_params) { service.send(:query_params) } + + it "returns a hash with symbolized keys" do + expect(query_params).to be_a(Hash) + expect(query_params.keys).to all(be_a(Symbol)) + end + + it "includes all required parameters" do + expect(query_params).to have_key(:center) + expect(query_params).to have_key(:zoom) + expect(query_params).to have_key(:maptype) + expect(query_params).to have_key(:size) + expect(query_params).to have_key(:markers) + expect(query_params).to have_key(:key) + end + + it "uses correct values from MAP_CONFIG" do + expect(query_params[:zoom]).to eq(14) + expect(query_params[:maptype]).to eq("roadmap") + expect(query_params[:size]).to eq("400x400") + end + + it "uses coordinates for center parameter" do + expect(query_params[:center]).to eq("49.243463,-123.106431") + end + + it "joins markers with pipe separator" do + expect(query_params[:markers]).to eq("color:red|label:F|49.243463,-123.106431") + end + end + end + + describe "class method interface" do + it "can be called using .call class method" do + result = described_class.call(latitude, longitude) + expect(result).to be_a(URI::HTTPS) + end + + it "class method returns same result as instance method" do + instance_result = service.call + class_result = described_class.call(latitude, longitude) + + expect(instance_result.to_s).to eq(class_result.to_s) + end + + it "class method handles multiple arguments correctly" do + result = described_class.call(40.7128, -74.0060) + query_params = URI.decode_www_form(result.query).to_h + expect(query_params["center"]).to eq("40.7128,-74.006") + end + end + + describe "integration with URI handling" do + it "handles URI with no existing query parameters" do + # This is the normal case + result = service.call + query_params = URI.decode_www_form(result.query).to_h + + expect(query_params).to have_key("center") + expect(query_params).to have_key("zoom") + expect(query_params).to have_key("markers") + expect(query_params).to have_key("key") + end + end + + describe "error handling and validation" do + context "when latitude is not numeric" do + let(:invalid_lat_service) { described_class.new("invalid", -123.106431) } + + it "raises error for non-numeric latitude" do + expect do + invalid_lat_service.call + end.to raise_error(NoMethodError, /undefined method 'round' for an instance of String/) + end + end + + context "when longitude is not numeric" do + let(:invalid_long_service) { described_class.new(49.243463, "invalid") } + + it "raises error for non-numeric longitude" do + expect do + invalid_long_service.call + end.to raise_error(NoMethodError, /undefined method 'round' for an instance of String/) + end + end + + context "when coordinates are extremely large" do + let(:extreme_service) { described_class.new(Float::INFINITY, -Float::INFINITY) } + + it "handles extreme values" do + expect { extreme_service.call }.not_to raise_error + result = extreme_service.call + expect(result).to be_a(URI::HTTPS) + end + end + end + + describe "configuration independence" do + it "uses the actual MAP_CONFIG values" do + result = described_class.call(latitude, longitude) + query_params = URI.decode_www_form(result.query).to_h + + expect(query_params["zoom"]).to eq("14") + expect(query_params["size"]).to eq("400x400") + expect(query_params["maptype"]).to eq("roadmap") + end + end +end diff --git a/spec/services/locations/searcher_spec.rb b/spec/services/locations/searcher_spec.rb new file mode 100644 index 00000000..6f38032c --- /dev/null +++ b/spec/services/locations/searcher_spec.rb @@ -0,0 +1,563 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Locations::Searcher, type: :service do + describe "initialization" do + it "initializes with address parameter" do + address = "123 Main St, Vancouver, BC" + searcher = described_class.new(address:) + + expect(searcher.address).to eq(address) + end + + it "initializes with nil address" do + searcher = described_class.new(address: nil) + + expect(searcher.address).to be_nil + end + + it "defaults address to nil when not provided" do + searcher = described_class.new + + expect(searcher.address).to be_nil + end + end + + describe "#call" do + let(:address) { "123 Main St, Vancouver, BC" } + let(:searcher) { described_class.new(address:) } + + context "with successful geocoding" do + let(:geocoder_result_one) do + double("Geocoder Result 1").tap do |double| + allow(double).to receive(:latitude).and_return(49.243463) + allow(double).to receive(:longitude).and_return(-123.106431) + allow(double).to receive(:address).and_return("123 Main St") + allow(double).to receive(:city).and_return("Vancouver") + allow(double).to receive(:state).and_return("BC") + allow(double).to receive(:postal_code).and_return("V6A 1A1") + allow(double).to receive(:country).and_return("Canada") + allow(double).to receive(:data).and_return({ "place_id" => "12345" }) + allow(double).to receive(:street_address).and_return("123 Main St") + end + end + + let(:geocoder_result_two) do + double("Geocoder Result 2").tap do |double| + allow(double).to receive(:latitude).and_return(49.243464) + allow(double).to receive(:longitude).and_return(-123.106432) + allow(double).to receive(:address).and_return("123 Main Street") + allow(double).to receive(:city).and_return("Vancouver") + allow(double).to receive(:state).and_return("BC") + allow(double).to receive(:postal_code).and_return("V6A 1A2") + allow(double).to receive(:country).and_return("Canada") + allow(double).to receive(:data).and_return({ "place_id" => "67890" }) + allow(double).to receive(:street_address).and_return("123 Main Street") + end + end + + let(:parsed_location_one) do + Locations::GeocoderLocation.new( + address: "123 Main St", + city: "Vancouver", + state: "BC", + country: "Canada", + postal_code: "V6A 1A1", + latitude: 49.243463, + longitude: -123.106431, + data: { "place_id" => "12345" }, + data_raw: '{"place_id":"12345"}' + ) + end + + let(:parsed_location_two) do + Locations::GeocoderLocation.new( + address: "123 Main Street", + city: "Vancouver", + state: "BC", + country: "Canada", + postal_code: "V6A 1A2", + latitude: 49.243464, + longitude: -123.106432, + data: { "place_id" => "67890" }, + data_raw: '{"place_id":"67890"}' + ) + end + + let(:expected_location_one) do + Location.build_from(geocoder_location: parsed_location_one) + end + + let(:expected_location_two) do + Location.build_from(geocoder_location: parsed_location_two) + end + + before do + allow(Geocoder).to receive(:search).with(address).and_return([geocoder_result_one, geocoder_result_two]) + allow(Locations::Parser).to receive(:parse).and_return(parsed_location_one, parsed_location_two) + allow(Location).to receive(:build_from).and_return(expected_location_one, expected_location_two) + end + + it "calls Geocoder.search with the address" do + searcher.call + expect(Geocoder).to have_received(:search).with(address) + end + + it "returns a lazy enumerator" do + result = searcher.call + + expect(result).to be_a(Enumerator::Lazy) + end + + it "maps results through Locations::Parser.parse" do + result = searcher.call + result.to_a # Force evaluation + + expect(Locations::Parser).to have_received(:parse).with(geocoder_result_one) + expect(Locations::Parser).to have_received(:parse).with(geocoder_result_two) + expect(Location).to have_received(:build_from).with(geocoder_location: parsed_location_one) + expect(Location).to have_received(:build_from).with(geocoder_location: parsed_location_two) + end + + it "returns enumerable of Location objects" do + result = searcher.call + locations = result.to_a + + expect(locations).to eq([expected_location_one, expected_location_two]) + expect(locations.first).to be_a(Location) + expect(locations.last).to be_a(Location) + end + + describe "lazy enumeration behavior" do + it "does not process results until enumeration" do + searcher.call + + expect(Locations::Parser).not_to have_received(:parse) + expect(Location).not_to have_received(:build_from) + end + + it "processes results only as needed" do + result = searcher.call + + # Process only the first element + result.first + + expect(Locations::Parser).to have_received(:parse).with(geocoder_result_one).once + expect(Locations::Parser).not_to have_received(:parse).with(geocoder_result_two) + expect(Location).to have_received(:build_from).with(geocoder_location: parsed_location_one).once + expect(Location).not_to have_received(:build_from).with(geocoder_location: parsed_location_two) + end + + it "can be enumerated multiple times" do + result = searcher.call + + # First enumeration + first_enumeration = result.to_a + expect(first_enumeration).to be_an(Array) + expect(first_enumeration.length).to eq(2) + + # Second enumeration - should also work and return results + second_enumeration = result.to_a + expect(second_enumeration).to be_an(Array) + expect(second_enumeration.length).to eq(2) + + # Both should contain Location objects + expect(first_enumeration.all? { |loc| loc.is_a?(Location) }).to be true + expect(second_enumeration.all? { |loc| loc.is_a?(Location) }).to be true + end + end + end + + context "with single result" do + let(:geocoder_result) do + double("Geocoder Result").tap do |double| + allow(double).to receive(:latitude).and_return(49.243463) + allow(double).to receive(:longitude).and_return(-123.106431) + allow(double).to receive(:address).and_return("123 Main St") + allow(double).to receive(:city).and_return("Vancouver") + allow(double).to receive(:state).and_return("BC") + allow(double).to receive(:postal_code).and_return("V6A 1A1") + allow(double).to receive(:country).and_return("Canada") + allow(double).to receive(:data).and_return({}) + allow(double).to receive(:street_address).and_return("123 Main St") + end + end + + let(:parsed_location) do + Locations::GeocoderLocation.new( + address: "123 Main St", + city: "Vancouver", + state: "BC", + country: "Canada", + postal_code: "V6A 1A1", + latitude: 49.243463, + longitude: -123.106431, + data: {}, + data_raw: "{}" + ) + end + + let(:expected_location) do + Location.build_from(geocoder_location: parsed_location) + end + + before do + allow(Geocoder).to receive(:search).with(address).and_return([geocoder_result]) + allow(Locations::Parser).to receive(:parse).and_return(parsed_location) + allow(Location).to receive(:build_from).and_return(expected_location) + end + + it "returns single location object" do + result = searcher.call + locations = result.to_a + + expect(locations).to eq([expected_location]) + expect(locations.length).to eq(1) + end + + it "can be accessed with first" do + result = searcher.call + location = result.first + + expect(location).to eq(expected_location) + end + end + + context "with empty results" do + before do + allow(Geocoder).to receive(:search).with(address).and_return([]) + allow(Locations::Parser).to receive(:parse) + allow(Location).to receive(:build_from) + end + + it "returns empty lazy enumerator" do + result = searcher.call + + expect(result).to be_a(Enumerator::Lazy) + expect(result.to_a).to be_empty + end + + it "does not call Locations::Parser" do + result = searcher.call + result.to_a + + expect(Locations::Parser).not_to have_received(:parse) + end + + it "does not call Location.build_from" do + result = searcher.call + result.to_a + + expect(Location).not_to have_received(:build_from) + end + end + + context "with nil address" do + let(:nil_address_searcher) { described_class.new(address: nil) } + + before do + allow(Geocoder).to receive(:search).with(nil).and_return([]) + end + + it "handles nil address gracefully" do + result = nil_address_searcher.call + expect(result).to be_a(Enumerator::Lazy) + expect(result.to_a).to be_empty + end + + it "calls Geocoder.search with nil" do + nil_address_searcher.call + + expect(Geocoder).to have_received(:search).with(nil) + end + end + + context "with invalid address" do + let(:invalid_address) { "" } + let(:invalid_address_searcher) { described_class.new(address: invalid_address) } + + before do + allow(Geocoder).to receive(:search).with(invalid_address).and_return([]) + end + + it "handles invalid address gracefully" do + result = invalid_address_searcher.call + expect(result).to be_a(Enumerator::Lazy) + expect(result.to_a).to be_empty + end + + it "calls Geocoder.search with invalid address" do + invalid_address_searcher.call + + expect(Geocoder).to have_received(:search).with(invalid_address) + end + end + + context "error handling" do + context "when Geocoder.search raises an error" do + before do + allow(Geocoder).to receive(:search).with(address).and_raise(StandardError, "Geocoder error") + end + + it "propagates the error" do + expect do + searcher.call + end.to raise_error(StandardError, "Geocoder error") + end + end + + context "when Locations::Parser.parse raises an error" do + let(:geocoder_result) { instance_double("Geocoder::Result::Base") } + + before do + allow(Geocoder).to receive(:search).with(address).and_return([geocoder_result]) + allow(Locations::Parser).to receive(:parse).with(geocoder_result).and_raise(StandardError, "Parser error") + end + + it "propagates the error when enumeration occurs" do + result = searcher.call + + expect do + result.to_a + end.to raise_error(StandardError, "Parser error") + end + + it "does not raise error before enumeration due to lazy evaluation" do + expect do + searcher.call + end.not_to raise_error + end + end + + context "when Location.build_from raises an error" do + let(:geocoder_result) { instance_double("Geocoder::Result::Base") } + let(:parsed_location) { instance_double("Locations::GeocoderLocation") } + + before do + allow(Geocoder).to receive(:search).with(address).and_return([geocoder_result]) + allow(Locations::Parser).to receive(:parse).with(geocoder_result).and_return(parsed_location) + allow(Location).to receive(:build_from).with(geocoder_location: parsed_location).and_raise(StandardError, "Build error") + end + + it "propagates the error when enumeration occurs" do + result = searcher.call + + expect do + result.to_a + end.to raise_error(StandardError, "Build error") + end + end + end + + context "integration with Locations::Parser" do + let(:geocoder_result) do + double("Geocoder Result").tap do |double| + allow(double).to receive(:latitude).and_return(49.243463) + allow(double).to receive(:longitude).and_return(-123.106431) + allow(double).to receive(:address).and_return("123 Main St") + allow(double).to receive(:city).and_return("Vancouver") + allow(double).to receive(:state).and_return("BC") + allow(double).to receive(:postal_code).and_return("V6A 1A1") + allow(double).to receive(:country).and_return("Canada") + allow(double).to receive(:data).and_return({ "provider" => "test" }) + allow(double).to receive(:street_address).and_return("123 Main St") + end + end + + let(:parsed_location) do + Locations::GeocoderLocation.new( + address: "123 Main St", + city: "Vancouver", + state: "BC", + country: "Canada", + postal_code: "V6A 1A1", + latitude: 49.243463, + longitude: -123.106431, + data: { "provider" => "test" }, + data_raw: '{"provider":"test"}' + ) + end + + let(:expected_location) do + Location.build_from(geocoder_location: parsed_location) + end + + before do + allow(Geocoder).to receive(:search).with(address).and_return([geocoder_result]) + allow(Location).to receive(:build_from).with(geocoder_location: parsed_location).and_return(expected_location) + end + + it "calls Locations::Parser.parse with correct parameters" do + allow(Locations::Parser).to receive(:parse).and_call_original + allow(Locations::Parser).to receive(:provider_class).and_return(class_double("Locations::Providers::TestParser", call: parsed_location)) + + result = searcher.call + result.to_a + + expect(Locations::Parser).to have_received(:parse).with(geocoder_result) + end + + it "uses parsed location to build final Location object" do + allow(Locations::Parser).to receive(:parse).with(geocoder_result).and_return(parsed_location) + + result = searcher.call + locations = result.to_a + + expect(locations).to eq([expected_location]) + end + end + + context "integration with Location.build_from" do + let(:geocoder_result) do + double("Geocoder Result").tap do |double| + allow(double).to receive(:latitude).and_return(49.243463) + allow(double).to receive(:longitude).and_return(-123.106431) + allow(double).to receive(:address).and_return("123 Main St") + allow(double).to receive(:city).and_return("Vancouver") + allow(double).to receive(:state).and_return("BC") + allow(double).to receive(:postal_code).and_return("V6A 1A1") + allow(double).to receive(:country).and_return("Canada") + allow(double).to receive(:data).and_return({}) + allow(double).to receive(:street_address).and_return("123 Main St") + end + end + + let(:parsed_location) do + Locations::GeocoderLocation.new( + address: "123 Main St", + city: "Vancouver", + state: "BC", + country: "Canada", + postal_code: "V6A 1A1", + latitude: 49.243463, + longitude: -123.106431, + data: {}, + data_raw: "{}" + ) + end + + let(:built_location) do + Location.new( + address: "123 Main St, Vancouver, BC, V6A 1A1, Canada", + lat: 49.243463, + long: -123.106431 + ) + end + + before do + allow(Geocoder).to receive(:search).with(address).and_return([geocoder_result]) + allow(Locations::Parser).to receive(:parse).with(geocoder_result).and_return(parsed_location) + allow(Location).to receive(:build_from).with(geocoder_location: parsed_location).and_call_original + end + + it "calls Location.build_from with correct geocoder_location parameter" do + result = searcher.call + result.to_a + + expect(Location).to have_received(:build_from).with(geocoder_location: parsed_location) + end + + it "returns properly built Location objects" do + result = searcher.call + locations = result.to_a + + expect(locations.first.address).to eq("123 Main St, Vancouver, BC, V6A 1A1") + expect(locations.first.lat).to eq(49.243463) + expect(locations.first.long).to eq(-123.106431) + end + end + end + + describe "performance aspects" do + let(:address) { "123 Main St, Vancouver, BC" } + let(:searcher) { described_class.new(address:) } + + context "lazy evaluation performance" do + let(:geocoder_results) do + Array.new(1000) do |i| + double("Geocoder Result #{i}").tap do |double| + allow(double).to receive(:latitude).and_return(49.243463 + (i * 0.001)) + allow(double).to receive(:longitude).and_return(-123.106431 + (i * 0.001)) + allow(double).to receive(:address).and_return("123 Main St #{i}") + allow(double).to receive(:city).and_return("Vancouver") + allow(double).to receive(:state).and_return("BC") + allow(double).to receive(:postal_code).and_return("V6A 1A#{i}") + allow(double).to receive(:country).and_return("Canada") + allow(double).to receive(:data).and_return({ "index" => i }) + allow(double).to receive(:street_address).and_return("123 Main St #{i}") + end + end + end + + before do + allow(Geocoder).to receive(:search).with(address).and_return(geocoder_results) + allow(Locations::Parser).to receive(:parse).and_return(instance_double("Locations::GeocoderLocation")) + allow(Location).to receive(:build_from).and_return(instance_double("Location")) + end + + it "does not process all results immediately" do + # Instead of expecting not to receive, we'll verify that the methods were called only as many times as needed + allow(Locations::Parser).to receive(:parse).and_call_original + allow(Location).to receive(:build_from).and_call_original + + result = searcher.call + # Process only first 5 elements + result.first(5).to_a + + expect(Locations::Parser).to have_received(:parse).exactly(5).times + expect(Location).to have_received(:build_from).exactly(5).times + end + + it "processes only requested number of results" do + result = searcher.call + result.first(3).to_a + + expect(Locations::Parser).to have_received(:parse).exactly(3).times + expect(Location).to have_received(:build_from).exactly(3).times + end + end + + context "memory efficiency" do + let(:large_result_set) { Array.new(10_000) { double("Geocoder Result") } } + + before do + allow(Geocoder).to receive(:search).with(address).and_return(large_result_set) + allow(Locations::Parser).to receive(:parse).and_return(instance_double("Locations::GeocoderLocation")) + allow(Location).to receive(:build_from).and_return(instance_double("Location")) + end + + it "can handle large result sets without immediate memory overhead" do + result = searcher.call + + # Should not attempt to process all 10,000 results immediately + expect(result).to be_a(Enumerator::Lazy) + end + end + end + + describe "class method shortcut" do + it "can be called with .class method" do + address = "123 Main St, Vancouver, BC" + + allow(Geocoder).to receive(:search).with(address).and_return([]) + + described_class.call(address: address) + + expect(Geocoder).to have_received(:search).with(address) + end + + it "works with the same interface as instance call" do + address = "123 Main St, Vancouver, BC" + + allow(Geocoder).to receive(:search).with(address).and_return([]) + + class_result = described_class.call(address: address) + instance_result = described_class.new(address: address).call + + expect(class_result).to be_a(Enumerator::Lazy) + expect(instance_result).to be_a(Enumerator::Lazy) + expect(class_result.to_a).to eq(instance_result.to_a) + end + end +end