diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 5c95dc4f..2904c492 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -11,7 +11,8 @@ updates:
interval: "weekly"
target-branch: "develop"
labels:
- - "ruby dependencies"
+ - "ruby"
+ - "dependencies"
- package-ecosystem: "npm"
directory: "/"
@@ -19,7 +20,8 @@ updates:
interval: "weekly"
target-branch: "develop"
labels:
- - "javascript dependencies"
+ - "javascript"
+ - "dependencies"
- package-ecosystem: "composer"
directory: "/"
diff --git a/.opencode/agents/rails-code-auditor.md b/.opencode/agents/rails-code-auditor.md
index 8e40ed14..cc56bcb7 100644
--- a/.opencode/agents/rails-code-auditor.md
+++ b/.opencode/agents/rails-code-auditor.md
@@ -1,7 +1,7 @@
---
description: Review code for quality and Rails conventions (report + suggest on request)
mode: subagent
-model: minimax-coding-plan/MiniMax-M2.5
+model: minimax-coding-plan/MiniMax-M2.7
permission:
skill:
"rails-code-quality": "allow"
diff --git a/.opencode/agents/rails-migration-manager.md b/.opencode/agents/rails-migration-manager.md
index 86e1c41d..f84e53f2 100644
--- a/.opencode/agents/rails-migration-manager.md
+++ b/.opencode/agents/rails-migration-manager.md
@@ -1,7 +1,7 @@
---
description: Manage Rails migrations - create, run, rollback, and troubleshoot
mode: subagent
-model: minimax-coding-plan/MiniMax-M2.5
+model: minimax-coding-plan/MiniMax-M2.7
permission:
skill:
"rails-migrations": "allow"
diff --git a/.opencode/agents/rails-refactor.md b/.opencode/agents/rails-refactor.md
index 5e439bd8..ef9f8407 100644
--- a/.opencode/agents/rails-refactor.md
+++ b/.opencode/agents/rails-refactor.md
@@ -1,7 +1,7 @@
---
description: Refactor code following Rails and project conventions
mode: subagent
-model: minimax-coding-plan/MiniMax-M2.5
+model: minimax-coding-plan/MiniMax-M2.7
permission:
skill:
"rails-code-quality": "allow"
diff --git a/.opencode/agents/rails-resource-builder.md b/.opencode/agents/rails-resource-builder.md
index 7844740a..dea91293 100644
--- a/.opencode/agents/rails-resource-builder.md
+++ b/.opencode/agents/rails-resource-builder.md
@@ -1,7 +1,7 @@
---
description: Generate complete Rails resources (models, controllers, routes, tests)
mode: subagent
-model: minimax-coding-plan/MiniMax-M2.5
+model: minimax-coding-plan/MiniMax-M2.7
permission:
skill:
"rails-models": "allow"
diff --git a/.opencode/agents/rails-test-runner.md b/.opencode/agents/rails-test-runner.md
index 9f9819bd..c5906966 100644
--- a/.opencode/agents/rails-test-runner.md
+++ b/.opencode/agents/rails-test-runner.md
@@ -1,7 +1,7 @@
---
description: Execute tests and report results only
mode: subagent
-model: minimax-coding-plan/MiniMax-M2.5
+model: minimax-coding-plan/MiniMax-M2.7
permission:
skill:
"rspec-testing": "allow"
diff --git a/.opencode/agents/ruby-gem-updater.md b/.opencode/agents/ruby-gem-updater.md
index 1cb9680a..b5a0cc87 100644
--- a/.opencode/agents/ruby-gem-updater.md
+++ b/.opencode/agents/ruby-gem-updater.md
@@ -1,7 +1,7 @@
---
description: Execute Ruby gem updates with version checking, breaking change analysis, and testing
mode: subagent
-model: minimax-coding-plan/MiniMax-M2.5
+model: minimax-coding-plan/MiniMax-M2.7
permission:
skill:
"ruby-gem-update": "allow"
diff --git a/Gemfile b/Gemfile
index 81e83ad0..85153e05 100644
--- a/Gemfile
+++ b/Gemfile
@@ -4,7 +4,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby "3.4.5"
# Bundle edge Rails instead: gem "rails", github: "rails/rails"
-gem "rails", "~> 8.1.0"
+gem "rails", "~> 8.1.3"
# Use postgresql as the database for Active Record
gem "pg", "~> 1.6.2"
# Use Puma as the app server
@@ -68,8 +68,8 @@ gem "faker", "~> 3.6", groups: [:development, :test].tap { |groups|
group :development do
# Access an interactive console on exception pages or by calling "console" anywhere in the code.
- gem "web-console", "~> 4.2.1"
- gem "listen", "~> 3.9.0"
+ gem "web-console", "~> 4.3.0"
+ gem "listen", "~> 3.10.0"
# Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
# gem "spring"
@@ -106,7 +106,7 @@ end
gem "tzinfo-data", platforms: [:mri, :windows]
# Pagination
-gem "pagy", "~> 43.4"
+gem "pagy", "~> 43.5"
# Alternative approach to web apps development.
# https://github.com/hotwired/hotwire-rails
@@ -135,7 +135,7 @@ gem "importmap-rails"
# gem "rack-timeout"
# Http client for making API requests
-gem "faraday", "~> 2.14.0"
+gem "faraday", "~> 2.14.2"
# OpenStruct for easy data modeling - removed from standard library since ruby 3.5
gem "ostruct"
diff --git a/Gemfile.lock b/Gemfile.lock
index 2c28e615..9f7f05a0 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -7,31 +7,31 @@ GIT
GEM
remote: https://rubygems.org/
specs:
- action_text-trix (2.1.17)
+ action_text-trix (2.1.18)
railties
- actioncable (8.1.2)
- actionpack (= 8.1.2)
- activesupport (= 8.1.2)
+ actioncable (8.1.3)
+ actionpack (= 8.1.3)
+ activesupport (= 8.1.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
- actionmailbox (8.1.2)
- actionpack (= 8.1.2)
- activejob (= 8.1.2)
- activerecord (= 8.1.2)
- activestorage (= 8.1.2)
- activesupport (= 8.1.2)
+ actionmailbox (8.1.3)
+ actionpack (= 8.1.3)
+ activejob (= 8.1.3)
+ activerecord (= 8.1.3)
+ activestorage (= 8.1.3)
+ activesupport (= 8.1.3)
mail (>= 2.8.0)
- actionmailer (8.1.2)
- actionpack (= 8.1.2)
- actionview (= 8.1.2)
- activejob (= 8.1.2)
- activesupport (= 8.1.2)
+ actionmailer (8.1.3)
+ actionpack (= 8.1.3)
+ actionview (= 8.1.3)
+ activejob (= 8.1.3)
+ activesupport (= 8.1.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
- actionpack (8.1.2)
- actionview (= 8.1.2)
- activesupport (= 8.1.2)
+ actionpack (8.1.3)
+ actionview (= 8.1.3)
+ activesupport (= 8.1.3)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -39,36 +39,36 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
- actiontext (8.1.2)
+ actiontext (8.1.3)
action_text-trix (~> 2.1.15)
- actionpack (= 8.1.2)
- activerecord (= 8.1.2)
- activestorage (= 8.1.2)
- activesupport (= 8.1.2)
+ actionpack (= 8.1.3)
+ activerecord (= 8.1.3)
+ activestorage (= 8.1.3)
+ activesupport (= 8.1.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
- actionview (8.1.2)
- activesupport (= 8.1.2)
+ actionview (8.1.3)
+ activesupport (= 8.1.3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
- activejob (8.1.2)
- activesupport (= 8.1.2)
+ activejob (8.1.3)
+ activesupport (= 8.1.3)
globalid (>= 0.3.6)
- activemodel (8.1.2)
- activesupport (= 8.1.2)
- activerecord (8.1.2)
- activemodel (= 8.1.2)
- activesupport (= 8.1.2)
+ activemodel (8.1.3)
+ activesupport (= 8.1.3)
+ activerecord (8.1.3)
+ activemodel (= 8.1.3)
+ activesupport (= 8.1.3)
timeout (>= 0.4.0)
- activestorage (8.1.2)
- actionpack (= 8.1.2)
- activejob (= 8.1.2)
- activerecord (= 8.1.2)
- activesupport (= 8.1.2)
+ activestorage (8.1.3)
+ actionpack (= 8.1.3)
+ activejob (= 8.1.3)
+ activerecord (= 8.1.3)
+ activesupport (= 8.1.3)
marcel (~> 1.0)
- activesupport (8.1.2)
+ activesupport (8.1.3)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -81,7 +81,7 @@ GEM
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
- addressable (2.8.9)
+ addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
base64 (0.3.0)
@@ -90,7 +90,7 @@ GEM
erubi (>= 1.0.0)
rack (>= 0.9.0)
rouge (>= 1.0.0)
- bigdecimal (4.0.1)
+ bigdecimal (4.1.2)
bindex (0.8.1)
binding_of_caller (2.0.0)
debug_inspector (>= 1.2.0)
@@ -143,20 +143,20 @@ GEM
railties (>= 5.0.0)
faker (3.6.1)
i18n (>= 1.8.11, < 2)
- faraday (2.14.1)
+ faraday (2.14.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-net_http (3.4.2)
net-http (~> 0.5)
- ffi (1.17.3-aarch64-linux-gnu)
- ffi (1.17.3-aarch64-linux-musl)
- ffi (1.17.3-arm-linux-gnu)
- ffi (1.17.3-arm-linux-musl)
- ffi (1.17.3-arm64-darwin)
- ffi (1.17.3-x86_64-darwin)
- ffi (1.17.3-x86_64-linux-gnu)
- ffi (1.17.3-x86_64-linux-musl)
+ ffi (1.17.4-aarch64-linux-gnu)
+ ffi (1.17.4-aarch64-linux-musl)
+ ffi (1.17.4-arm-linux-gnu)
+ ffi (1.17.4-arm-linux-musl)
+ ffi (1.17.4-arm64-darwin)
+ ffi (1.17.4-x86_64-darwin)
+ ffi (1.17.4-x86_64-linux-gnu)
+ ffi (1.17.4-x86_64-linux-musl)
geo_coord (0.2.0)
geocoder (1.8.6)
base64 (>= 0.1.0)
@@ -203,15 +203,13 @@ GEM
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
- json (2.19.2)
- json-schema (6.2.0)
- addressable (~> 2.8)
- bigdecimal (>= 3.1, < 5)
+ json (2.19.5)
jwt (3.1.2)
base64
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
- listen (3.9.0)
+ listen (3.10.0)
+ logger
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.7.0)
@@ -226,12 +224,10 @@ GEM
net-smtp
marcel (1.1.0)
matrix (0.4.3)
- mcp (0.9.0)
- json-schema (>= 4.1)
memory_profiler (1.1.0)
method_source (1.1.0)
mini_mime (1.1.5)
- minitest (6.0.2)
+ minitest (6.0.4)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
@@ -247,30 +243,30 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
- nokogiri (1.19.2-aarch64-linux-gnu)
+ nokogiri (1.19.3-aarch64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.2-aarch64-linux-musl)
+ nokogiri (1.19.3-aarch64-linux-musl)
racc (~> 1.4)
- nokogiri (1.19.2-arm-linux-gnu)
+ nokogiri (1.19.3-arm-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.2-arm-linux-musl)
+ nokogiri (1.19.3-arm-linux-musl)
racc (~> 1.4)
- nokogiri (1.19.2-arm64-darwin)
+ nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.19.2-x86_64-darwin)
+ nokogiri (1.19.3-x86_64-darwin)
racc (~> 1.4)
- nokogiri (1.19.2-x86_64-linux-gnu)
+ nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.2-x86_64-linux-musl)
+ nokogiri (1.19.3-x86_64-linux-musl)
racc (~> 1.4)
orm_adapter (0.5.0)
ostruct (0.6.3)
- pagy (43.4.2)
+ pagy (43.5.1)
json
uri
yaml
- parallel (1.27.0)
- parser (3.3.10.2)
+ parallel (2.0.1)
+ parser (3.3.11.1)
ast (~> 2.4.1)
racc
pg (1.6.3)
@@ -284,7 +280,7 @@ GEM
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
- propshaft (1.3.1)
+ propshaft (1.3.2)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
@@ -311,33 +307,33 @@ GEM
puma (7.2.0)
nio4r (~> 2.0)
racc (1.8.1)
- rack (3.2.5)
+ rack (3.2.6)
rack-cors (3.0.0)
logger
rack (>= 3.0.14)
rack-mini-profiler (4.0.1)
rack (>= 1.2.0)
- rack-session (2.1.1)
+ rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
- rails (8.1.2)
- actioncable (= 8.1.2)
- actionmailbox (= 8.1.2)
- actionmailer (= 8.1.2)
- actionpack (= 8.1.2)
- actiontext (= 8.1.2)
- actionview (= 8.1.2)
- activejob (= 8.1.2)
- activemodel (= 8.1.2)
- activerecord (= 8.1.2)
- activestorage (= 8.1.2)
- activesupport (= 8.1.2)
+ rails (8.1.3)
+ actioncable (= 8.1.3)
+ actionmailbox (= 8.1.3)
+ actionmailer (= 8.1.3)
+ actionpack (= 8.1.3)
+ actiontext (= 8.1.3)
+ actionview (= 8.1.3)
+ activejob (= 8.1.3)
+ activemodel (= 8.1.3)
+ activerecord (= 8.1.3)
+ activestorage (= 8.1.3)
+ activesupport (= 8.1.3)
bundler (>= 1.15.0)
- railties (= 8.1.2)
+ railties (= 8.1.3)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
@@ -349,9 +345,9 @@ GEM
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
- railties (8.1.2)
- actionpack (= 8.1.2)
- activesupport (= 8.1.2)
+ railties (8.1.3)
+ actionpack (= 8.1.3)
+ activesupport (= 8.1.3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -371,7 +367,7 @@ GEM
redis-client (>= 0.22.0)
redis-client (0.28.0)
connection_pool
- regexp_parser (2.11.3)
+ regexp_parser (2.12.0)
reline (0.6.3)
io-console (~> 0.5)
requestjs-rails (0.0.14)
@@ -397,12 +393,11 @@ GEM
rspec-mocks (>= 3.13.0, < 5.0.0)
rspec-support (>= 3.13.0, < 5.0.0)
rspec-support (3.13.7)
- rubocop (1.85.1)
+ rubocop (1.86.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
- mcp (~> 0.6)
- parallel (~> 1.10)
+ parallel (>= 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
@@ -480,11 +475,10 @@ GEM
concurrent-ruby (~> 1)
warden (1.2.9)
rack (>= 2.0.9)
- web-console (4.2.1)
- actionview (>= 6.0.0)
- activemodel (>= 6.0.0)
+ web-console (4.3.0)
+ actionview (>= 8.0.0)
bindex (>= 0.4.0)
- railties (>= 6.0.0)
+ railties (>= 8.0.0)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
@@ -519,7 +513,7 @@ DEPENDENCIES
dotenv-rails
factory_bot_rails (~> 6.4.3)
faker (~> 3.6)
- faraday (~> 2.14.0)
+ faraday (~> 2.14.2)
geo_coord
geocoder (~> 1.8)
haversine!
@@ -527,10 +521,10 @@ DEPENDENCIES
importmap-rails
inline_svg
jwt
- listen (~> 3.9.0)
+ listen (~> 3.10.0)
memory_profiler
ostruct
- pagy (~> 43.4)
+ pagy (~> 43.5)
pg (~> 1.6.2)
propshaft
pry (~> 0.16.0)
@@ -541,7 +535,7 @@ DEPENDENCIES
puma (~> 7.2)
rack-cors
rack-mini-profiler (~> 4.0)
- rails (~> 8.1.0)
+ rails (~> 8.1.3)
rails-controller-testing
redis (~> 5.4.1)
requestjs-rails
@@ -557,7 +551,7 @@ DEPENDENCIES
turbo-rails
tzinfo-data
view_component
- web-console (~> 4.2.1)
+ web-console (~> 4.3.0)
RUBY VERSION
ruby 3.4.5p51
diff --git a/app/components/facilities/discard_reason_component.rb b/app/components/facilities/discard_reason_component.rb
index e978c750..fbc6c2ff 100644
--- a/app/components/facilities/discard_reason_component.rb
+++ b/app/components/facilities/discard_reason_component.rb
@@ -4,9 +4,10 @@ class Facilities::DiscardReasonComponent < ViewComponent::Base
attr_reader :discard_reason
VALID_REASONS = {
- none: "None",
+ nil => "None",
closed: "Closed",
- duplicated: "Duplicated"
+ duplicated: "Duplicated",
+ sync_removed: "Removed by Sync"
}.freeze
def initialize(discard_reason)
@@ -20,7 +21,7 @@ def self.select_options
end
def call
- text = VALID_REASONS[discard_reason] || "Unsupported value '#{discard_reason}'"
+ text = VALID_REASONS[discard_reason.presence] || "Unsupported value '#{discard_reason}'"
tag.span(text)
end
end
diff --git a/app/components/facilities/show_component/details_card_component.html.erb b/app/components/facilities/show_component/details_card_component.html.erb
index 23f029d8..09420267 100644
--- a/app/components/facilities/show_component/details_card_component.html.erb
+++ b/app/components/facilities/show_component/details_card_component.html.erb
@@ -95,6 +95,10 @@
+
+ | External ID: |
+ <%= facility.external_id %> |
+
| Website: |
<%= link_to_website %> |
diff --git a/app/controllers/admin/facilities_controller.rb b/app/controllers/admin/facilities_controller.rb
index 1f64e6ec..72391b08 100644
--- a/app/controllers/admin/facilities_controller.rb
+++ b/app/controllers/admin/facilities_controller.rb
@@ -75,42 +75,55 @@ def switch_status
def load_facilities
facilities = Facility.all
+ facilities = filter_by_status(facilities)
+ facilities = filter_by_service(facilities)
+ facilities = filter_by_welcome(facilities)
+ facilities = filter_by_search(facilities)
+ facilities = facilities.with_associations.order(updated_at: :desc)
+ @pagy, @facilities = pagy(facilities)
+ end
+
+ def filter_by_status(facilities)
case params[:status]
- when "live"
- facilities = facilities.live
- when "pending_reviews"
- facilities = facilities.pending_reviews
- when "discarded"
- facilities = facilities.discarded
+ when "live" then facilities.live
+ when "pending_reviews" then facilities.pending_reviews
+ when "discarded" then facilities.discarded
+ else facilities
end
+ end
+ def filter_by_service(facilities)
if params[:service] == "none"
- facilities = facilities.without_services
+ facilities.without_services
elsif params[:service].present?
- facilities = facilities.with_service(params[:service])
+ facilities.with_service(params[:service])
+ else
+ facilities
end
+ end
+ def filter_by_welcome(facilities)
if params[:welcome_customer] == "none"
- facilities = facilities.without_welcomes
+ facilities.without_welcomes
elsif params[:welcome_customer].present?
- facilities = facilities.joins(:facility_welcomes)
- .where(facility_welcomes: { customer: params[:welcome_customer] })
+ facilities.joins(:facility_welcomes)
+ .where(facility_welcomes: { customer: params[:welcome_customer] })
+ else
+ facilities
end
+ end
+ def filter_by_search(facilities)
if params[:q].present?
- facilities = facilities.name_search(params[:q]).or(
- facilities.address_search(params[:q])
- )
+ facilities.name_search(params[:q]).or(facilities.address_search(params[:q]))
+ else
+ facilities
end
-
- facilities = facilities.order(updated_at: :asc)
-
- @pagy, @facilities = pagy(facilities)
end
def load_facility
- @facility = Facility.find(params[:id])
+ @facility = Facility.with_associations.find(params[:id])
end
def load_services_dropdown
diff --git a/app/controllers/admin/tools_controller.rb b/app/controllers/admin/tools_controller.rb
index 0448a6e1..b6db530b 100644
--- a/app/controllers/admin/tools_controller.rb
+++ b/app/controllers/admin/tools_controller.rb
@@ -8,7 +8,6 @@ def index; end
def import_facilities
api_key = params[:api]
- # Validate that both parameters are present and supported
unless External::ApiHelper.supported_api?(api_key)
redirect_to admin_tools_path, alert: "Invalid API selected. Please choose from the supported APIs."
return
@@ -16,23 +15,108 @@ def import_facilities
result = External::VancouverCity::Syncer.call(
api_key: api_key,
- api_client: External::VancouverCity.default_client
+ api_client: External::VancouverCity.default_client,
+ full_sync: true
)
- if result.success?
- total_count = result.data[:total_count] || 0
- redirect_to admin_facilities_path(service: "water_fountain"), notice: "#{total_count} Facilities imported successfully from #{External::ApiHelper.api_name(api_key)}."
+ result_data = collect_result_data(result)
+ handle_import_result(result, result_data, api_key)
+ end
+
+ def discard_facilities
+ successes = 0
+ errors = []
+ Facility.external.kept.find_each do |facility|
+ result = External::VancouverCity::FacilityDiscarder.call(facility)
+ if result.success?
+ successes += 1
+ else
+ Rails.logger.error "Failed to discard facility #{facility.id} (#{facility.name}): #{result.errors.join(', ')}"
+ errors << result.error_messages
+ end
+ end
+
+ handle_discard_result(successes, errors)
+ end
+
+ def handle_discard_result(successes, errors)
+ if errors.empty?
+ Rails.logger.info "Successfully discarded #{successes} external facilities."
+ redirect_to admin_facilities_path(service: "water_fountain"),
+ notice: "#{successes} external facilities discarded."
+ elsif successes.positive?
+ Rails.logger.warn "Partial discard: #{successes} facilities discarded, but #{errors.flatten.size} failed to discard with errors: #{errors.flatten.join('; ')}"
+ success_message = "#{successes} external facilities discarded."
+ failure_message = "However, #{errors.flatten.size} facilities failed to discard with errors: #{errors.flatten.join('; ')}"
+ redirect_to admin_tools_path(service: "water_fountain"),
+ alert: "#{success_message} #{failure_message}."
else
- error_messages = result.errors.join(", ")
- redirect_to admin_tools_path, alert: "Failed to import facilities: #{error_messages}"
+ Rails.logger.error "Failed to discard any facilities. Errors: #{errors.flatten.join('; ')}"
+ error_messages = errors.flatten.join("; ")
+ redirect_to admin_tools_path, alert: "Failed to discard #{errors.size} facilities: #{error_messages}"
end
end
+ SyncReport = Struct.new(
+ :create_operation, :update_operation, :delete_operation,
+ :success_count, :failure_count,
+ :success_created, :success_updated, :success_deleted,
+ :failure_created, :failure_updated, :failure_deleted,
+ :errors_messages,
+ keyword_init: true
+ )
+
# Helper method for the view
helper_method :api_options_for_select
private
+ def collect_result_data(sync_result)
+ create_op = sync_result.data.select { |e| e.operation == External::SyncOperations.create }
+ update_op = sync_result.data.select { |e| e.operation.name == "update" }
+ delete_op = sync_result.data.select { |e| e.operation == External::SyncOperations.discard }
+
+ SyncReport.new(
+ create_operation: create_op,
+ update_operation: update_op,
+ delete_operation: delete_op,
+ success_count: sync_result.data.count(&:success?),
+ failure_count: sync_result.data.count(&:failed?),
+ success_created: create_op.count(&:success?),
+ success_updated: update_op.count(&:success?),
+ success_deleted: delete_op.count(&:success?),
+ failure_created: create_op.count(&:failed?),
+ failure_updated: update_op.count(&:failed?),
+ failure_deleted: delete_op.count(&:failed?),
+ errors_messages: sync_result.errors.flatten
+ )
+ end
+
+ def handle_import_result(result, result_data, api_key)
+ if result.partial_failed?
+ handle_partial_failure(result_data, api_key)
+ elsif result.success?
+ handle_full_success(result_data, api_key)
+ else
+ handle_full_failure(result_data, api_key)
+ end
+ end
+
+ def handle_partial_failure(result_data, _api_key)
+ Rails.logger.warn "Partial sync: #{result_data.success_count} facilities synced successfully, but #{result_data.failure_count} failed with errors: #{result_data.errors_messages.join(', ')}"
+ redirect_to admin_tools_path, alert: "Sync completed with some errors: #{result_data.success_count} succeeded (#{result_data.success_created} created, #{result_data.success_updated} updated, #{result_data.success_deleted} deleted), but #{result_data.failure_count} failed (#{result_data.failure_created} create failures, #{result_data.failure_updated} update failures, #{result_data.failure_deleted} delete failures). Please check logs for details."
+ end
+
+ def handle_full_success(result_data, api_key)
+ Rails.logger.info "Successfully imported #{result_data.success_count} facilities (#{result_data.success_created} created, #{result_data.success_updated} updated, #{result_data.success_deleted} deleted) from #{api_key} API."
+ redirect_to admin_facilities_path(service: "water_fountain"), notice: "Successfully imported #{result_data.success_count} facilities (#{result_data.success_created} created, #{result_data.success_updated} updated, #{result_data.success_deleted} deleted) from #{api_key} API."
+ end
+
+ def handle_full_failure(result_data, api_key)
+ Rails.logger.error "Failed to sync facilities from #{api_key} API: #{result_data.errors_messages.join(', ')}"
+ redirect_to admin_tools_path, alert: "Failed to sync facilities from #{api_key} API: #{result_data.errors_messages.join(', ')}"
+ end
+
def api_options_for_select
External::ApiHelper.api_options
end
diff --git a/app/controllers/api/facilities_controller.rb b/app/controllers/api/facilities_controller.rb
index 885eaed7..cc77c144 100644
--- a/app/controllers/api/facilities_controller.rb
+++ b/app/controllers/api/facilities_controller.rb
@@ -65,12 +65,7 @@ def load_facilities
end
# Includes related objects to avoid N+1 queries
- @facilities = @facilities.includes(
- :zone,
- :facility_welcomes,
- { facility_services: [:service] },
- { schedules: [:time_slots] }
- )
+ @facilities = @facilities.with_associations
end
def register_impressions
diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js
index 5d960599..696e17b0 100644
--- a/app/javascript/controllers/index.js
+++ b/app/javascript/controllers/index.js
@@ -18,3 +18,9 @@ application.register("navigate", NavigateController)
import PagyController from "controllers/pagy_controller"
application.register("pagy", PagyController)
+
+import ProgressController from "controllers/progress_controller"
+application.register("progress", ProgressController)
+
+import TabsController from "controllers/tabs_controller"
+application.register("tabs", TabsController)
diff --git a/app/javascript/controllers/progress_controller.js b/app/javascript/controllers/progress_controller.js
new file mode 100644
index 00000000..951c9a77
--- /dev/null
+++ b/app/javascript/controllers/progress_controller.js
@@ -0,0 +1,36 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["indicator", "message"]
+
+ connect() {
+ console.log("Progress Controller Connected", this.element)
+ }
+
+ submit(event) {
+ const form = event.currentTarget
+ const button = form.querySelector('input[type="submit"], button[type="submit"]')
+ const operation = form.dataset.operation
+
+ if (this.hasMessageTarget) {
+ this.messageTarget.textContent = this.getMessage(operation)
+ }
+
+ if (button) {
+ button.classList.add("is-loading")
+ button.disabled = true
+ }
+
+ if (this.hasIndicatorTarget) {
+ this.indicatorTarget.classList.remove("is-hidden")
+ }
+ }
+
+ getMessage(operation) {
+ const messages = {
+ import: "Importing facilities from Vancouver City API...",
+ discard: "Discarding facilities from Vancouver City API..."
+ }
+ return messages[operation] || "Processing..."
+ }
+}
diff --git a/app/javascript/controllers/tabs_controller.js b/app/javascript/controllers/tabs_controller.js
new file mode 100644
index 00000000..8a031982
--- /dev/null
+++ b/app/javascript/controllers/tabs_controller.js
@@ -0,0 +1,52 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["link", "content"]
+
+ initialize() {
+ this.activeTab = this.element.dataset.activeTab || "sync"
+ }
+
+ connect() {
+ console.log("Tabs Controller Connected", this.element);
+
+ this.boundSwitchTab = this.switchTab.bind(this)
+ this.linkTargets.forEach((link) => {
+ link.addEventListener('click', this.boundSwitchTab)
+ })
+ this.activateTab(this.activeTab)
+ }
+
+ disconnect() {
+ this.linkTargets.forEach((link) => {
+ link.removeEventListener('click', this.boundSwitchTab)
+ })
+ }
+
+ switchTab(event) {
+ event.preventDefault()
+
+ const tab = event.currentTarget.dataset.tab
+ this.activateTab(tab)
+ }
+
+ activateTab(tab) {
+ this.linkTargets.forEach((link) => {
+ if (link.dataset.tab === tab) {
+ link.closest("li").classList.add("is-active")
+ } else {
+ link.closest("li").classList.remove("is-active")
+ }
+ })
+
+ this.contentTargets.forEach((content) => {
+ if (content.id === tab) {
+ content.classList.remove("is-hidden")
+ } else {
+ content.classList.add("is-hidden")
+ }
+ })
+
+ this.activeTab = tab
+ }
+}
diff --git a/app/jobs/facilities_static_generator_job.rb b/app/jobs/facilities_static_generator_job.rb
deleted file mode 100644
index d35bb852..00000000
--- a/app/jobs/facilities_static_generator_job.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-class FacilitiesStaticGeneratorJob
- def perform
- jsonfile = "public/facilities.json"
- facilities_hash = {
- v1: { facilities: Facility.is_verified.as_json }
- }
- File.write(jsonfile, JSON.pretty_generate(facilities_hash))
- end
-end
diff --git a/app/models/facility.rb b/app/models/facility.rb
index 450cc5e3..bb6328c9 100644
--- a/app/models/facility.rb
+++ b/app/models/facility.rb
@@ -10,19 +10,20 @@ class Facility < ApplicationRecord
belongs_to :user, optional: true
belongs_to :zone, optional: true
- has_many :facility_welcomes, dependent: :destroy
- has_many :facility_services, dependent: :destroy
+ has_many :facility_welcomes, dependent: :destroy, autosave: true
+ has_many :facility_services, dependent: :destroy, autosave: true
has_many :services, through: :facility_services
- has_many :schedules, class_name: "FacilitySchedule", dependent: :destroy
+ has_many :schedules, class_name: "FacilitySchedule", dependent: :destroy, autosave: true
has_many :time_slots, through: :schedules
enum :discard_reason, {
- none: nil,
closed: "closed",
- duplicated: "duplicated"
- }, prefix: true, default: :none
+ duplicated: "duplicated",
+ sync_removed: "sync_removed"
+ }, prefix: true, default: nil
validates :name, presence: true
+ validates :external_id, uniqueness: { allow_nil: true }
validate :validate_website
with_options if: :verified? do
@@ -42,23 +43,30 @@ class Facility < ApplicationRecord
scope :without_welcomes, -> { where.not(facility_welcomes: FacilityWelcome.all) }
scope :external, -> { where.not(external_id: nil) }
scope :not_external, -> { where(external_id: nil) }
+ scope :with_associations, lambda {
+ includes(
+ :zone,
+ :facility_welcomes,
+ { facility_services: [:service] },
+ { schedules: [:time_slots] }
+ )
+ }
+
+ def discard_reason_none?
+ discard_reason.nil?
+ end
def managed_by?(user_or_user_id)
- return false if user_or_user_id.blank?
+ f_user = if user_or_user_id.respond_to? :id
+ user_or_user_id
+ else
+ User.find_by(id: user_or_user_id)
+ end
- f_user_id = if user_or_user_id.respond_to? :id
- user_or_user_id.id
- else
- user_or_user_id
- end
+ return false if f_user.blank?
# Case Facility's User is the same
- return true if user_id == f_user_id
- # Case Zone of the Facility has the user as admin
- return true if User.find(f_user_id).manages.any?
-
- # Otherwise return FALSE
- false
+ user_id == f_user.id || f_user.manages.exists?(id: id)
end
def self.managed_by(user)
@@ -157,7 +165,7 @@ def clean_data
end
# handles discard
- self.discard_reason = :none if undiscarded?
+ self.discard_reason = nil if undiscarded?
end
def distance(to_coord: nil, to_lat: nil, to_long: nil, to_facility: nil)
diff --git a/app/models/facility_schedule.rb b/app/models/facility_schedule.rb
index 156e7495..1fe22e21 100644
--- a/app/models/facility_schedule.rb
+++ b/app/models/facility_schedule.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class FacilitySchedule < ApplicationRecord
- belongs_to :facility, touch: true
+ belongs_to :facility, touch: true, inverse_of: :schedules
has_many :time_slots, class_name: "FacilityTimeSlot", dependent: :destroy
SLOT_TIME_PRESENCE_ERROR = "must not be present if facility availability is %s all day for %s"
diff --git a/app/models/facility_service.rb b/app/models/facility_service.rb
index bfd3c59c..1d16a376 100644
--- a/app/models/facility_service.rb
+++ b/app/models/facility_service.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
class FacilityService < ApplicationRecord
- belongs_to :facility, touch: true
- belongs_to :service
+ belongs_to :facility, touch: true, inverse_of: :facility_services
+ belongs_to :service, inverse_of: :facility_services
validates :service, uniqueness: { scope: :facility }
diff --git a/app/models/facility_time_slot.rb b/app/models/facility_time_slot.rb
index daae60ea..680c898a 100644
--- a/app/models/facility_time_slot.rb
+++ b/app/models/facility_time_slot.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class FacilityTimeSlot < ApplicationRecord
- belongs_to :facility_schedule, touch: true
+ belongs_to :facility_schedule, touch: true, inverse_of: :time_slots
has_one :facility, through: :facility_schedule
validates :from_hour, :to_hour, presence: true,
diff --git a/app/models/facility_welcome.rb b/app/models/facility_welcome.rb
index b7ca2c6b..befa0293 100644
--- a/app/models/facility_welcome.rb
+++ b/app/models/facility_welcome.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class FacilityWelcome < ApplicationRecord
- belongs_to :facility, touch: true
+ belongs_to :facility, touch: true, inverse_of: :facility_welcomes
validates :customer, presence: true, uniqueness: { scope: :facility }
diff --git a/app/services/application_service.rb b/app/services/application_service.rb
index cb9d3869..b6bc76f5 100644
--- a/app/services/application_service.rb
+++ b/app/services/application_service.rb
@@ -31,6 +31,14 @@ def invalid?
!valid?
end
+ def success(data = {})
+ Result.new(data: data, errors: [])
+ end
+
+ def failure(errors)
+ Result.new(data: nil, errors: Array(errors))
+ end
+
private
def errors
diff --git a/app/services/external/sync_operations.rb b/app/services/external/sync_operations.rb
new file mode 100644
index 00000000..3fca0116
--- /dev/null
+++ b/app/services/external/sync_operations.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+class External::SyncOperations
+ class << self
+ def unexpected_error
+ @unexpected_error ||= UnexpectedError.new
+ end
+
+ def unknown
+ @unknown ||= Unknown.new
+ end
+
+ def api_call
+ @api_call ||= ApiCall.new
+ end
+
+ def create
+ @create ||= Create.new
+ end
+
+ def external_update
+ @external_update ||= ExternalUpdate.new
+ end
+
+ def internal_update
+ @internal_update ||= InternalUpdate.new
+ end
+
+ def discard
+ @discard ||= Discard.new
+ end
+ end
+
+ class Base
+ def name
+ throw NotImplementedError, "Subclasses must implement the name method"
+ end
+ end
+
+ class Create < Base
+ def name
+ "create"
+ end
+ end
+
+ class ExternalUpdate < Base
+ def name
+ "update"
+ end
+ end
+
+ class InternalUpdate < Base
+ def name
+ "update"
+ end
+ end
+
+ class Discard < Base
+ def name
+ "discard"
+ end
+ end
+
+ class ApiCall < Base
+ def name
+ "api call"
+ end
+ end
+
+ class Unknown < Base
+ def name
+ "unknown"
+ end
+ end
+
+ class UnexpectedError < Base
+ def name
+ "unexpected error"
+ end
+ end
+end
diff --git a/app/services/external/vancouver_city/facility_builder.rb b/app/services/external/vancouver_city/facility_builder.rb
index 8203373b..63b5cbed 100644
--- a/app/services/external/vancouver_city/facility_builder.rb
+++ b/app/services/external/vancouver_city/facility_builder.rb
@@ -3,7 +3,7 @@
# Service for building facility objects from Vancouver City Open Data API records
# Inherits from ApplicationService and handles record validation and error recovery
class External::VancouverCity::FacilityBuilder < ApplicationService
- attr_reader :record, :api_key
+ attr_reader :facility, :record, :api_key, :mapper
ResultData = Struct.new(:facility, keyword_init: true) do
def blank?
@@ -14,10 +14,12 @@ def blank?
# Initialize the builder with required parameters
# @param record [Hash] Single API response record
# @param api_key [String] One of the supported API keys from External::ApiHelper
- def initialize(record:, api_key:)
+ def initialize(facility:, record:, api_key:)
super()
+ @facility = facility
@record = record
@api_key = api_key
+ @mapper = ::External::VancouverCity::FacilityMapper.new(record)
end
# rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
@@ -26,36 +28,29 @@ def initialize(record:, api_key:)
def call
return Result.new(data: ResultData.new, errors: errors) if invalid?
- begin
- facility = build_facility_from_record
-
- # Build facility services
- service_builder = External::VancouverCity::FacilityServiceBuilder.new(facility: facility, fields: record, api_key: api_key)
- service_result = service_builder.call
- service_result.errors.each { |error| add_error(error) } unless service_result.success?
-
- # Build facility welcomes
- welcome_builder = External::VancouverCity::FacilityWelcomeBuilder.new(facility: facility, fields: record)
- welcome_result = welcome_builder.call
- welcome_result.errors.each { |error| add_error(error) } unless welcome_result.success?
-
- # Build facility schedules
- schedule_builder = External::VancouverCity::FacilityScheduleBuilder.new(facility: facility, fields: record)
- schedule_result = schedule_builder.call
- schedule_result.errors.each { |error| add_error(error) } unless schedule_result.success?
-
- if facility&.valid?
- Result.new(data: ResultData.new(facility: facility), errors: errors)
- else
- # rubocop:disable Style/SafeNavigationChainLength
- add_error("Facility #{facility&.name} is invalid: #{facility&.errors&.full_messages&.join(', ')}")
- # rubocop:enable Style/SafeNavigationChainLength
- Result.new(data: ResultData.new, errors: errors)
- end
- rescue StandardError => e
- add_error("Failed to build facility from record: #{e.message}")
- Rails.logger.warn "Failed to build facility from record: #{e.message}"
- Rails.logger.warn "Record data: #{record.inspect}"
+ facility.assign_attributes(facility_data_from_record)
+
+ # Build facility services
+ service_builder = ::External::VancouverCity::FacilityServiceBuilder.new(facility: facility, fields: record, api_key: api_key)
+ service_result = service_builder.call
+ service_result.errors.each { |error| add_error(error) } unless service_result.success?
+
+ # Build facility welcomes
+ welcome_builder = ::External::VancouverCity::FacilityWelcomeBuilder.new(facility: facility, fields: record)
+ welcome_result = welcome_builder.call
+ welcome_result.errors.each { |error| add_error(error) } unless welcome_result.success?
+
+ # Build facility schedules
+ schedule_builder = ::External::VancouverCity::FacilityScheduleBuilder.new(facility: facility, fields: record)
+ schedule_result = schedule_builder.call
+ schedule_result.errors.each { |error| add_error(error) } unless schedule_result.success?
+
+ if facility&.valid?
+ Result.new(data: ResultData.new(facility: facility), errors: errors)
+ else
+ # rubocop:disable Style/SafeNavigationChainLength
+ add_error("Facility '#{facility&.name}' is invalid: #{facility&.errors&.full_messages&.join(', ')}")
+ # rubocop:enable Style/SafeNavigationChainLength
Result.new(data: ResultData.new, errors: errors)
end
end
@@ -70,6 +65,8 @@ def validate
add_error("Record is required")
elsif !record.is_a?(Hash)
add_error("Record must be a Hash")
+ elsif mapper.external_id(api_key).blank?
+ add_error("Record is missing external_id for API key '#{api_key}'")
elsif !valid_geometry?
add_error("Geometry should be either Array with 2 elements or Hash with 'lat' and 'lon' keys")
end
@@ -77,102 +74,28 @@ def validate
private
+ def coords
+ mapper.coordinates.presence || mapper.geo_point_2d
+ end
+
def valid_geometry?
- coordinates.present? || geo_point_2d.present?
+ coords.present?
end
# Build a Facility object from an API record
# @param record [Hash] Single API response record
# @return [Facility, nil] Built Facility object or nil if invalid
- def build_facility_from_record
- coords = coordinates.presence || geo_point_2d
-
- facility_data = {
- name: extract_name(record),
- address: extract_address(record),
- phone: extract_phone(record),
- website: extract_website(record),
- notes: extract_notes(record),
- lat: coords[:lat],
- long: coords[:long],
+ def facility_data_from_record
+ {
+ name: mapper.name,
+ address: mapper.address,
+ phone: mapper.phone,
+ website: mapper.website,
+ notes: mapper.notes,
+ lat: coords.lat,
+ long: coords.long,
verified: true,
- external_id: record["mapid"] || "#{api_key}-unknown-id"
+ external_id: mapper.external_id(api_key)
}.compact
-
- Facility.new(facility_data)
- end
-
- # Extract facility name from fields
- # @param fields [Hash] API record fields
- # @return [String, nil] Facility name
- def extract_name(fields)
- name = fields["name"]
- return nil unless name
-
- # Replace special characters with whitespace and clean up
- name.gsub("\\n", " ").tr("\n", " ").gsub(/\s+/, " ").strip.presence
- end
-
- # Extract address from fields
- # @param fields [Hash] API record fields
- # @return [String, nil] Facility address
- def extract_address(fields)
- # For drinking fountains, use the location field and geo_local_area
- location = fields["location"]
- area = fields["geo_local_area"]
-
- [location, area].compact.join(", ").presence
- end
-
- # Extract phone number from fields
- # @param fields [Hash] API record fields
- # @return [String, nil] Phone number
- def extract_phone(fields)
- fields["phone"] || fields["phone_number"] || fields["contact_phone"]
- end
-
- # Extract website from fields
- # @param fields [Hash] API record fields
- # @return [String, nil] Website URL
- def extract_website(fields)
- fields["website"] || fields["url"] || fields["web_site"]
- end
-
- # Extract notes/description from fields
- # @param fields [Hash] API record fields
- # @return [String, nil] Notes or description
- def extract_notes(fields)
- notes_parts = []
-
- # Include maintainer info
- notes_parts << "Maintained by: #{fields['maintainer']}" if fields["maintainer"].present?
-
- # Include operation info
- notes_parts << "Operation: #{fields['in_operation']}" if fields["in_operation"].present?
-
- # Include pet friendly info
- notes_parts << "Pet friendly: #{fields['pet_friendly']}" if fields["pet_friendly"].present?
-
- notes_parts.join(". ").presence
- end
-
- # Extract coordinates from geometry
- # @return [Hash] Hash with :lat and :long keys
- def coordinates
- coords = record.dig("geom", "geometry", "coordinates").presence || []
- return {} unless coords.size == 2
-
- # GeoJSON coordinates are [longitude, latitude]
- { lat: coords[1], long: coords[0] }
- end
-
- # Extract coordinates from geo_point_2d field
- # @return [Hash] Hash with :lat and :long keys
- def geo_point_2d
- geo_point = record["geo_point_2d"].presence || {}
- return {} unless geo_point.is_a?(Hash)
- return {} unless geo_point.key?("lat") && geo_point.key?("lon")
-
- { lat: geo_point["lat"], long: geo_point["lon"] }
end
end
diff --git a/app/services/external/vancouver_city/facility_discarder.rb b/app/services/external/vancouver_city/facility_discarder.rb
new file mode 100644
index 00000000..b3d1d5a4
--- /dev/null
+++ b/app/services/external/vancouver_city/facility_discarder.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class External::VancouverCity::FacilityDiscarder < ApplicationService
+ attr_reader :facility
+
+ def initialize(facility)
+ @facility = facility
+ super()
+ end
+
+ def call
+ # Return early if the facility is already discarded
+ return build_result if facility.discarded?
+
+ facility.discard_reason = :sync_removed
+ unless facility.discard
+ # discard failed, collect errors
+ add_errors(facility.errors.full_messages)
+ end
+
+ build_result
+ end
+
+ private
+
+ # Builds the result object for this discard operation
+ def build_result
+ ::External::VancouverCity::Syncer::SyncResultDataEntry.new(
+ operation: External::SyncOperations.discard,
+ facility: facility,
+ errors: errors
+ )
+ end
+end
diff --git a/app/services/external/vancouver_city/facility_mapper.rb b/app/services/external/vancouver_city/facility_mapper.rb
new file mode 100644
index 00000000..51a1d8bf
--- /dev/null
+++ b/app/services/external/vancouver_city/facility_mapper.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+class External::VancouverCity::FacilityMapper < ApplicationService
+ attr_reader :record
+
+ def initialize(record)
+ super()
+ @record = record
+ end
+
+ # Extract external_id or fallback to a generated ID using the provided prefix
+ # @param record [Hash] API response record
+ # @param prefix [String] Prefix to use if mapid is not present
+ # @return [String] Generated external_id
+ def self.external_id(record, prefix = nil)
+ result = record["mapid"]
+ generated_id = nil
+ if result.blank? && prefix.present?
+ generated_id = "#{prefix}-#{name(record)}".parameterize
+ Rails.logger.warn "Record is missing 'mapid', generated external_id '#{generated_id}' using prefix '#{prefix}' and name '#{record['name']}'"
+ end
+
+ result.presence || generated_id
+ end
+
+ # Extract external_id
+ # @param prefix [String] Prefix to use if mapid is not present
+ # @return [String] Generated external_id
+ def external_id(prefix)
+ self.class.external_id(record, prefix)
+ end
+
+ # Extract facility name
+ # @return [String, nil] Facility name
+ def self.name(record)
+ name = record["name"].to_s
+ return nil if name.blank?
+
+ strip_special_chars(name)
+ end
+
+ # Replace special characters with whitespace and clean up
+ def self.strip_special_chars(value)
+ value.to_s.gsub("\\n", " ").tr("\n", " ").gsub(/\s+/, " ").strip.presence
+ end
+
+ # Extract facility name
+ # @return [String, nil] Facility name
+ def name
+ self.class.name(record)
+ end
+
+ # Extract address
+ # @return [String, nil] Facility address
+ def address
+ # For drinking fountains, use the location field and geo_local_area
+ location = self.class.strip_special_chars(record["location"])
+ area = self.class.strip_special_chars(record["geo_local_area"])
+
+ [location, area].compact.join(", ").presence
+ end
+
+ # Extract phone number
+ # @return [String, nil] Phone number
+ def phone
+ record["phone"] || record["phone_number"] || record["contact_phone"]
+ end
+
+ # Extract website
+ # @return [String, nil] Website URL
+ def website
+ record["website"] || record["url"] || record["web_site"]
+ end
+
+ # Extract notes/description
+ # @return [String, nil] Notes or description
+ def notes
+ notes_parts = []
+
+ # Include maintainer info
+ notes_parts << "Maintained by: #{record['maintainer']}" if record["maintainer"].present?
+
+ # Include operation info
+ notes_parts << "Operation: #{record['in_operation']}" if record["in_operation"].present?
+
+ # Include pet friendly info
+ notes_parts << "Pet friendly: #{record['pet_friendly']}" if record["pet_friendly"].present?
+
+ notes_parts.join(". ").presence
+ end
+
+ Coord = Struct.new(:lat, :long, keyword_init: true)
+
+ # Extract coordinates from geometry
+ # @return [Hash] Hash with :lat and :long keys
+ def coordinates
+ coords = record.dig("geom", "geometry", "coordinates").presence || []
+ return {} unless coords.size == 2
+
+ # GeoJSON coordinates are [longitude, latitude]
+ Coord.new(lat: coords[1], long: coords[0])
+ end
+
+ # Extract coordinates from geo_point_2d
+ # @return [Hash] Hash with :lat and :long keys
+ def geo_point_2d
+ geo_point = record["geo_point_2d"].presence || {}
+ return {} unless geo_point.is_a?(Hash)
+ return {} unless geo_point.key?("lat") && geo_point.key?("lon")
+
+ Coord.new(lat: geo_point["lat"], long: geo_point["lon"])
+ end
+end
diff --git a/app/services/external/vancouver_city/facility_schedule_builder.rb b/app/services/external/vancouver_city/facility_schedule_builder.rb
index 91adc6d0..3ec4e76e 100644
--- a/app/services/external/vancouver_city/facility_schedule_builder.rb
+++ b/app/services/external/vancouver_city/facility_schedule_builder.rb
@@ -53,7 +53,10 @@ def validate
# Add schedules to facility based on business requirements
# Creates open-all-day schedules for all weekdays
def add_facility_schedules
+ current_schedules = facility.schedules.index_by(&:week_day)
FacilitySchedule.week_days.each_key do |day|
+ next if current_schedules.key?(day)
+
facility.schedules.build(
week_day: day,
closed_all_day: false,
diff --git a/app/services/external/vancouver_city/facility_service_builder.rb b/app/services/external/vancouver_city/facility_service_builder.rb
index 5394b727..62ff5132 100644
--- a/app/services/external/vancouver_city/facility_service_builder.rb
+++ b/app/services/external/vancouver_city/facility_service_builder.rb
@@ -66,7 +66,13 @@ def add_facility_services
service = Service.find_by(key: service_key)
return if service.blank?
+ return if facility.facility_services.any? { |fs| fs.service == service }
+
# Build FacilityService association without saving
- facility.facility_services.build(service: service)
+ if facility.new_record?
+ facility.facility_services.build(service: service)
+ else
+ facility.facility_services.create!(service: service)
+ end
end
end
diff --git a/app/services/external/vancouver_city/facility_syncer.rb b/app/services/external/vancouver_city/facility_syncer.rb
index 1ffbd830..e6b96797 100644
--- a/app/services/external/vancouver_city/facility_syncer.rb
+++ b/app/services/external/vancouver_city/facility_syncer.rb
@@ -3,102 +3,72 @@
# Service for syncing facility data from Vancouver City Open Data API
# Inherits from ApplicationService and handles pagination to fetch all facilities
class External::VancouverCity::FacilitySyncer < ApplicationService
- attr_reader :record, :api_key, :logger
+ attr_reader :operation, :record, :api_key, :current, :log
- ResultData = Struct.new(:operation, :facility, keyword_init: true) do
- delegate :present?, :blank?, to: :facility
- end
-
- # rubocop:disable Lint/MissingSuper
- def initialize(record:, api_key:, logger: Rails.logger)
+ def initialize(operation:, record:, api_key:, current:, logger: Rails.logger)
+ @operation = operation
@record = record
+ @current = current
@api_key = api_key
- @logger = logger
+ @log = logger
+
+ super()
end
- # rubocop:enable Lint/MissingSuper
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
+ # rubocop:disable Metrics/AbcSize
def call
- builder_result = External::VancouverCity::FacilityBuilder.call(record: record, api_key: api_key)
+ external_id = ::External::VancouverCity::FacilityMapper.external_id(record)
+ log.info "Processing facility record with external_id '#{external_id}' and name '#{record['name']}' using operation '#{operation.name}'"
+ builder_result = External::VancouverCity::FacilityBuilder.call(facility: current || Facility.new, record: record, api_key: api_key)
if builder_result.failed?
+ log.warn "FacilityBuilder failed for record with external_id '#{external_id}': #{builder_result.errors.join(', ')}"
add_errors(builder_result.errors)
return Result.new(
- data: ResultData.new(operation: nil, facility: nil),
+ data: nil,
errors: errors
)
end
- built_facility = builder_result.data[:facility]
- existing_facility = Facility.find_by(external_id: built_facility.external_id)
-
- # If no external_id match, look for name match but prefer internal facilities
- if existing_facility.blank?
- existing_facility = Facility.where(name: built_facility.name)
- .order(Arel.sql("external_id IS NULL DESC, external_id"))
- .first
- end
- operation = if existing_facility.blank?
- :create
- elsif existing_facility.external?
- :external_update
- else
- :internal_update
- end
+ built_facility = builder_result.data.facility
result_facility = nil
ApplicationRecord.transaction do
case operation
- when :external_update
- logger.info "Facility with external_id '#{existing_facility.external_id}' already exists, updating services"
- update_external_facility(existing_facility, built_facility)
- result_facility = existing_facility
- when :internal_update
- logger.warn "Facility with name '#{existing_facility.name}' already exists internally, adding services"
- update_internal_facility(existing_facility, built_facility)
- result_facility = existing_facility
- when :create
- logger.info "Creating new facility with external_id '#{built_facility.external_id}'"
- if built_facility.invalid?
- add_errors(built_facility.errors)
- result_facility = nil
- else
- built_facility.save!
- result_facility = built_facility
- end
+ when External::SyncOperations::ExternalUpdate
+ log.info "Facility with external_id '#{built_facility.external_id}' already exists, updating services"
+ update_facility(built_facility)
+ result_facility = built_facility
+ when External::SyncOperations::InternalUpdate
+ log.warn "Facility with name '#{built_facility.name}' already exists internally, adding services"
+ update_facility(built_facility)
+ result_facility = built_facility
+ when External::SyncOperations::Create
+ log.info "Creating new facility with external_id '#{built_facility.external_id}'"
+ create_facility(built_facility)
+ result_facility = built_facility
+ else
+ throw ArgumentError.new("Unsupported operation: #{operation}")
end
rescue ActiveRecord::RecordInvalid => e
add_error("Failed to save facility: #{e.message}")
result_facility = nil
- rescue StandardError => e
- add_error("Unexpected error during facility sync: #{e.message}")
- result_facility = nil
end
Result.new(
- data: ResultData.new(operation: operation, facility: result_facility),
+ data: result_facility,
errors: errors
)
end
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
+ # rubocop:enable Metrics/AbcSize
private
- def update_internal_facility(internal_facility, built_facility)
- add_missing_services(internal_facility, built_facility)
+ def update_facility(facility)
+ facility.undiscard if facility.discarded?
+ facility.save!
end
- def update_external_facility(external_facility, built_facility)
- add_missing_services(external_facility, built_facility)
-
- external_facility.update!(built_facility.attributes.slice("name", "address", "lat", "long", "verified"))
- end
-
- def add_missing_services(existing_facility, built_facility)
- built_services = built_facility.facility_services.map(&:service).uniq
- existing_services = existing_facility.facility_services.map(&:service).uniq
- new_services = built_services - existing_services
- new_services.each do |service|
- existing_facility.facility_services.create!(service: service)
- end
+ def create_facility(facility)
+ facility.save!
end
end
diff --git a/app/services/external/vancouver_city/facility_welcome_builder.rb b/app/services/external/vancouver_city/facility_welcome_builder.rb
index d585133b..50e58af8 100644
--- a/app/services/external/vancouver_city/facility_welcome_builder.rb
+++ b/app/services/external/vancouver_city/facility_welcome_builder.rb
@@ -55,7 +55,17 @@ def add_facility_welcomes
welcomes = FacilityWelcome.all_customers
welcomes.each do |customer_type|
+ add_welcomes(customer_type)
+ end
+ end
+
+ def add_welcomes(customer_type)
+ return if facility.facility_welcomes.any? { |fw| fw.customer == customer_type.value }
+
+ if facility.new_record?
facility.facility_welcomes.build(customer: customer_type.value)
+ else
+ facility.facility_welcomes.create!(customer: customer_type.value)
end
end
end
diff --git a/app/services/external/vancouver_city/syncer.rb b/app/services/external/vancouver_city/syncer.rb
index b8591faa..8600db53 100644
--- a/app/services/external/vancouver_city/syncer.rb
+++ b/app/services/external/vancouver_city/syncer.rb
@@ -3,25 +3,114 @@
# Service for syncing facility data from Vancouver City Open Data API
# Inherits from ApplicationService and handles pagination to fetch all facilities
class External::VancouverCity::Syncer < ApplicationService
- attr_reader :api_key, :api_client
+ attr_reader :api_key, :api_client, :full_sync
PAGE_SIZE = 50 # Maximum records per request allowed by the API
+ SyncResultDataEntry = Struct.new(:operation, :facility, :errors, keyword_init: true) do
+ def success?
+ errors.blank?
+ end
+
+ def failed?
+ errors.present?
+ end
+
+ # Returns a human-readable error message for this sync result entry
+ def error_messages
+ return [] if success?
+ return "#{operation.name} failed: #{errors.join(', ')}" if facility.blank?
+
+ "#{operation.name} failed for facility '#{facility.name}': #{errors.join(', ')}"
+ end
+ end
+
+ SyncResult = Struct.new(:result, keyword_init: true) do
+ delegate :failed?, :success?, :data, :errors, to: :result
+
+ def partial_failed?
+ result_errors.flatten.empty? && entry_error_messages.flatten.any?
+ end
+
+ def error_messages
+ (result_errors + entry_error_messages).flatten.compact.uniq
+ end
+
+ private
+
+ def result_errors
+ result.errors.presence || []
+ end
+
+ def entry_error_messages
+ data.presence&.map(&:error_messages) || []
+ end
+ end
# Initialize the syncer with required parameters
# @param api_key [String] One of the supported API keys from External::ApiHelper
# @param api_client [VancouverApiClient] The API client instance
- def initialize(api_key:, api_client:)
+ # @param full_sync [Boolean] Whether to perform a full sync (discard missing facilities)
+ def initialize(api_key:, api_client:, full_sync: true)
super()
@api_key = api_key
@api_client = api_client
+ @full_sync = full_sync
end
# Main method that performs the sync operation
# @return [ApplicationService::Result] Result object with data and errors
def call
- return Result.new(data: nil, errors: errors) if invalid?
+ return build_result([], errors) if invalid?
+
+ Rails.logger.info "Starting sync for #{api_key} API (full_sync: #{full_sync})"
+
+ sync_results = sync_facilities_from_api
+ synced_external_ids = sync_results
+ .select(&:success?)
+ .map { |result| result.facility.external_id }
+ success_count = synced_external_ids.size
+ failed_count = sync_results.size - success_count
+
+ discard_results = discard_missing_facilities(synced_external_ids)
+ discard_success_count = discard_results.count { |result| result.errors.blank? }
+ discard_failed_count = discard_results.size - discard_success_count
+
+ Rails.logger.info "Finished sync for #{api_key} API: #{success_count} succeeded, #{failed_count} failed, #{discard_success_count} discarded, #{discard_failed_count} failed to discard"
+
+ build_result(sync_results + discard_results, errors)
+ end
+
+ # Validates the input parameters
+ # @return [Array] Array of error messages
+ def validate
+ @errors = []
+
+ add_error("Unsupported API: #{api_key}") unless External::ApiHelper.supported_api?(api_key)
+
+ if api_client.nil?
+ add_error("API client is required")
+ elsif !api_client.is_a?(External::VancouverCity::VancouverApiClient)
+ add_error("API client must be an instance of VancouverApiClient")
+ end
+
+ errors
+ end
+
+ private
- facilities = []
+ def build_result(result_data, result_errors)
+ SyncResult.new(
+ result: Result.new(
+ data: result_data,
+ errors: result_errors
+ )
+ )
+ end
+
+ # Syncs facilities from the API with pagination
+ # @return [Array] Array containing facilities, synced_external_ids, created_count, updated_count
+ def sync_facilities_from_api
+ sync_results = []
offset = 0
loop do
@@ -34,68 +123,106 @@ def call
break if records.empty?
# Process each record and build Facility objects
- batch_facilities = process_records(records)
- facilities.concat(batch_facilities)
+ batch_results = process_records_with_operations(records)
+ sync_results += batch_results
# If we got fewer records than the limit, we've reached the end
break if records.size < PAGE_SIZE
offset += PAGE_SIZE
rescue External::VancouverCity::VancouverApiError => e
- add_error("API request failed: #{e.message}")
+ sync_results << SyncResultDataEntry.new(
+ operation: External::SyncOperations.api_call,
+ facility: nil,
+ errors: ["API request failed: #{e.message}"]
+ )
break
rescue StandardError => e
- add_error("Unexpected error during sync: #{e.message}")
+ sync_results << SyncResultDataEntry.new(
+ operation: External::SyncOperations.unexpected_error,
+ facility: nil,
+ errors: ["Unexpected error during sync; #{e.class}: #{e.message}"]
+ )
+ # raise
break
end
end
- Rails.logger.info "Successfully processed #{facilities.size} facilities from #{api_key} API"
-
- Result.new(
- data: {
- facilities: facilities,
- total_count: facilities.size,
- api_key: api_key
- },
- errors: errors
- )
+ sync_results
end
- # Validates the input parameters
- # @return [Array] Array of error messages
- def validate
- @errors = []
-
- add_error("Unsupported API: #{api_key}") unless External::ApiHelper.supported_api?(api_key)
+ # Process API records and return ResultData objects with operations
+ # @param records [Array] Array of API response records
+ # @return [Array] Array of result data objects
+ def process_records_with_operations(records)
+ external_ids = records.filter_map { |record| ::External::VancouverCity::FacilityMapper.external_id(record) }
+ names = records.filter_map { |record| ::External::VancouverCity::FacilityMapper.name(record) }
+ existing_facilities = Facility.with_associations
+ .with_discarded
+ .where(external_id: external_ids)
+ .to_a
+ existing_facilities_by_name = Facility.with_associations
+ .with_discarded
+ .where(name: names)
+ .to_a
+
+ records.map do |record|
+ process_single_record(record.with_indifferent_access, existing_facilities, existing_facilities_by_name)
+ end
+ end
- if api_client.nil?
- add_error("API client is required")
- elsif !api_client.is_a?(External::VancouverCity::VancouverApiClient)
- add_error("API client must be an instance of VancouverApiClient")
+ def process_single_record(record, existing_facilities, existing_facilities_by_name)
+ external_id = ::External::VancouverCity::FacilityMapper.external_id(record)
+ if external_id.nil?
+ return SyncResultDataEntry.new(
+ operation: External::SyncOperations.unknown,
+ facility: nil,
+ errors: ["Missing external_id for record with name '#{record['name']}'"]
+ )
end
- errors
+ current_facility = existing_facilities.find { |f| f.external_id == external_id } ||
+ existing_facilities_by_name.find { |f| f.name == ::External::VancouverCity::FacilityMapper.name(record) }
+ operation = if current_facility.blank?
+ External::SyncOperations.create
+ elsif current_facility.external?
+ External::SyncOperations.external_update
+ else
+ External::SyncOperations.internal_update
+ end
+
+ syncer_result = External::VancouverCity::FacilitySyncer.call(
+ operation: operation,
+ record: record,
+ current: current_facility,
+ api_key: api_key,
+ logger: Rails.logger
+ )
+ SyncResultDataEntry.new(
+ operation: operation,
+ facility: syncer_result.data,
+ errors: syncer_result.errors
+ )
end
- private
+ # Discard facilities that were not in the API response (full sync only)
+ # @param synced_external_ids [Array] Array of external_ids that were in the response
+ # @return [Integer] Number of facilities discarded
+ def discard_missing_facilities(synced_external_ids)
+ return [] unless full_sync
- # Process API records and convert them to Facility objects
- # @param records [Array] Array of API response records
- # @return [Array] Array of built Facility objects
- def process_records(records)
- facilities = []
+ results = []
+ External::SyncOperations.discard
- records.each do |record|
- syncer_result = External::VancouverCity::FacilitySyncer.call(record: record, api_key: api_key)
+ # Only discard facilities that are currently kept
+ # (not already discarded with deleted_at set)
+ missing_facilities = Facility.external.kept
+ .where.not(external_id: synced_external_ids)
- if syncer_result.success?
- facilities << syncer_result.data[:facility]
- else
- add_errors(syncer_result.errors)
- end
+ missing_facilities.find_each do |facility|
+ results << External::VancouverCity::FacilityDiscarder.call(facility)
end
- facilities
+ results
end
end
diff --git a/app/views/admin/tools/index.html.erb b/app/views/admin/tools/index.html.erb
index 72f3707e..20078bfa 100644
--- a/app/views/admin/tools/index.html.erb
+++ b/app/views/admin/tools/index.html.erb
@@ -10,13 +10,32 @@
-
-
- <%= render Shared::CardComponent.new(title: "Vancouver City API") do |card| %>
+
Vancouver City API
+
+
+
+
+
+
+
+ <%= render Shared::CardComponent.new(title: "Import Facilities") do |card| %>
Choose which facilities to import from Vancouver City's Open Data API.
- <%= form_with url: import_facilities_admin_tools_path, method: :post, class: "form", id: "import-form" do |form| %>
+ <%= form_with url: import_facilities_admin_tools_path, method: :post, class: "form", id: "import-form", data: { action: "progress#submit", operation: "import" } do |form| %>
<%= form.label :api, "API Endpoint", class: "label" %>
@@ -31,48 +50,66 @@
- <%= form.submit "Import Facilities", class: "button is-primary", id: "import-button", data: { disable_with: "Importing..." } %>
+ <%= form.submit "Import Facilities",
+ class: "button is-primary",
+ id: "import-button",
+ data: { disable_with: "Importing..." } %>
+
+
Note: Import performs a full sync, which removes any facilities that no longer exist in the API.
+
<% end %>
+
+ <% end %>
+
-
-
-
-
-
-
-
-
-
Importing facilities from Vancouver City API...
+
+ <%= render Shared::CardComponent.new(title: "Discard Facilities") do |card| %>
+
+
Remove all water fountains from the database. This action cannot be undone.
+
+ <%= form_with url: discard_facilities_admin_tools_path, method: :delete, class: "form", id: "discard-form", data: { action: "progress#submit", operation: "discard" } do |form| %>
+
+
+
+ <%= form.select :api,
+ options_for_select(api_options_for_select),
+ { include_blank: 'Select an API...' },
+ { class: "select", required: true } %>
-
-
+
+
+
+ <%= form.submit "Discard All Facilities",
+ class: "button is-danger",
+ id: "discard-button",
+ data: {
+ confirm: "Are you sure you want to remove ALL facilities for the selected API? This action cannot be undone.",
+ disable_with: "Discarding..."
+ } %>
+
+
+ <% end %>
<% end %>
+
+
+
+
+
+
+
+
+
+ Processing...
+
+
+
+
+
-
-
diff --git a/config/importmap.rb b/config/importmap.rb
index d7a52f25..749a0395 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -21,6 +21,8 @@
pin "controllers/modal_controller"
pin "controllers/navigate_controller"
pin "controllers/pagy_controller"
+pin "controllers/tabs_controller"
+pin "controllers/progress_controller"
# Pin local JavaScript modules individually
pin "src/richtext"
diff --git a/config/routes.rb b/config/routes.rb
index 9c1ff99e..ec306863 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -34,6 +34,7 @@
resources :tools do
collection do
post :import_facilities
+ delete :discard_facilities
end
end
diff --git a/db/migrate/20260412182834_change_external_id_to_unique.rb b/db/migrate/20260412182834_change_external_id_to_unique.rb
new file mode 100644
index 00000000..2ad3deab
--- /dev/null
+++ b/db/migrate/20260412182834_change_external_id_to_unique.rb
@@ -0,0 +1,35 @@
+class ChangeExternalIdToUnique < ActiveRecord::Migration[8.1]
+ def up
+ # Handle duplicates by making them unique first (append "-dup-{id}")
+ # This preserves all data including facility_services associations
+ duplicate_ids = Facility.external
+ .group(:external_id)
+ .having("COUNT(*) > 1")
+ .pluck(:external_id)
+
+ # By updating older records first, we ensure that the most recently
+ # updated record retains the original external_id, which is likely
+ # the most accurate
+ external_facilities = Facility.external
+ .where(external_id: duplicate_ids)
+ .order(updated_at: :asc)
+ .to_a
+ external_facilities.each do |facility|
+ # Check if there are still duplicates after we've modified some
+ still_duplicates = Facility.external
+ .where(external_id: facility.external_id)
+ .count
+ next unless still_duplicates > 1
+
+ # Update this record's external_id to be unique
+ new_external_id = "#{facility.external_id}-dup-#{facility.id}"
+ facility.update!(external_id: new_external_id)
+ end
+
+ add_index :facilities, :external_id, unique: true, where: "external_id IS NOT NULL"
+ end
+
+ def down
+ remove_index :facilities, :external_id
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 8e9177ce..6c811657 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2025_06_30_180209) do
+ActiveRecord::Schema[8.1].define(version: 2026_04_12_182834) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -90,6 +90,7 @@
t.boolean "verified", default: false
t.string "website"
t.integer "zone_id"
+ t.index ["external_id"], name: "index_facilities_on_external_id", unique: true, where: "(external_id IS NOT NULL)"
t.index ["user_id"], name: "index_facilities_on_user_id"
t.index ["zone_id"], name: "index_facilities_on_zone_id"
end
diff --git a/docs/plans/README.md b/docs/plans/README.md
index b1434bb7..fcc310e7 100644
--- a/docs/plans/README.md
+++ b/docs/plans/README.md
@@ -12,3 +12,4 @@ Implementation plans for Linkvan API development.
| [Rails 8.1 Upgrade](rails-81-upgrade/plan.md) | Complete | 22/22 (100%) | 2026-03-15 |
| [RuboCop Remediation](rubocop-remediation/plan.md) | Complete | 64/64 (100%) | 2026-03-14 |
| [Test Coverage Implementation](test-coverage-implementation/plan.md) | Complete | 24/24 (100%) | 2026-01-26 |
+| [Vancouver Water Fountain Sync](vancouver-water-fountain-sync/plan.md) | Not Started | 0/10 (0%) | 2026-03-21 |
diff --git a/docs/plans/vancouver-water-fountain-sync/plan.md b/docs/plans/vancouver-water-fountain-sync/plan.md
new file mode 100644
index 00000000..b0c7a0f8
--- /dev/null
+++ b/docs/plans/vancouver-water-fountain-sync/plan.md
@@ -0,0 +1,242 @@
+# Vancouver City Water Fountain Sync Enhancement
+
+## Status: COMPLETED
+
+## Created: 2026-03-21
+
+## Goal
+
+Enhance the Vancouver City API water fountain sync feature with two new capabilities:
+1. **Full Sync**: Remove facilities not present in the API (soft-delete with `sync_removed` reason)
+2. **Discard All**: Add ability to remove all water fountains via admin action
+3. **Undelete Support**: Sync should undelete and update facilities that were previously soft-deleted but now appear in the API
+
+## Current State
+
+- `External::VancouverCity::Syncer` fetches facilities from Vancouver Open Data API
+- `External::VancouverCity::FacilitySyncer` processes each record (create/update by external_id or name match)
+- Facilities with `external_id` are tracked as "external" facilities
+- No mechanism to remove facilities not present in the API
+- Discarded (soft-deleted) facilities are not considered during sync
+
+## Target State
+
+- **Full Sync**: After fetching all records, any external facility for that API not in the response will be soft-deleted with `discard_reason: "sync_removed"` (enabled by default)
+- **Undelete**: Discarded facilities matching API records will be undeleted and updated
+- **Discard All**: New admin action to soft-delete all water fountains for a given API
+- **Enhanced Result**: `Syncer.call` result includes `created_count`, `updated_count`, `deleted_count`
+
+## Analysis Summary
+
+### Key Changes Required
+
+1. **FacilitySyncer** - Modify to use `Facility.with_discarded` queries and call `undiscard` before updates
+2. **Syncer** - Add `full_sync:` parameter (default `true`), track synced IDs, discard missing facilities
+3. **DiscardService** - New service to discard all facilities for an API
+4. **Admin Tools Controller** - Add `discard_facilities` action (DELETE method)
+5. **Routes** - Add DELETE route for discard action
+6. **Views** - Add checkbox for full sync and "Discard All" button
+
+### Discard Reason
+
+`"sync_removed"` - Indicates facility was removed during API synchronization
+
+### Dependencies
+
+- Rails `discard` gem (already in use via `Discardable` module)
+- `External::ApiHelper` for API key validation and mapping
+
+### Breaking Changes
+
+- None - Full sync is enabled by default, but can be disabled via `full_sync: false` for incremental sync
+
+---
+
+## Priority System
+
+- **CRITICAL** - Must complete for success
+- **HIGH** - Should complete for full functionality
+- **MEDIUM** - Recommended for best UX
+- **LOW** - Optional improvements
+
+---
+
+## Implementation Stages
+
+### Stage 1: Full Sync with Deletion + Undelete Support
+
+**Focus:** Modify FacilitySyncer and Syncer to support full sync with deletion and undelete
+
+#### 1.1 Add Tests for Facility Undeletion
+- **Priority:** CRITICAL
+- **Type:** Test
+- **Location:** `spec/services/external/vancouver_city/facility_syncer/undelete_facility_spec.rb`
+- **Description:** Add tests for undeleting discarded facilities during sync
+- **Tests:**
+ - Discarded facility with matching `external_id` → undeleted and updated
+ - Discarded facility with matching name (internal update) → undeleted and services added
+ - Verify `undiscard` is called before update operations
+
+#### 1.2 Update FacilitySyncer to Handle Discarded Facilities
+- **Priority:** CRITICAL
+- **Type:** Code Fix
+- **Location:** `app/services/external/vancouver_city/facility_syncer.rb`
+- **Description:** Modify queries to use `with_discarded` and add undiscard logic
+- **Changes:**
+ - Line 32: `Facility.find_by(external_id: ...)` → `Facility.with_discarded.find_by(external_id: ...)`
+ - Line 36: Name-match query also use `with_discarded`
+ - Add `facility.undiscard` before `update_external_facility` and `update_internal_facility`
+
+#### 1.3 Add Tests for Full Sync Deletion
+- **Priority:** CRITICAL
+- **Type:** Test
+- **Location:** `spec/services/external/vancouver_city/syncer_spec.rb`
+- **Description:** Add tests for `full_sync` option
+- **Tests:**
+ - `full_sync: true` (default) → orphaned facilities discarded with `"sync_removed"`
+ - `full_sync: false` → no deletion (facilities kept even if missing from API)
+ - Result includes `created_count`, `updated_count`, `deleted_count`
+
+#### 1.4 Implement Full Sync Deletion Logic
+- **Priority:** CRITICAL
+- **Type:** Code Fix
+- **Location:** `app/services/external/vancouver_city/syncer.rb`
+- **Description:** Add `full_sync` parameter and deletion logic
+- **Changes:**
+ - Add `full_sync:` boolean parameter (default `true`) to `initialize`
+ - Track fetched `external_id`s during `call`
+ - After processing all records, if `full_sync: true`: discard facilities not in response
+
+#### 1.5 Enhance Result with Counts
+- **Priority:** HIGH
+- **Type:** Code Fix
+- **Location:** `app/services/external/vancouver_city/syncer.rb`
+- **Description:** Add operation counts to result data
+- **Changes:** Result data includes:
+ ```ruby
+ {
+ facilities: facilities,
+ total_count: facilities.size,
+ created_count:
,
+ updated_count: ,
+ deleted_count: ,
+ api_key: api_key
+ }
+ ```
+
+---
+
+### Stage 2: Discard All Water Fountains
+
+**Focus:** Add ability to remove all water fountains for an API
+
+#### 2.1 Add Tests for Discard Service
+- **Priority:** CRITICAL
+- **Type:** Test
+- **Location:** `spec/services/external/vancouver_city/discard_service_spec.rb`
+- **Description:** Add tests for discard service
+- **Tests:**
+ - Discards all external facilities for `api_key`
+ - Discards with `discard_reason: "sync_removed"`
+ - Returns `discarded_count`
+ - Validates `api_key` is supported
+
+#### 2.2 Implement Discard Service
+- **Priority:** CRITICAL
+- **Type:** Code Fix
+- **Location:** `app/services/external/vancouver_city/discard_service.rb`
+- **Description:** Create new service to discard all facilities for an API
+- **Implementation:** Find all external facilities for the service, discard each with `sync_removed` reason
+
+#### 2.3 Add Tests for Discard Controller Action
+- **Priority:** CRITICAL
+- **Type:** Test
+- **Location:** `spec/controllers/admin/tools_controller_spec.rb`
+- **Description:** Add tests for discard action
+- **Tests:**
+ - `DELETE /admin/tools/discard_facilities?api=drinking-fountains`
+ - Admin only; non-admins redirect with access denied
+ - Success redirects with notice showing count
+ - Invalid `api_key` returns alert
+
+#### 2.4 Implement Discard Action + Route
+- **Priority:** CRITICAL
+- **Type:** Code Fix
+- **Location:** `app/controllers/admin/tools_controller.rb`, `config/routes.rb`
+- **Description:** Add discard_facilities action and DELETE route
+- **Changes:**
+ - Add `discard_facilities` action (DELETE method)
+ - Add route: `delete :discard_facilities, to: 'admin/tools#discard_facilities'`
+
+---
+
+### Stage 3: Update Admin Tools View
+
+#### 3.1 Update Admin Tools View
+- **Priority:** HIGH
+- **Type:** Code Fix
+- **Location:** `app/views/admin/tools/index.html.*`
+- **Description:** Add UI for full sync and discard options
+- **Changes:**
+ - Remove checkbox (full sync is now the default behavior)
+ - Add "Discard All" button with confirmation dialog → `DELETE /admin/tools/discard_facilities?api=drinking-fountains`
+
+---
+
+## Quality Checks
+
+### Stage 1 Completion Criteria
+- [ ] Undeletion tests pass (1.1)
+- [ ] FacilitySyncer updated with undelete logic (1.2)
+- [ ] Deletion tests pass (1.3)
+- [ ] Full sync deletion logic implemented (1.4)
+- [ ] Result enhanced with counts (1.5)
+- [ ] Run `bin/rspec spec/services/external/vancouver_city/`
+
+### Stage 2 Completion Criteria
+- [ ] Discard service tests pass (2.1)
+- [ ] Discard service implemented (2.2)
+- [ ] Discard controller tests pass (2.3)
+- [ ] Discard action and route implemented (2.4)
+- [ ] Run `bin/rspec spec/controllers/admin/tools_controller_spec.rb`
+
+### Stage 3 Completion Criteria
+- [ ] View updated with checkbox (3.1)
+- [ ] Manual test: Verify UI elements work correctly
+
+### Overall Completion Criteria
+- [ ] All tests pass
+- [ ] `bin/rubocop` passes
+- [ ] Manual verification of full sync and discard features
+
+---
+
+## Rollback Plan
+
+If issues occur:
+1. Revert `app/services/external/vancouver_city/syncer.rb` - removes `full_sync` and deletion logic
+2. Revert `app/services/external/vancouver_city/facility_syncer.rb` - removes `with_discarded` and undelete logic
+3. Revert `app/controllers/admin/tools_controller.rb` - removes `discard_facilities` action
+4. Revert `config/routes.rb` - removes discard route
+5. Delete new files: `discard_service.rb`, `undelete_facility_spec.rb`
+
+---
+
+## Estimated Time
+
+| Stage | Tasks | Time |
+|-------|-------|------|
+| 1 | 5 | 45 min |
+| 2 | 4 | 30 min |
+| 3 | 1 | 10 min |
+| Total | 10 | ~85 min |
+
+---
+
+## Related Documentation
+
+- [Vancouver City API Syncer](../../app/services/external/vancouver_city/syncer.rb)
+- [Vancouver City FacilitySyncer](../../app/services/external/vancouver_city/facility_syncer.rb)
+- [AGENTS.md](../../AGENTS.md)
+- [Rails Migrations Skill](../../.opencode/skills/rails-migrations/SKILL.md)
+- [RSpec Testing Skill](../../.opencode/skills/rspec-testing/SKILL.md)
diff --git a/docs/plans/vancouver-water-fountain-sync/tracker.md b/docs/plans/vancouver-water-fountain-sync/tracker.md
new file mode 100644
index 00000000..2bc6ed0d
--- /dev/null
+++ b/docs/plans/vancouver-water-fountain-sync/tracker.md
@@ -0,0 +1,151 @@
+# Vancouver City Water Fountain Sync Enhancement Tracker
+
+## Plan Reference
+
+[plan.md](plan.md)
+
+---
+
+## Created: 2026-03-21
+## Last Updated: 2026-04-11
+
+---
+
+## Summary
+
+| Priority | Total | Not Started | In Progress | Completed | Blocked |
+|----------|-------|-------------|-------------|-----------|---------|
+| CRITICAL | 8 | 0 | 0 | 8 | 0 |
+| HIGH | 2 | 0 | 0 | 2 | 0 |
+| **TOTAL**| **10**| **0** | **0** | **10** | **0** |
+
+---
+
+## Stage 1: Full Sync with Deletion + Undelete Support
+
+### Item Tables
+
+#### 1.1 - Add Tests for Facility Undeletion
+
+| ID | Priority | Status | File | Notes |
+|----|----------|--------|------|-------|
+| 1.1 | CRITICAL | ✅ Completed | spec/.../facility_syncer/undelete_facility_spec.rb | 12 tests - all passing |
+
+#### 1.2 - Update FacilitySyncer to Handle Discarded Facilities
+
+| ID | Priority | Status | File | Notes |
+|----|----------|--------|------|-------|
+| 1.2 | CRITICAL | ✅ Completed | app/.../facility_syncer.rb | Uses with_discarded + undiscard |
+
+#### 1.3 - Add Tests for Full Sync Deletion
+
+| ID | Priority | Status | File | Notes |
+|----|----------|--------|------|-------|
+| 1.3 | CRITICAL | ✅ Completed | spec/.../syncer_spec.rb | Tests for full_sync option |
+
+#### 1.4 - Implement Full Sync Deletion Logic
+
+| ID | Priority | Status | File | Notes |
+|----|----------|--------|------|-------|
+| 1.4 | CRITICAL | ✅ Completed | app/.../syncer.rb | full_sync param + deletion |
+
+#### 1.5 - Enhance Result with Counts
+
+| ID | Priority | Status | File | Notes |
+|----|----------|--------|------|-------|
+| 1.5 | HIGH | ✅ Completed | app/.../syncer.rb | created/updated/deleted counts |
+
+---
+
+## Stage 2: Discard All Water Fountains
+
+### Item Tables
+
+#### 2.1 - Add Tests for Discard Service
+
+| ID | Priority | Status | File | Notes |
+|----|----------|--------|------|-------|
+| 2.1 | CRITICAL | ✅ Completed | spec/.../discard_service_spec.rb | 7 tests - all passing |
+
+#### 2.2 - Implement Discard Service
+
+| ID | Priority | Status | File | Notes |
+|----|----------|--------|------|-------|
+| 2.2 | CRITICAL | ✅ Completed | app/.../discard_service.rb | New service file |
+
+#### 2.3 - Add Tests for Discard Controller Action
+
+| ID | Priority | Status | File | Notes |
+|----|----------|--------|------|-------|
+| 2.3 | CRITICAL | ✅ Completed | spec/controllers/admin/tools_controller_spec.rb | 11 tests - all passing |
+
+#### 2.4 - Implement Discard Action + Route
+
+| ID | Priority | Status | File | Notes |
+|----|----------|--------|------|-------|
+| 2.4 | CRITICAL | ✅ Completed | app/.../tools_controller.rb, config/routes.rb | DELETE route added |
+
+---
+
+## Stage 3: Update Admin Tools View
+
+### Item Tables
+
+#### 3.1 - Update Admin Tools View
+
+| ID | Priority | Status | File | Notes |
+|----|----------|--------|------|-------|
+| 3.1 | HIGH | ✅ Completed | app/views/admin/tools/index.html.erb | Added discard button + note about full sync |
+
+---
+
+## Dependencies
+
+- Stage 1 must complete before Stage 2
+- Stage 1.2 must complete before 1.4 (FacilitySyncer changes needed for Syncer)
+
+### Blockers
+
+None identified at this time.
+
+---
+
+## Progress Tracking
+
+```
+Stage 1 (CRITICAL): ████████████████████ 5/5 items (100%)
+Stage 2 (CRITICAL): ████████████████████ 4/4 items (100%)
+Stage 3 (HIGH): ████████████████████ 1/1 items (100%)
+Overall: ████████████████████ 10/10 items (100%)
+```
+
+---
+
+## Status Legend
+
+| Icon | Status |
+|------|--------|
+| ⬜ | Not Started |
+| 🔄 | In Progress |
+| ✅ | Completed |
+| ⏸️ | On Hold |
+| 🚫 | Blocked |
+
+---
+
+## Change Log
+
+| Date | Change | Author |
+|------|--------|--------|
+| 2026-03-21 | Initial plan creation | Assistant |
+| 2026-04-11 | Implementation completed | Assistant |
+
+---
+
+## Notes
+
+- Discard reason for removed facilities: `sync_removed`
+- `full_sync: true` (default) - soft-deletes missing facilities; use `full_sync: false` for incremental sync
+- Discard action uses DELETE HTTP method
+- Confirmation dialog required before discard
+- Since discard is used, operation is soft-delete (non-destructive, reversible)
diff --git a/.env.production.template b/env.production.template
similarity index 100%
rename from .env.production.template
rename to env.production.template
diff --git a/package-lock.json b/package-lock.json
index a7a1dfac..c81f5f54 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,29 +8,28 @@
"name": "linkvan-api",
"version": "0.1.0",
"dependencies": {
- "@fortawesome/fontawesome-free": "^6.5.1",
+ "@fortawesome/fontawesome-free": "^7.2.0",
"@hotwired/stimulus": "^3.2.2",
- "@hotwired/turbo-rails": "^8.0.18",
- "@rails/actioncable": "^8.1.0",
- "@rails/actiontext": "^8.1.0",
- "@rails/activestorage": "^8.1.0",
- "@rails/request.js": "^0.0.12",
+ "@hotwired/turbo-rails": "^8.0.23",
+ "@rails/actioncable": "^8.1.300",
+ "@rails/actiontext": "^8.1.300",
+ "@rails/activestorage": "^8.1.300",
+ "@rails/request.js": "^0.0.13",
"babel-preset-react": "^6.24.1",
"bulma": "^1.0.2",
"bulma-tooltip": "^3.0.2",
"flatpickr": "^4.6.9",
- "sass": "^1.98.0",
- "trix": "^2.1.4"
+ "sass": "^1.99.0",
+ "trix": "^2.1.19"
},
"devDependencies": {
- "jquery": "^3.6.0"
+ "jquery": "^4.0.0"
}
},
"node_modules/@fortawesome/fontawesome-free": {
- "version": "6.5.1",
- "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.1.tgz",
- "integrity": "sha512-CNy5vSwN3fsUStPRLX7fUYojyuzoEMSXPl7zSLJ8TgtRfjv24LOnOWKT2zYwaHZCJGkdyRnTmstR0P+Ah503Gw==",
- "hasInstallScript": true,
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.2.0.tgz",
+ "integrity": "sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg==",
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
@@ -43,21 +42,21 @@
"license": "MIT"
},
"node_modules/@hotwired/turbo": {
- "version": "8.0.18",
- "resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.18.tgz",
- "integrity": "sha512-dG0N7khQsP8sujclodQE3DYkI4Lq7uKA04fhT0DCC/DwMgn4T4WM3aji6EC6+iCfABQeJncY0SraXqVeOq0vvQ==",
+ "version": "8.0.23",
+ "resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.23.tgz",
+ "integrity": "sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ==",
"license": "MIT",
"engines": {
"node": ">= 18"
}
},
"node_modules/@hotwired/turbo-rails": {
- "version": "8.0.18",
- "resolved": "https://registry.npmjs.org/@hotwired/turbo-rails/-/turbo-rails-8.0.18.tgz",
- "integrity": "sha512-iRxd922VSTVH0NzlLDx9T9S8Ep0NPnrLCKva31WIMLNApJgUZKa/a90EFiBa2G6Do+x4xuKZk53dlweiwTyXkQ==",
+ "version": "8.0.23",
+ "resolved": "https://registry.npmjs.org/@hotwired/turbo-rails/-/turbo-rails-8.0.23.tgz",
+ "integrity": "sha512-iBILwda3qmQC7FYM70+4s6kEQ7Fx9dJ6+yGxjPyrz9a5JDx1+y7OAA5TA7GGVOZJoicMLrKGdFDNorl40X35lw==",
"license": "MIT",
"dependencies": {
- "@hotwired/turbo": "^8.0.18",
+ "@hotwired/turbo": "^8.0.23",
"@rails/actioncable": ">=7.0"
}
},
@@ -358,15 +357,15 @@
}
},
"node_modules/@rails/actioncable": {
- "version": "8.1.200",
- "resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-8.1.200.tgz",
- "integrity": "sha512-on0DSb7AFUkq1ocxivDNQhhGW/RQpY91zvRVyyaEWP4gOOZWy33P/UyxjQk74IENWNrTqs8+zOGHwTjiiFruRw==",
+ "version": "8.1.300",
+ "resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-8.1.300.tgz",
+ "integrity": "sha512-zOENQsq3NM2jyBY6Z2qtZa3V/R/6OEqA+LGKixQbBMl7kk/J3FXDRcszPe74LsHNgB01jCl/DXu/xA8sHt4I/g==",
"license": "MIT"
},
"node_modules/@rails/actiontext": {
- "version": "8.1.200",
- "resolved": "https://registry.npmjs.org/@rails/actiontext/-/actiontext-8.1.200.tgz",
- "integrity": "sha512-l4OuFLZbQB+A3yCNOzX0Y4Tn7XSekfuYjy20TiBuf+4Q5JKTnfuybHrQ5cDk/9DbwWE9sdWcdbODFUIYd4tczg==",
+ "version": "8.1.300",
+ "resolved": "https://registry.npmjs.org/@rails/actiontext/-/actiontext-8.1.300.tgz",
+ "integrity": "sha512-KPbLfZTt6aFKbdeTlU7ZzmX0uSSNhhfZx3G+2lfa6HltQ/r23HDcJnJOEVt72WKJo13IS5C5HK0sYwplVMCdNw==",
"license": "MIT",
"dependencies": {
"@rails/activestorage": ">= 8.1.0-alpha"
@@ -376,20 +375,27 @@
}
},
"node_modules/@rails/activestorage": {
- "version": "8.1.200",
- "resolved": "https://registry.npmjs.org/@rails/activestorage/-/activestorage-8.1.200.tgz",
- "integrity": "sha512-bPZqv447REBd1NQfba//FjgUqbUd93zKh7+BWhh3vRZ7Nm+RUgm6c5GbWctmik/rMHjsruTHhusYGyoKyf60pg==",
+ "version": "8.1.300",
+ "resolved": "https://registry.npmjs.org/@rails/activestorage/-/activestorage-8.1.300.tgz",
+ "integrity": "sha512-AOv3zZrTJjQlzm4L9uzOQtCz6zaM4IqUyux6yzSqmS+PZ8EzwD1F2JAc4LlJNJAv4MSyNYriG+CaCm1QbQTjsA==",
"license": "MIT",
"dependencies": {
"spark-md5": "^3.0.1"
}
},
"node_modules/@rails/request.js": {
- "version": "0.0.12",
- "resolved": "https://registry.npmjs.org/@rails/request.js/-/request.js-0.0.12.tgz",
- "integrity": "sha512-g3//JBja1s04Zflj7IoMLQuXza9i4ZvtLmm0r0dMwh1QQUs6rL2iKUOGGyERfLsd81SnXC5ucfVV//rtsDlEEA==",
+ "version": "0.0.13",
+ "resolved": "https://registry.npmjs.org/@rails/request.js/-/request.js-0.0.13.tgz",
+ "integrity": "sha512-7MXmjFOPuaxpjG8brqKJG0EfIe9ak6R0wRnjCBtRuADNFbdlRxETdKx1T5NVU4Ato3iZOkEpeSUEuLboL3tCGA==",
"license": "MIT"
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/babel-helper-builder-react-jsx": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-helper-builder-react-jsx/-/babel-helper-builder-react-jsx-6.26.0.tgz",
@@ -552,6 +558,15 @@
"node": ">=8"
}
},
+ "node_modules/dompurify": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
+ "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -597,16 +612,16 @@
}
},
"node_modules/jquery": {
- "version": "3.6.1",
- "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.1.tgz",
- "integrity": "sha512-opJeO4nCucVnsjiXOE+/PcCgYw9Gwpvs/a6B1LL/lQhwWwpbVEVYDZ1FokFr8PRc7ghYlrFPuyHuiiDNTQxmcw==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz",
+ "integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/node-addon-api": {
@@ -617,9 +632,9 @@
"optional": true
},
"node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"optional": true,
"engines": {
@@ -649,9 +664,9 @@
"license": "MIT"
},
"node_modules/sass": {
- "version": "1.98.0",
- "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz",
- "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==",
+ "version": "1.99.0",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz",
+ "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==",
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
@@ -693,10 +708,16 @@
}
},
"node_modules/trix": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/trix/-/trix-2.1.4.tgz",
- "integrity": "sha512-f0AGnqBV8J2qW+fCtVU71JmvzjcxnO5Xbbd6Cl2KrHVRpgXKDqNGTmDmQzNHWU7T2OgtwHwvNiN+OIf3Z3KmHQ==",
- "license": "MIT"
+ "version": "2.1.19",
+ "resolved": "https://registry.npmjs.org/trix/-/trix-2.1.19.tgz",
+ "integrity": "sha512-E7RA3EOeUiUwNJlrF5onIOkqCA06xUU6GmHOVxXyMnGMValrDK3Ce7uaMVgiVUOvVt4mzUERAHAzD10mxoLpOg==",
+ "license": "MIT",
+ "dependencies": {
+ "dompurify": "^3.2.5"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
}
}
}
diff --git a/package.json b/package.json
index 2b79f7a4..f391e66e 100644
--- a/package.json
+++ b/package.json
@@ -4,21 +4,21 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^7.2.0",
"@hotwired/stimulus": "^3.2.2",
- "@hotwired/turbo-rails": "^8.0.18",
- "@rails/actioncable": "^8.1.0",
- "@rails/actiontext": "^8.1.0",
- "@rails/activestorage": "^8.1.0",
- "@rails/request.js": "^0.0.12",
+ "@hotwired/turbo-rails": "^8.0.23",
+ "@rails/actioncable": "^8.1.300",
+ "@rails/actiontext": "^8.1.300",
+ "@rails/activestorage": "^8.1.300",
+ "@rails/request.js": "^0.0.13",
"babel-preset-react": "^6.24.1",
"bulma": "^1.0.2",
"bulma-tooltip": "^3.0.2",
"flatpickr": "^4.6.9",
- "sass": "^1.98.0",
- "trix": "^2.1.4"
+ "sass": "^1.99.0",
+ "trix": "^2.1.19"
},
"version": "0.1.0",
"devDependencies": {
- "jquery": "^3.6.0"
+ "jquery": "^4.0.0"
},
"scripts": {
"build:css": "sass ./app/assets/stylesheets/application.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules"
diff --git a/spec/components/facilities/discard_reason_component_spec.rb b/spec/components/facilities/discard_reason_component_spec.rb
index 92d8e594..6212fe47 100644
--- a/spec/components/facilities/discard_reason_component_spec.rb
+++ b/spec/components/facilities/discard_reason_component_spec.rb
@@ -3,7 +3,7 @@
RSpec.describe Facilities::DiscardReasonComponent, type: :component do
subject(:component) { described_class.new(discard_reason) }
- let(:discard_reason) { :none }
+ let(:discard_reason) { nil }
describe "#initialize" do
context "when discard_reason is a symbol" do
@@ -59,15 +59,15 @@
context "with nil discard_reason" do
let(:discard_reason) { nil }
- it "returns error message for nil" do
- expect(component.call).to have_text("Unsupported value ''")
+ it "returns 'None' text" do
+ expect(component.call).to have_text("None")
end
end
end
describe ".select_options" do
it "returns inverted hash as array of arrays" do
- expected = [["None", :none], ["Closed", :closed], ["Duplicated", :duplicated]]
+ expected = [["None", nil], ["Closed", :closed], ["Duplicated", :duplicated], ["Removed by Sync", :sync_removed]]
expect(described_class.select_options).to eq(expected)
end
end
@@ -92,11 +92,11 @@
end
context "with string discard reason" do
- let(:discard_reason) { "none" }
+ let(:discard_reason) { "sync_removed" }
it "renders the correct text" do
render_inline(component)
- expect(rendered_content).to have_text("None")
+ expect(rendered_content).to have_text("Removed by Sync")
end
end
end
diff --git a/spec/controllers/admin/facilities_controller_spec.rb b/spec/controllers/admin/facilities_controller_spec.rb
index 60ece7fc..dbdcca73 100644
--- a/spec/controllers/admin/facilities_controller_spec.rb
+++ b/spec/controllers/admin/facilities_controller_spec.rb
@@ -11,6 +11,12 @@
allow(controller).to receive_messages(authenticate_user!: true, current_user: admin_user, user_signed_in?: true)
end
+ def stub_facility_find_with(facility)
+ relation = Facility.all
+ allow(Facility).to receive(:with_associations).and_return(relation)
+ allow(relation).to receive(:find).and_return(facility)
+ end
+
describe "GET #index" do
subject(:get_index) { get :index, params: params }
@@ -354,24 +360,23 @@
end
describe "undiscard action" do
- let(:facility) { create(:facility).tap(&:discard) }
+ let(:facility) { create(:facility, discard_reason: :closed).tap(&:discard) }
let(:params) { { id: facility.id, undiscard: true } }
context "when undiscard succeeds" do
- before do
- facility.discard_reason = :closed
- patch_update
- end
-
it "undiscards the facility" do
- expect(facility.reload).not_to be_discarded
+ expect do
+ patch_update
+ end.to change { facility.reload.discarded? }.from(true).to(false)
end
it "redirects to show" do
+ patch_update
expect(response).to redirect_to(admin_facility_path(facility))
end
it "sets flash notice" do
+ patch_update
expect(flash[:notice]).to match(/Successfully undiscarded facility/)
end
end
@@ -379,9 +384,8 @@
context "when undiscard fails" do
before do
# Stub Facility.find to return the facility with the undiscard stub
- allow(Facility).to receive(:find).and_return(facility)
+ stub_facility_find_with(facility)
allow(facility).to receive(:undiscard).and_return(false)
- facility.discard_reason = :closed
patch_update
end
@@ -406,8 +410,9 @@
context "with valid discard reason" do
it "discards the facility" do
- delete_destroy
- expect(facility.reload).to be_discarded
+ expect do
+ delete_destroy
+ end.to change { facility.reload.discarded? }.from(false).to(true)
end
it "sets flash notice" do
@@ -424,7 +429,7 @@
context "when discard fails" do
before do
# Stub Facility.find to return the facility with the discard stub
- allow(Facility).to receive(:find).and_return(facility)
+ stub_facility_find_with(facility)
allow(facility).to receive(:discard).and_return(false)
delete_destroy
end
@@ -494,7 +499,7 @@
context "when status update fails" do
before do
# Stub Facility.find to return the facility with the update_status stub
- allow(Facility).to receive(:find).and_return(facility)
+ stub_facility_find_with(facility)
allow(facility).to receive(:update_status).and_return(false)
patch_switch
end
@@ -534,7 +539,9 @@
context "when the destroy action" do
let(:facility) { create(:facility) }
- before { delete :destroy, params: { id: facility.id, facility: { discard_reason: "closed" } } }
+ before do
+ delete :destroy, params: { id: facility.id, facility: { discard_reason: "closed" } }
+ end
it { expect(assigns(:facility)).to eq(facility) }
end
@@ -542,7 +549,9 @@
context "when the switch_status action" do
let(:facility) { create(:facility) }
- before { patch :switch_status, params: { id: facility.id, status: "live" } }
+ before do
+ patch :switch_status, params: { id: facility.id, status: "live" }
+ end
it { expect(assigns(:facility)).to eq(facility) }
end
@@ -638,7 +647,7 @@
before do
# Stub Facility.find to return the facility with the discard stub
- allow(Facility).to receive(:find).and_return(facility)
+ stub_facility_find_with(facility)
allow(facility).to receive(:discard).and_return(false)
delete :destroy, params: { id: facility.id, facility: { discard_reason: "closed" } }
end
@@ -653,7 +662,7 @@
before do
# Stub Facility.find to return the facility with the update_status stub
- allow(Facility).to receive(:find).and_return(facility)
+ stub_facility_find_with(facility)
allow(facility).to receive(:update_status).and_return(false)
patch :switch_status, params: { id: facility.id, status: "live" }
end
diff --git a/spec/controllers/admin/tools_controller_spec.rb b/spec/controllers/admin/tools_controller_spec.rb
new file mode 100644
index 00000000..7aff5ecf
--- /dev/null
+++ b/spec/controllers/admin/tools_controller_spec.rb
@@ -0,0 +1,152 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe Admin::ToolsController do
+ let(:admin_user) { create(:user, :admin, :verified) }
+ let(:non_admin_user) { create(:user, :verified) }
+
+ # Stub Devise authentication methods
+ before do
+ allow(controller).to receive_messages(authenticate_user!: true, current_user: admin_user, user_signed_in?: true)
+ end
+
+ describe "DELETE #discard_facilities" do
+ subject(:discard_facilities) { delete :discard_facilities }
+
+ context "when admin user" do
+ let(:drinking_fountains_key) { "drinking-fountains" }
+ let(:public_washrooms_key) { "public-washrooms" }
+
+ before do
+ create(:facility, :with_verified, external_id: "FOO123", name: "Fountain 1")
+ create(:facility, :with_verified, external_id: "BAR456", name: "Fountain 2")
+ end
+
+ context "with facilities to discard" do
+ it "discards all external facilities" do
+ expect do
+ discard_facilities
+ end.to change { Facility.external.kept.count }.from(2).to(0)
+ end
+
+ it "redirects with notice showing count" do
+ discard_facilities
+
+ expect(response).to redirect_to(admin_facilities_path(service: "water_fountain"))
+ expect(flash[:notice]).to include("2")
+ end
+
+ it "discards facilities with sync_removed reason" do
+ discard_facilities
+
+ discarded = Facility.external.with_discarded.find_by(external_id: "FOO123")
+ expect(discarded).to be_discarded
+ expect(discarded.discard_reason).to eq("sync_removed")
+ end
+ end
+
+ context "with no facilities to discard" do
+ before do
+ Facility.external.kept.destroy_all
+ end
+
+ it "redirects with notice showing zero count" do
+ discard_facilities
+
+ expect(response).to redirect_to(admin_facilities_path(service: "water_fountain"))
+ expect(flash[:notice]).to include("0")
+ end
+ end
+ end
+
+ context "when non-admin user" do
+ before do
+ allow(controller).to receive_messages(current_user: non_admin_user, user_signed_in?: true)
+ end
+
+ it "redirects with access denied" do
+ discard_facilities
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to include("Access denied")
+ end
+ end
+ end
+
+ describe "GET #index" do
+ subject(:get_index) { get :index }
+
+ it { is_expected.to have_http_status(:success) }
+
+ it "renders without error" do
+ get_index
+ expect(response).to have_http_status(:success)
+ end
+ end
+
+ describe "POST #import_facilities" do
+ subject(:import_facilities) { post :import_facilities, params: { api: api_key } }
+
+ let(:api_key) { "drinking-fountains" }
+
+ context "when admin user" do
+ context "with valid api_key" do
+ let(:syncer_result) do
+ ApplicationService::Result.new(
+ data: { facilities: [], total_count: 0, created_count: 0, updated_count: 0, deleted_count: 0, api_key: api_key },
+ errors: []
+ )
+ end
+ let(:api_client) do
+ client = instance_double(External::VancouverCity::VancouverApiClient)
+ allow(client).to receive(:is_a?).with(External::VancouverCity::VancouverApiClient).and_return(true)
+ client
+ end
+ let(:api_response) { instance_double(Faraday::Response, body: { "records" => [record] }) }
+ let(:record) do
+ { "mapid" => "FOO123", "name" => "Fountain 1",
+ "address" => "123 Main St",
+ "geom" => { geometry: { coordinates: [-123.365644, 48.428421] } } }
+ end
+
+ before do
+ allow(External::VancouverCity).to receive(:default_client).and_return(api_client)
+ allow(api_client).to receive(:get_dataset_records)
+ .with(api_key, limit: anything, offset: 0)
+ .and_return(api_response)
+ end
+
+ it "imports facilities and redirects" do
+ import_facilities
+
+ expect(response).to redirect_to(admin_facilities_path(service: "water_fountain"))
+ end
+ end
+
+ context "with invalid api_key" do
+ let(:api_key) { "invalid-api" }
+
+ it "redirects with alert" do
+ import_facilities
+
+ expect(response).to redirect_to(admin_tools_path)
+ expect(flash[:alert]).to include("Invalid API")
+ end
+ end
+ end
+
+ context "when non-admin user" do
+ before do
+ allow(controller).to receive_messages(current_user: non_admin_user, user_signed_in?: true)
+ end
+
+ it "redirects with access denied" do
+ import_facilities
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to include("Access denied")
+ end
+ end
+ end
+end
diff --git a/spec/factories/facilities.rb b/spec/factories/facilities.rb
index e7635a2f..d2cc47a4 100644
--- a/spec/factories/facilities.rb
+++ b/spec/factories/facilities.rb
@@ -54,6 +54,13 @@
end
end
+ # Creates a facility that is logically discarded (deleted)
+ trait :discarded do
+ after(:build) do |facility|
+ facility.deleted_at = Time.current
+ end
+ end
+
# Remove these fields
# "r_pets": false,
# "r_id": false,
diff --git a/spec/models/facility_spec.rb b/spec/models/facility_spec.rb
index c62584f2..0339d424 100644
--- a/spec/models/facility_spec.rb
+++ b/spec/models/facility_spec.rb
@@ -34,12 +34,6 @@
it { expect(facility).to have_many(:time_slots).through(:schedules) }
end
- describe "discard_reason enum" do
- it "defines enum values" do
- expect(described_class.discard_reasons).to eq({ "none" => nil, "closed" => "closed", "duplicated" => "duplicated" })
- end
- end
-
it_behaves_like "discardable" do
subject(:model) { facility }
end
@@ -52,7 +46,7 @@
end
context "with none" do
- let(:discard_reason) { :none }
+ let(:discard_reason) { nil }
it { expect(facility).to be_discard_reason_none }
end
@@ -68,6 +62,12 @@
it { expect(facility).to be_discard_reason_duplicated }
end
+
+ context "with sync_removed" do
+ let(:discard_reason) { :sync_removed }
+
+ it { expect(facility).to be_discard_reason_sync_removed }
+ end
end
describe "scopes" do
@@ -316,7 +316,7 @@
facility.save!
end
- it { expect(facility.discard_reason).to eq("none") }
+ it { expect(facility.discard_reason).to be_nil }
end
end
end
diff --git a/spec/services/external/vancouver_city/facility_builder_spec.rb b/spec/services/external/vancouver_city/facility_builder_spec.rb
index fbe2d4eb..24625895 100644
--- a/spec/services/external/vancouver_city/facility_builder_spec.rb
+++ b/spec/services/external/vancouver_city/facility_builder_spec.rb
@@ -3,6 +3,9 @@
require "rails_helper"
RSpec.describe External::VancouverCity::FacilityBuilder, type: :service do
+ subject(:builder) { described_class.new(facility: facility, record: record, api_key: api_key) }
+
+ let(:facility) { Facility.new }
let(:valid_api_key) { "drinking-fountains" }
let(:valid_record) do
@@ -35,17 +38,20 @@
end
describe "#initialize" do
- it "initializes with valid parameters" do
- builder = described_class.new(record: valid_record, api_key: valid_api_key)
+ let(:record) { valid_record }
+ let(:api_key) { valid_api_key }
+ it "initializes with valid parameters" do
expect(builder.record).to eq(valid_record)
expect(builder.api_key).to eq(valid_api_key)
end
end
describe "#validate" do
+ let(:api_key) { valid_api_key }
+
context "with valid parameters" do
- let(:builder) { described_class.new(record: valid_record, api_key: valid_api_key) }
+ let(:record) { valid_record }
it "returns empty errors array" do
expect(builder.validate).to be_blank
@@ -57,7 +63,7 @@
end
context "with nil record" do
- let(:builder) { described_class.new(record: nil, api_key: valid_api_key) }
+ let(:record) { nil }
it "returns validation errors" do
errors = builder.validate
@@ -66,7 +72,7 @@
end
context "with non-hash record" do
- let(:builder) { described_class.new(record: "invalid", api_key: valid_api_key) }
+ let(:record) { "invalid_record" }
it "returns validation errors" do
errors = builder.validate
@@ -77,13 +83,14 @@
describe "#call" do
let(:service) { create(:water_fountain_service) }
+ let(:api_key) { valid_api_key }
before do
service # Ensure service exists
end
context "with valid parameters and complete record" do
- let(:builder) { described_class.new(record: valid_record, api_key: valid_api_key) }
+ let(:record) { valid_record }
it "returns successful result" do
result = builder.call
@@ -95,72 +102,75 @@
it "builds facility with correct attributes" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
- expect(facility.external_id).to eq("12345")
- expect(facility.name).to eq("Test Fountain")
- expect(facility.address).to eq("Test Park, Downtown")
- expect(facility.phone).to eq("604-123-4567")
- expect(facility.website).to eq("https://vancouver.ca")
- expect(facility.lat).to eq(49.2827)
- expect(facility.long).to eq(-123.1207)
- expect(facility.verified).to be true
+ expect(result_facility.external_id).to eq("12345")
+ expect(result_facility.name).to eq("Test Fountain")
+ expect(result_facility.address).to eq("Test Park, Downtown")
+ expect(result_facility.phone).to eq("604-123-4567")
+ expect(result_facility.website).to eq("https://vancouver.ca")
+ expect(result_facility.lat).to eq(49.2827)
+ expect(result_facility.long).to eq(-123.1207)
+ expect(result_facility.verified).to be true
end
it "builds notes from multiple fields" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
- expect(facility.notes).to include("Maintained by: Parks Department")
- expect(facility.notes).to include("Operation: Yes")
- expect(facility.notes).to include("Pet friendly: Yes")
+ expect(result_facility.notes).to include("Maintained by: Parks Department")
+ expect(result_facility.notes).to include("Operation: Yes")
+ expect(result_facility.notes).to include("Pet friendly: Yes")
end
it "associates correct service" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
- expect(facility.facility_services.size).to eq(1)
- expect(facility.facility_services.first.service).to eq(service)
+ expect(result).to be_success
+ expect(result_facility.facility_services.size).to eq(1)
+ expect(result_facility.facility_services.first.service).to eq(service)
end
it "creates facility welcomes for all customers" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
- expect(facility.facility_welcomes).not_to be_blank
+ expect(result_facility.facility_welcomes).not_to be_blank
# Test that welcomes are created (exact count depends on FacilityWelcome.all_customers)
end
it "creates schedules for all weekdays" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
- expect(facility.schedules.size).to eq(7) # All weekdays
- facility.schedules.each do |schedule|
+ expect(result_facility.schedules.size).to eq(7) # All weekdays
+ result_facility.schedules.each do |schedule|
expect(schedule.closed_all_day).to be false
expect(schedule.open_all_day).to be true
end
end
describe "schedule business logic" do
+ let(:record) { valid_record }
+
it "creates exactly one schedule for each day of the week" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
# Test that we have all 7 days
- expect(facility.schedules.size).to eq(7)
+ expect(result_facility.schedules.size).to eq(7)
# Test that each day is covered exactly once
- week_days = facility.schedules.map(&:week_day)
+ week_days = result_facility.schedules.map(&:week_day)
expect(week_days.sort).to eq(FacilitySchedule.week_days.keys.sort)
end
it "sets all schedules to open_all_day = true and closed_all_day = false" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
- facility.schedules.each do |schedule|
+ result_facility.schedules.each do |schedule|
expect(schedule.open_all_day).to be(true), "Expected #{schedule.week_day} to be open_all_day"
expect(schedule.closed_all_day).to be(false), "Expected #{schedule.week_day} not to be closed_all_day"
end
@@ -168,25 +178,25 @@
it "creates schedules without time slots (consistent with open_all_day)" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
- facility.schedules.each do |schedule|
+ result_facility.schedules.each do |schedule|
expect(schedule.time_slots).to be_blank, "Expected #{schedule.week_day} to have no time slots when open_all_day"
end
end
it "creates valid schedule objects that pass model validations" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
- expect(facility.schedules).to all(be_valid)
+ expect(result_facility.schedules).to all(be_valid)
end
it "sets schedule availability to :open for all days" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
- facility.schedules.each do |schedule|
+ result_facility.schedules.each do |schedule|
expect(schedule.availability).to eq(:open), "Expected #{schedule.week_day} availability to be :open"
end
end
@@ -194,12 +204,11 @@
context "when no fields are provided for schedules" do
it "still creates open_all_day schedules for all weekdays" do
# Test with minimal record that has no schedule-related fields
- minimal_builder = described_class.new(record: minimal_record, api_key: valid_api_key)
- result = minimal_builder.call
- facility = result.data[:facility]
+ result = builder.call
+ result_facility = result.data[:facility]
- expect(facility.schedules.size).to eq(7)
- facility.schedules.each do |schedule|
+ expect(result_facility.schedules.size).to eq(7)
+ result_facility.schedules.each do |schedule|
expect(schedule.open_all_day).to be true
expect(schedule.closed_all_day).to be false
end
@@ -209,11 +218,11 @@
context "with business requirement verification" do
it "ensures imported facilities are always accessible 24/7" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
# Verify that the facility is accessible any day of the week, any time
FacilitySchedule.week_days.each_key do |day|
- schedule = facility.schedules.find { |s| s.week_day == day.to_s }
+ schedule = result_facility.schedules.find { |s| s.week_day == day.to_s }
expect(schedule).to be_present, "Missing schedule for #{day}"
expect(schedule.open_all_day).to be(true), "Facility should be accessible 24/7 on #{day}"
expect(schedule.closed_all_day).to be(false), "Facility should not be closed on #{day}"
@@ -224,7 +233,7 @@
end
context "with minimal record" do
- let(:builder) { described_class.new(record: minimal_record, api_key: valid_api_key) }
+ let(:record) { minimal_record }
it "returns successful result" do
result = builder.call
@@ -235,19 +244,34 @@
it "builds facility with minimal data" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
+
+ expect(result_facility.name).to eq("Minimal Fountain")
+ expect(result_facility.lat).to eq(49.2827)
+ expect(result_facility.long).to eq(-123.1207)
+ expect(result_facility.address).to be_nil
+ expect(result_facility.phone).to be_nil
+ expect(result_facility.website).to be_nil
+ end
+ end
+
+ context "with non-string name field" do
+ let(:record) do
+ minimal_record.merge("name" => 12_345) # Integer instead of String
+ end
+
+ it "returns error result with exception message" do
+ result = builder.call
+ result_facility = result.data[:facility]
- expect(facility.name).to eq("Minimal Fountain")
- expect(facility.lat).to eq(49.2827)
- expect(facility.long).to eq(-123.1207)
- expect(facility.address).to be_nil
- expect(facility.phone).to be_nil
- expect(facility.website).to be_nil
+ expect(result).to be_success
+ expect(result_facility).to be_present
+ expect(result_facility.name).to eq("12345") # Should be converted to string
end
end
context "with geo_point_2d coordinates" do
- let(:record_with_geo_point) do
+ let(:record) do
{
"name" => "Geo Point Fountain",
"geo_point_2d" => {
@@ -256,19 +280,18 @@
}
}
end
- let(:builder) { described_class.new(record: record_with_geo_point, api_key: valid_api_key) }
it "extracts coordinates from geo_point_2d" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
- expect(facility.lat).to eq(49.2827)
- expect(facility.long).to eq(-123.1207)
+ expect(result_facility.lat).to eq(49.2827)
+ expect(result_facility.long).to eq(-123.1207)
end
end
context "with geometry coordinates" do
- let(:record_with_geometry) do
+ let(:record) do
{
"name" => "Geometry Fountain",
"geom" => {
@@ -278,69 +301,70 @@
}
}
end
- let(:builder) { described_class.new(record: record_with_geometry, api_key: valid_api_key) }
it "extracts coordinates from geometry in correct order" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
- expect(facility.lat).to eq(49.2827) # Latitude from coordinates[1]
- expect(facility.long).to eq(-123.1207) # Longitude from coordinates[0]
+ expect(result_facility.lat).to eq(49.2827) # Latitude from coordinates[1]
+ expect(result_facility.long).to eq(-123.1207) # Longitude from coordinates[0]
end
end
context "with special characters in name" do
- let(:record_with_special_chars) do
+ let(:record) do
{
"name" => "Test\\nFountain\nWith\n\nSpecial Chars",
"geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
}
end
- let(:builder) { described_class.new(record: record_with_special_chars, api_key: valid_api_key) }
it "cleans name by removing special characters and extra whitespace" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
- expect(facility.name).to eq("Test Fountain With Special Chars")
+ expect(result_facility.name).to eq("Test Fountain With Special Chars")
end
end
- context "with phone field variations" do
- let(:record_with_phone_number) do
- {
- "name" => "Phone Test",
- "phone_number" => "604-555-1234",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
- let(:record_with_contact_phone) do
- {
- "name" => "Contact Phone Test",
- "contact_phone" => "604-555-5678",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
+ describe "with phone field variations" do
+ context "when phone_number field is present" do
+ let(:record) do
+ {
+ "name" => "Phone Test",
+ "phone_number" => "604-555-1234",
+ "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
+ }
+ end
- it "extracts phone from phone_number field" do
- builder = described_class.new(record: record_with_phone_number, api_key: valid_api_key)
- result = builder.call
- facility = result.data[:facility]
+ it "extracts phone from phone_number field" do
+ result = builder.call
+ result_facility = result.data[:facility]
- expect(facility.phone).to eq("604-555-1234")
+ expect(result_facility.phone).to eq("604-555-1234")
+ end
end
- it "extracts phone from contact_phone field" do
- builder = described_class.new(record: record_with_contact_phone, api_key: valid_api_key)
- result = builder.call
- facility = result.data[:facility]
+ context "when contact_phone field is present" do
+ let(:record) do
+ {
+ "name" => "Contact Phone Test",
+ "contact_phone" => "604-555-5678",
+ "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
+ }
+ end
+
+ it "extracts phone from contact_phone field" do
+ result = builder.call
+ result_facility = result.data[:facility]
- expect(facility.phone).to eq("604-555-5678")
+ expect(result_facility.phone).to eq("604-555-5678")
+ end
end
end
context "with website field variations" do
- let(:record_with_url) do
+ let(:record) do
{
"name" => "URL Test",
"url" => "https://example.com",
@@ -349,50 +373,85 @@
end
it "extracts website from url field" do
- builder = described_class.new(record: record_with_url, api_key: valid_api_key)
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
- expect(facility.website).to eq("https://example.com")
+ expect(result_facility.website).to eq("https://example.com")
end
end
context "with no coordinates" do
- let(:record_without_coords) do
+ let(:record) do
{
"name" => "No Coords Fountain"
}
end
- let(:builder) { described_class.new(record: record_without_coords, api_key: valid_api_key) }
it "builds facility with nil coordinates" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
expect(result).not_to be_success
- expect(facility).to be_nil
+ expect(result_facility).to be_nil
+ end
+ end
+
+ context "when facility already exists" do
+ let(:facility) { create(:facility) }
+ let(:record) { valid_record }
+ let(:api_key) { valid_api_key }
+
+ it "adds FacilityService to existing facility" do
+ result = builder.call
+ result_facility = result.data[:facility]
+
+ expect(result).to be_success
+ expect(result_facility.facility_services).not_to be_blank
+ expect(result_facility.facility_services.count).to eq(1)
+ expect(result_facility.facility_services).to all(be_persisted)
+ expect(result_facility.facility_services.map(&:service)).to include(service)
+ end
+
+ it "adds FacilityWelcome to existing facility" do
+ result = builder.call
+ result_facility = result.data[:facility]
+
+ expect(result).to be_success
+ expect(result_facility.facility_welcomes).not_to be_blank
+ expect(result_facility.facility_welcomes.map(&:customer))
+ .to match_array(FacilityWelcome.all_customers.map(&:value))
+ expect(result_facility.facility_welcomes).to all(be_persisted)
+ end
+
+ it "updates existing facility attributes" do
+ result = builder.call
+ result_facility = result.data[:facility]
+
+ expect(result).to be_success
+ expect(result_facility.name).to eq("Test Fountain")
+ expect(result_facility.external_id).to eq("12345")
end
end
context "when service does not exist" do
- let(:non_existent_api_key) { "non-existent-service" }
- let(:builder) { described_class.new(record: valid_record, api_key: non_existent_api_key) }
+ let(:record) { valid_record }
+ let(:api_key) { "non-existent-service" }
before do
# Stub the API validation to pass
- allow(External::ApiHelper).to receive(:supported_api?).with(non_existent_api_key).and_return(true)
+ allow(External::ApiHelper).to receive(:supported_api?).with(api_key).and_return(true)
end
it "builds facility without service association" do
result = builder.call
- facility = result.data[:facility]
+ result_facility = result.data[:facility]
- expect(facility.facility_services).to be_blank
+ expect(result_facility.facility_services).to be_blank
end
end
context "with invalid parameters" do
- let(:builder) { described_class.new(record: nil, api_key: valid_api_key) }
+ let(:record) { nil }
it "returns error result without building facility" do
result = builder.call
@@ -404,35 +463,8 @@
end
context "when record has invalid data types that cause exceptions" do
- context "with non-string name field" do
- let(:record_with_invalid_name) do
- {
- "name" => 12_345, # Integer instead of String
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
- let(:builder) { described_class.new(record: record_with_invalid_name, api_key: valid_api_key) }
-
- it "returns error result with exception message" do
- result = builder.call
-
- expect(result).to be_failed
- expect(result.data).to be_blank
- expect(result.errors).to include(a_string_matching(/Failed to build facility from record:/))
- end
-
- it "logs the error and record data" do
- allow(Rails.logger).to receive(:warn)
-
- builder.call
-
- expect(Rails.logger).to have_received(:warn).with(a_string_matching(/Failed to build facility from record:/))
- expect(Rails.logger).to have_received(:warn).with("Record data: #{record_with_invalid_name.inspect}")
- end
- end
-
context "with invalid geometry coordinates" do
- let(:record_with_invalid_geometry) do
+ let(:record) do
{
"name" => "Test Fountain",
"geom" => {
@@ -442,7 +474,6 @@
}
}
end
- let(:builder) { described_class.new(record: record_with_invalid_geometry, api_key: valid_api_key) }
it "returns error result with exception message" do
result = builder.call
@@ -454,13 +485,12 @@
end
context "with invalid geo_point_2d field" do
- let(:record_with_invalid_geo_point) do
+ let(:record) do
{
"name" => "Test Fountain",
"geo_point_2d" => "invalid_string" # String instead of Hash
}
end
- let(:builder) { described_class.new(record: record_with_invalid_geo_point, api_key: valid_api_key) }
it "returns error result with exception message" do
result = builder.call
@@ -473,13 +503,12 @@
end
context "when built facility is invalid" do
- let(:invalid_record) do
+ let(:record) do
{
"name" => "", # Empty name might make facility invalid
"geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
}
end
- let(:builder) { described_class.new(record: invalid_record, api_key: valid_api_key) }
it "returns error result with validation messages" do
result = builder.call
@@ -492,6 +521,8 @@
end
describe ".call class method" do
+ let(:record) { valid_record }
+ let(:api_key) { valid_api_key }
let(:service) { create(:water_fountain_service) }
before do
@@ -499,7 +530,7 @@
end
it "works as a class method" do
- result = described_class.call(record: valid_record, api_key: valid_api_key)
+ result = builder.call
expect(result).to be_success
expect(result.data[:facility]).to be_a(Facility)
diff --git a/spec/services/external/vancouver_city/facility_syncer/create_operation_spec.rb b/spec/services/external/vancouver_city/facility_syncer/create_operation_spec.rb
index 9c95c157..59072f7b 100644
--- a/spec/services/external/vancouver_city/facility_syncer/create_operation_spec.rb
+++ b/spec/services/external/vancouver_city/facility_syncer/create_operation_spec.rb
@@ -5,46 +5,47 @@
require "rails_helper"
RSpec.describe External::VancouverCity::FacilitySyncer, "#call", type: :service do
+ subject(:syncer) { described_class.new(operation:, record:, current: nil, api_key: api_key) }
+
+ let(:operation) { External::SyncOperations.create }
let(:api_key) { "drinking-fountains" }
let(:service) { create(:water_fountain_service) }
+ let(:valid_record) do
+ {
+ "mapid" => "CREATE123",
+ "name" => "New Valid Fountain",
+ "location" => "Valid Park",
+ "geo_local_area" => "Downtown",
+ "phone" => "604-123-4567",
+ "website" => "https://vancouver.ca",
+ "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
+ }
+ end
+
+ let(:invalid_record) do
+ {
+ "mapid" => "INVALID123",
+ "name" => "", # Empty name causes FacilityBuilder to fail
+ "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
+ }
+ end
before { service } # Ensure service exists
describe "create operation (:create)" do
- context "when built facility is valid" do
- let(:valid_record) do
- {
- "mapid" => "CREATE123",
- "name" => "New Valid Fountain",
- "location" => "Valid Park",
- "geo_local_area" => "Downtown",
- "phone" => "604-123-4567",
- "website" => "https://vancouver.ca",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
+ let(:record) { valid_record }
+ context "when built facility is valid" do
it "saves the facility successfully" do
expect do
- syncer = described_class.new(record: valid_record, api_key: api_key)
syncer.call
end.to change(Facility, :count).by(1)
end
- it "returns success result with operation: :create" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_success
- expect(result.data.operation).to eq(:create)
- expect(result.errors).to be_empty
- end
-
it "sets result_facility to built_facility" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
result = syncer.call
- facility = result.data.facility
+ facility = result.data
expect(facility).to be_persisted
expect(facility.name).to eq("New Valid Fountain")
expect(facility.external_id).to eq("CREATE123")
@@ -52,10 +53,9 @@
end
it "creates facility with all expected attributes" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
result = syncer.call
- facility = result.data.facility
+ facility = result.data
expect(facility.name).to eq("New Valid Fountain")
expect(facility.address).to eq("Valid Park, Downtown")
expect(facility.phone).to eq("604-123-4567")
@@ -67,10 +67,9 @@
end
it "creates facility services" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
result = syncer.call
- facility = result.data.facility
+ facility = result.data
expect(facility.facility_services.count).to eq(1)
expect(facility.services).to include(service)
end
@@ -78,7 +77,6 @@
it "logs creation message with external_id" do
allow(Rails.logger).to receive(:info)
- syncer = described_class.new(record: valid_record, api_key: api_key)
syncer.call
expect(Rails.logger).to have_received(:info).with("Creating new facility with external_id 'CREATE123'")
@@ -86,23 +84,15 @@
end
context "when FacilityBuilder fails due to invalid data" do
- let(:invalid_record) do
- {
- "mapid" => "INVALID123",
- "name" => "", # Empty name causes FacilityBuilder to fail
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
+ let(:record) { invalid_record }
it "does not save facility" do
expect do
- syncer = described_class.new(record: invalid_record, api_key: api_key)
syncer.call
end.not_to change(Facility, :count)
end
it "adds validation errors to errors array" do
- syncer = described_class.new(record: invalid_record, api_key: api_key)
result = syncer.call
expect(result).to be_failed
@@ -110,190 +100,23 @@
end
it "sets result_facility to nil" do
- syncer = described_class.new(record: invalid_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.facility).to be_nil
- end
-
- it "returns early with operation: nil when FacilityBuilder fails" do
- syncer = described_class.new(record: invalid_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.operation).to be_nil # FacilityBuilder fails before operation is determined
- expect(result).to be_failed
- end
- end
-
- context "when save! raises other StandardError" do
- let(:valid_record) do
- {
- "mapid" => "ERROR123",
- "name" => "Error Test Fountain",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- let(:built_facility) { build(:facility) }
-
- before do
- # Simulate a database connection error or similar
- allow(External::VancouverCity::FacilityBuilder).to receive(:call).with(record: valid_record, api_key: api_key).and_return(
- ApplicationService::Result.new(
- data: { facility: built_facility },
- errors: []
- )
- )
- allow(built_facility).to receive(:save!).and_raise(StandardError.new("Database connection lost"))
- end
-
- it "catches exception and adds generic error message" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_failed
- expect(result.errors).to include(a_string_matching(/Unexpected error during facility sync:/))
- end
-
- it "includes original error message" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
- result = syncer.call
-
- expect(result.errors.first).to include("Database connection lost")
- end
-
- it "does not save facility on failure" do
- expect do
- syncer = described_class.new(record: valid_record, api_key: api_key)
- syncer.call
- end.not_to change(Facility, :count)
- end
-
- it "does not create any related records on failure" do
- expect do
- syncer = described_class.new(record: valid_record, api_key: api_key)
- syncer.call
- end.not_to change(FacilityService, :count)
- end
- end
-
- context "when save! raises ActiveRecord::RecordInvalid" do
- let(:invalid_save_record) do
- {
- "mapid" => "INVALID_SAVE123",
- "name" => "Valid Name",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- let(:built_facility) { build(:facility) }
-
- before do
- # Simulate a validation error during save
- allow(External::VancouverCity::FacilityBuilder).to receive(:call).with(record: invalid_save_record, api_key: api_key).and_return(
- ApplicationService::Result.new(
- data: { facility: built_facility },
- errors: []
- )
- )
- allow(built_facility).to receive(:save!).and_raise(
- ActiveRecord::RecordInvalid.new(built_facility)
- )
- end
-
- it "catches RecordInvalid and adds error message" do
- syncer = described_class.new(record: invalid_save_record, api_key: api_key)
result = syncer.call
- expect(result).to be_failed
- expect(result.errors).to include(a_string_matching(/Failed to save facility:/))
- end
-
- it "does not create facility record on validation failure" do
- expect do
- syncer = described_class.new(record: invalid_save_record, api_key: api_key)
- syncer.call
- end.not_to change(Facility, :count)
- end
-
- it "does not create any related records on validation failure" do
- expect do
- syncer = described_class.new(record: invalid_save_record, api_key: api_key)
- syncer.call
- end.not_to change(FacilityService, :count)
- end
- end
-
- context "when service creation fails" do
- let(:service_fail_record) do
- {
- "mapid" => "SERVICE_FAIL123",
- "name" => "Service Fail Test",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- let(:built_facility) { build(:facility) }
-
- before do
- # For create operations, service associations are built in memory by FacilityBuilder
- # and saved together with the facility. To simulate failure, we need to make
- # the facility save fail due to a constraint on the associations.
- allow(External::VancouverCity::FacilityBuilder).to receive(:call).with(record: service_fail_record, api_key: api_key).and_return(
- ApplicationService::Result.new(
- data: { facility: built_facility },
- errors: []
- )
- )
- allow(built_facility).to receive(:save!).and_raise(
- ActiveRecord::RecordInvalid.new(build(:facility, name: "Service validation failed"))
- )
- end
-
- it "rolls back facility creation when facility save fails" do
- expect do
- syncer = described_class.new(record: service_fail_record, api_key: api_key)
- syncer.call
- end.not_to change(Facility, :count)
- end
-
- it "does not create any service records when transaction fails" do
- expect do
- syncer = described_class.new(record: service_fail_record, api_key: api_key)
- syncer.call
- end.not_to change(FacilityService, :count)
- end
-
- it "does not create any schedule records when transaction fails" do
- expect do
- syncer = described_class.new(record: service_fail_record, api_key: api_key)
- syncer.call
- end.not_to change(FacilitySchedule, :count)
+ expect(result.data).to be_nil
end
- it "returns failed result with proper error message" do
- syncer = described_class.new(record: service_fail_record, api_key: api_key)
+ it "returns early with no data" do
result = syncer.call
+ expect(result.data).to be_nil # FacilityBuilder fails before operation is determined
expect(result).to be_failed
- expect(result.errors).to include(a_string_matching(/Failed to save facility:/))
end
end
context "when creating database record on success" do
- let(:success_record) do
- {
- "mapid" => "SUCCESS123",
- "name" => "Success Test Fountain",
- "location" => "Success Park",
- "geo_local_area" => "Downtown",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
+ let(:record) { valid_record }
it "creates facility with all related records atomically" do
- syncer = described_class.new(record: success_record, api_key: api_key)
-
expect { syncer.call }.to change(Facility, :count).by(1)
.and change(FacilityService, :count).by(1)
.and change(FacilitySchedule, :count).by(7) # 7 days of the week
@@ -301,13 +124,12 @@
end
it "creates facility with correct attributes and relationships" do
- syncer = described_class.new(record: success_record, api_key: api_key)
result = syncer.call
- facility = result.data.facility
+ facility = result.data
expect(facility).to be_persisted
- expect(facility.external_id).to eq("SUCCESS123")
- expect(facility.name).to eq("Success Test Fountain")
+ expect(facility.external_id).to eq(record["mapid"])
+ expect(facility.name).to eq(record["name"])
expect(facility.verified).to be true
# Verify related records are created
@@ -318,17 +140,57 @@
end
it "ensures all database records are properly linked" do
- syncer = described_class.new(record: success_record, api_key: api_key)
result = syncer.call
- facility = result.data.facility
+ facility = result.data
- # Verify foreign key relationships
expect(facility.facility_services.all? { |fs| fs.facility_id == facility.id }).to be true
expect(facility.schedules.all? { |s| s.facility_id == facility.id }).to be true
expect(facility.facility_welcomes.all? { |fw| fw.facility_id == facility.id }).to be true
end
end
+
+ context "with facility with special characters in name" do
+ let(:record) do
+ {
+ "mapid" => "SPECIAL123",
+ "name" => "O'Brien's Water Fountain & Rest Area",
+ "location" => "Québec Street",
+ "geo_local_area" => "Mount Pleasant",
+ "geo_point_2d" => { "lat" => 49.2627, "lon" => -123.1007 }
+ }
+ end
+
+ it "handles special characters correctly" do
+ result = syncer.call
+
+ expect(result).to be_success
+ facility = result.data
+ expect(facility.name).to eq("O'Brien's Water Fountain & Rest Area")
+ expect(facility.address).to eq("Québec Street, Mount Pleasant")
+ end
+ end
+
+ context "with facility at edge coordinates" do
+ let(:record) do
+ {
+ "mapid" => "EDGE123",
+ "name" => "Edge Case Fountain",
+ "location" => "Boundary Road",
+ "geo_local_area" => "Boundary",
+ "geo_point_2d" => { "lat" => 90.0, "lon" => -180.0 }
+ }
+ end
+
+ it "handles edge coordinate values" do
+ result = syncer.call
+
+ expect(result).to be_success
+ facility = result.data
+ expect(facility.lat).to eq(90.0)
+ expect(facility.long).to eq(-180.0)
+ end
+ end
end
end
# rubocop:enable RSpec/SpecFilePathFormat
diff --git a/spec/services/external/vancouver_city/facility_syncer/error_handling_spec.rb b/spec/services/external/vancouver_city/facility_syncer/error_handling_spec.rb
deleted file mode 100644
index 9c174a0d..00000000
--- a/spec/services/external/vancouver_city/facility_syncer/error_handling_spec.rb
+++ /dev/null
@@ -1,188 +0,0 @@
-# frozen_string_literal: true
-
-# rubocop:disable RSpec/SpecFilePathFormat
-
-require "rails_helper"
-
-RSpec.describe External::VancouverCity::FacilitySyncer, "#call", type: :service do
- let(:api_key) { "drinking-fountains" }
- let(:service) { create(:water_fountain_service) }
-
- before { service }
-
- describe "transaction rollback scenarios" do
- context "when ActiveRecord::RecordInvalid occurs during external_update" do
- let!(:existing_facility) do
- create(:facility,
- external_id: "FAIL_UPDATE123",
- name: "Test Facility",
- address: "Test Address")
- end
-
- let(:update_record) do
- {
- "mapid" => "FAIL_UPDATE123",
- "name" => "Updated Name",
- "location" => "Updated Location",
- "geo_local_area" => "Updated Area",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- before do
- # Stub update! to raise RecordInvalid to simulate validation failure
- allow(Facility).to receive(:find_by).and_return(existing_facility)
- allow(existing_facility).to receive(:update!).and_raise(
- ActiveRecord::RecordInvalid.new(existing_facility)
- )
- end
-
- it "rolls back transaction and reports error" do
- original_name = existing_facility.name
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- existing_facility.reload
- expect(existing_facility.name).to eq(original_name) # No change due to rollback
- expect(result).to be_failed
- expect(result.errors).to include(a_string_matching(/Failed to save facility/))
- expect(result.data.operation).to eq(:external_update)
- expect(result.data.facility).to be_nil
- end
- end
-
- context "when StandardError occurs during service synchronization" do
- let!(:existing_facility) do
- create(:facility,
- external_id: "SERVICE_ERROR123",
- name: "Test Facility")
- end
-
- let(:update_record) do
- {
- "mapid" => "SERVICE_ERROR123",
- "name" => "Updated Name",
- "location" => "Updated Location",
- "geo_local_area" => "Updated Area",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- before do
- # Stub facility_services.create! to raise StandardError
- allow(Facility).to receive(:find_by).and_return(existing_facility)
- allow(existing_facility.facility_services).to receive(:create!).and_raise(StandardError.new("Database connection lost"))
- end
-
- it "rolls back transaction and reports error" do
- original_service_count = existing_facility.facility_services.count
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- existing_facility.reload
- expect(existing_facility.facility_services.count).to eq(original_service_count)
- expect(result).to be_failed
- expect(result.errors).to include(a_string_matching(/Unexpected error during facility sync.*Database connection lost/))
- expect(result.data.operation).to eq(:external_update)
- expect(result.data.facility).to be_nil
- end
- end
- end
-
- describe "logging behavior during errors" do
- let(:valid_record) do
- {
- "mapid" => "LOG_TEST123",
- "name" => "Test Fountain",
- "location" => "Test Park",
- "geo_local_area" => "Downtown",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- before do
- # Stub save! to raise an error to test logging
- built_facility = build(:facility, external_id: "LOG_TEST123")
- allow(External::VancouverCity::FacilityBuilder).to receive(:call).with(record: valid_record, api_key: api_key).and_return(
- ApplicationService::Result.new(
- data: { facility: built_facility },
- errors: []
- )
- )
- allow(built_facility).to receive(:save!).and_raise(
- ActiveRecord::RecordInvalid.new(build(:facility))
- )
- end
-
- it "logs errors appropriately" do
- allow(Rails.logger).to receive(:info)
-
- syncer = described_class.new(record: valid_record, api_key: api_key)
- syncer.call
-
- expect(Rails.logger).to have_received(:info).with(
- a_string_matching(/Creating new facility with external_id 'LOG_TEST123'/)
- )
- end
- end
-
- describe "error message formatting" do
- context "when FacilityBuilder fails due to validation errors" do
- let(:invalid_facility_record) do
- {
- "mapid" => "INVALID123",
- "name" => "", # Invalid name causes FacilityBuilder to fail
- "location" => "Test Location",
- "geo_local_area" => "Downtown",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "includes detailed validation errors from FacilityBuilder" do
- syncer = described_class.new(record: invalid_facility_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_failed
- expect(result.errors.first).to match(/Name can't be blank/)
- expect(result.data.operation).to be_nil # No operation determined when FacilityBuilder fails
- expect(result.data.facility).to be_nil
- end
- end
-
- context "when ActiveRecord::RecordInvalid provides detailed message" do
- let(:valid_record) do
- {
- "mapid" => "DETAILED_ERROR123",
- "name" => "Test Facility",
- "location" => "Test Location",
- "geo_local_area" => "Downtown",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- before do
- built_facility = build(:facility)
- built_facility.errors.add(:base, "Custom validation error")
-
- allow(External::VancouverCity::FacilityBuilder).to receive(:call).with(record: valid_record, api_key: api_key).and_return(
- ApplicationService::Result.new(
- data: { facility: built_facility },
- errors: []
- )
- )
- allow(built_facility).to receive(:save!).and_raise(
- ActiveRecord::RecordInvalid.new(built_facility)
- )
- end
-
- it "includes the detailed ActiveRecord error message" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_failed
- expect(result.errors).to include(a_string_matching(/Failed to save facility.*Custom validation error/))
- end
- end
- end
-end
-# rubocop:enable RSpec/SpecFilePathFormat
diff --git a/spec/services/external/vancouver_city/facility_syncer/external_update_operation_spec.rb b/spec/services/external/vancouver_city/facility_syncer/external_update_operation_spec.rb
deleted file mode 100644
index e0032968..00000000
--- a/spec/services/external/vancouver_city/facility_syncer/external_update_operation_spec.rb
+++ /dev/null
@@ -1,384 +0,0 @@
-# frozen_string_literal: true
-
-# rubocop:disable RSpec/SpecFilePathFormat
-
-require "rails_helper"
-
-RSpec.describe External::VancouverCity::FacilitySyncer, "#call", type: :service do
- let(:api_key) { "drinking-fountains" }
- let(:service) { create(:water_fountain_service) }
- let(:other_service) { create(:service, key: "public-washrooms") }
-
- before do
- service
- other_service
- end
-
- describe "external_update operation (:external_update)" do
- context "when update succeeds" do
- let!(:existing_external_facility) do
- create(:facility,
- external_id: "EXT_UPDATE123",
- name: "Old Name",
- address: "Old Address",
- lat: 49.0000,
- long: -123.0000,
- verified: false)
- end
-
- let(:update_record) do
- {
- "mapid" => "EXT_UPDATE123",
- "name" => "Updated Fountain Name",
- "location" => "Updated Park",
- "geo_local_area" => "Updated Area",
- "phone" => "604-999-8888",
- "geo_point_2d" => { "lat" => 49.9999, "lon" => -123.9999 }
- }
- end
-
- it "updates facility attributes" do
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- facility = result.data.facility
- expect(facility.name).to eq("Updated Fountain Name")
- expect(facility.address).to eq("Updated Park, Updated Area")
- expect(facility.lat).to eq(49.9999)
- expect(facility.long).to eq(-123.9999)
- expect(facility.verified).to be true
- end
-
- it "adds missing services" do
- expect(existing_external_facility.services).not_to include(service)
-
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- facility = result.data.facility
- expect(facility.services).to include(service)
- end
-
- it "returns existing facility in result" do
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.facility.id).to eq(existing_external_facility.id)
- expect(result.data.operation).to eq(:external_update)
- end
-
- it "logs update message with external_id" do
- allow(Rails.logger).to receive(:info)
-
- syncer = described_class.new(record: update_record, api_key: api_key)
- syncer.call
-
- expect(Rails.logger).to have_received(:info).with("Facility with external_id 'EXT_UPDATE123' already exists, updating services")
- end
-
- it "returns success result" do
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_success
- expect(result.errors).to be_empty
- end
-
- it "does not create new facility" do
- expect do
- syncer = described_class.new(record: update_record, api_key: api_key)
- syncer.call
- end.not_to change(Facility, :count)
- end
- end
-
- context "when facility already has the service" do
- let!(:existing_external_facility) do
- facility = create(:facility,
- external_id: "EXT_HAS_SERVICE123",
- name: "Fountain with Service")
- facility.facility_services.create!(service: service)
- facility
- end
-
- let(:update_record) do
- {
- "mapid" => "EXT_HAS_SERVICE123",
- "name" => "Updated Name",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "does not duplicate existing services" do
- initial_service_count = existing_external_facility.facility_services.count
-
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- facility = result.data.facility
- expect(facility.facility_services.count).to eq(initial_service_count)
- end
-
- it "still updates facility attributes" do
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- facility = result.data.facility
- expect(facility.name).to eq("Updated Name")
- end
- end
-
- context "when update! raises ActiveRecord::RecordInvalid during attribute update" do
- let!(:existing_external_facility) do
- create(:facility,
- external_id: "EXT_INVALID123",
- name: "Test Facility")
- end
-
- let(:update_record) do
- {
- "mapid" => "EXT_INVALID123",
- "name" => "Updated Name",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- before do
- # Simulate a validation error during update
- allow(Facility).to receive(:find_by).and_return(existing_external_facility)
- allow(existing_external_facility).to receive(:update!).and_raise(
- ActiveRecord::RecordInvalid.new(existing_external_facility)
- )
- end
-
- it "catches exception during attribute update" do
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_failed
- expect(result.errors).to include(a_string_matching(/Failed to save facility:/))
- end
- end
-
- context "when create! raises ActiveRecord::RecordInvalid during service creation" do
- let(:update_record) do
- {
- "mapid" => "EXT_SERVICE_ERROR123",
- "name" => "Updated Name",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- before do
- existing_facility = create(:facility,
- external_id: "EXT_SERVICE_ERROR123",
- name: "Test Facility")
- # Simulate a constraint violation when creating facility service
- allow(Facility).to receive(:find_by).and_return(existing_facility)
- allow(existing_facility.facility_services).to receive(:create!).and_raise(
- ActiveRecord::RecordInvalid.new(FacilityService.new)
- )
- end
-
- it "catches exception during service creation" do
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_failed
- expect(result.errors).to include(a_string_matching(/Failed to save facility:/))
- end
- end
-
- context "when update raises other StandardError" do
- let!(:existing_external_facility) do
- create(:facility,
- external_id: "EXT_STD_ERROR123",
- name: "Test Facility")
- end
-
- let(:update_record) do
- {
- "mapid" => "EXT_STD_ERROR123",
- "name" => "Updated Name",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- before do
- # Force service creation to fail during add_missing_services
- allow(Facility).to receive(:find_by).and_return(existing_external_facility)
- allow(existing_external_facility.facility_services).to receive(:create!).and_raise(StandardError.new("Service creation failed"))
- end
-
- it "catches and handles generic errors" do
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_failed
- expect(result.errors).to include(a_string_matching(/Unexpected error during facility sync:/))
- expect(result.errors.first).to include("Service creation failed")
- end
-
- it "does not update facility attributes on error" do
- original_name = existing_external_facility.name
- original_address = existing_external_facility.address
-
- syncer = described_class.new(record: update_record, api_key: api_key)
- syncer.call
-
- existing_external_facility.reload
- expect(existing_external_facility.name).to eq(original_name)
- expect(existing_external_facility.address).to eq(original_address)
- end
-
- it "does not create any new service records on error" do
- expect do
- syncer = described_class.new(record: update_record, api_key: api_key)
- syncer.call
- end.not_to change(FacilityService, :count)
- end
- end
-
- context "when database record updates on success" do
- let!(:external_facility_with_data) do
- facility = create(:facility,
- external_id: "DB_UPDATE123",
- name: "Original Name",
- address: "Original Address",
- lat: 49.0000,
- long: -123.0000,
- verified: false)
-
- # Add existing service from different API
- facility.facility_services.create!(service: other_service)
- facility
- end
-
- let(:comprehensive_update_record) do
- {
- "mapid" => "DB_UPDATE123",
- "name" => "Completely Updated Name",
- "location" => "New Location",
- "geo_local_area" => "New Area",
- "phone" => "604-555-1234",
- "website" => "https://updated.example.com",
- "geo_point_2d" => { "lat" => 49.5555, "lon" => -123.5555 }
- }
- end
-
- it "updates all facility attributes correctly" do
- syncer = described_class.new(record: comprehensive_update_record, api_key: api_key)
- result = syncer.call
-
- facility = result.data.facility
- # Only these attributes are updated in external_update operations
- expect(facility.name).to eq("Completely Updated Name")
- expect(facility.address).to eq("New Location, New Area")
- expect(facility.lat).to eq(49.5555)
- expect(facility.long).to eq(-123.5555)
- expect(facility.verified).to be true
- expect(facility.external_id).to eq("DB_UPDATE123") # Should remain unchanged
-
- # These attributes are NOT updated in external_update operations
- expect(facility.phone).to eq("123") # Original value from factory
- expect(facility.website).to eq("www.facility.test") # Original value from factory
- end
-
- it "adds new service without removing existing ones" do
- initial_service_count = external_facility_with_data.facility_services.count
-
- syncer = described_class.new(record: comprehensive_update_record, api_key: api_key)
- result = syncer.call
-
- facility = result.data.facility
- expect(facility.facility_services.count).to eq(initial_service_count + 1)
- expect(facility.services).to include(service) # New service added
- expect(facility.services).to include(other_service) # Existing service preserved
- end
-
- it "maintains referential integrity during updates" do
- syncer = described_class.new(record: comprehensive_update_record, api_key: api_key)
- result = syncer.call
-
- facility = result.data.facility
-
- # Verify all related records still reference the correct facility
- expect(facility.facility_services.all? { |fs| fs.facility_id == facility.id }).to be true
- expect(facility.schedules.all? { |s| s.facility_id == facility.id }).to be true
- expect(facility.facility_welcomes.all? { |fw| fw.facility_id == facility.id }).to be true
- end
-
- it "does not create duplicate services for same API key" do
- # First update
- syncer1 = described_class.new(record: comprehensive_update_record, api_key: api_key)
- syncer1.call
-
- initial_count = external_facility_with_data.reload.facility_services.count
-
- # Second update with same API key
- syncer2 = described_class.new(record: comprehensive_update_record, api_key: api_key)
- syncer2.call
-
- external_facility_with_data.reload
- expect(external_facility_with_data.facility_services.count).to eq(initial_count)
- end
- end
-
- context "when transaction rollback on failure" do
- let!(:rollback_facility) do
- create(:facility,
- external_id: "ROLLBACK123",
- name: "Rollback Test",
- address: "Original Address",
- verified: false)
- end
-
- let(:rollback_record) do
- {
- "mapid" => "ROLLBACK123",
- "name" => "Updated Name",
- "location" => "Updated Location",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- before do
- # Force failure after attribute update but before service creation
- allow(Facility).to receive(:find_by).and_return(rollback_facility)
- allow(rollback_facility.facility_services).to receive(:create!).and_raise(StandardError.new("Service creation failed"))
- end
-
- it "rolls back attribute changes when service creation fails" do
- original_name = rollback_facility.name
- original_address = rollback_facility.address
- original_verified = rollback_facility.verified
-
- syncer = described_class.new(record: rollback_record, api_key: api_key)
- syncer.call
-
- rollback_facility.reload
- expect(rollback_facility.name).to eq(original_name)
- expect(rollback_facility.address).to eq(original_address)
- expect(rollback_facility.verified).to eq(original_verified)
- end
-
- it "does not create any service records when transaction fails" do
- expect do
- syncer = described_class.new(record: rollback_record, api_key: api_key)
- syncer.call
- end.not_to change(FacilityService, :count)
- end
-
- it "maintains database consistency after rollback" do
- original_service_count = rollback_facility.facility_services.count
-
- syncer = described_class.new(record: rollback_record, api_key: api_key)
- syncer.call
-
- rollback_facility.reload
- expect(rollback_facility.facility_services.count).to eq(original_service_count)
- end
- end
- end
-end
-# rubocop:enable RSpec/SpecFilePathFormat
diff --git a/spec/services/external/vancouver_city/facility_syncer/facility_builder_integration_spec.rb b/spec/services/external/vancouver_city/facility_syncer/facility_builder_integration_spec.rb
deleted file mode 100644
index 63d59a78..00000000
--- a/spec/services/external/vancouver_city/facility_syncer/facility_builder_integration_spec.rb
+++ /dev/null
@@ -1,113 +0,0 @@
-# frozen_string_literal: true
-
-# rubocop:disable RSpec/SpecFilePathFormat
-
-require "rails_helper"
-
-RSpec.describe External::VancouverCity::FacilitySyncer, "#call", type: :service do
- let(:api_key) { "drinking-fountains" }
- let(:service) { create(:water_fountain_service) }
-
- before { service } # Ensure service exists
-
- describe "FacilityBuilder integration" do
- context "when FacilityBuilder succeeds with valid facility" do
- let(:valid_record) do
- {
- "mapid" => "12345",
- "name" => "Test Fountain",
- "location" => "Test Park",
- "geo_local_area" => "Downtown",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "proceeds with sync operations" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_success
- expect(result.data.operation).to eq(:create)
- expect(result.data.facility).to be_present
- end
-
- it "facility is created and persisted" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.facility).to be_persisted
- expect(result.data.facility.name).to eq("Test Fountain")
- expect(result.data.facility.external_id).to eq("12345")
- end
- end
-
- context "when FacilityBuilder fails due to invalid record" do
- let(:invalid_record) do
- {
- # Missing required fields like name and coordinates
- }
- end
-
- it "returns early with FacilityBuilder errors" do
- syncer = described_class.new(record: invalid_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_failed
- expect(result.errors).to be_present
- end
-
- it "returns ResultData with operation: nil, facility: nil" do
- syncer = described_class.new(record: invalid_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.operation).to be_nil
- expect(result.data.facility).to be_nil
- end
-
- it "does not attempt database operations" do
- allow(Facility).to receive(:where)
-
- syncer = described_class.new(record: invalid_record, api_key: api_key)
- syncer.call
-
- expect(Facility).not_to have_received(:where)
- end
- end
-
- context "when FacilityBuilder fails due to invalid facility data" do
- # This scenario occurs when FacilityBuilder receives data that would create
- # an invalid facility, so it fails validation and returns errors
- let(:record_with_invalid_facility_data) do
- {
- "mapid" => "12345",
- "name" => "", # Empty name will make facility invalid
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "returns early with validation errors" do
- syncer = described_class.new(record: record_with_invalid_facility_data, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_failed
- expect(result.data.operation).to be_nil # No operation determined
- expect(result.data.facility).to be_nil # No facility created
- end
-
- it "includes FacilityBuilder validation errors" do
- syncer = described_class.new(record: record_with_invalid_facility_data, api_key: api_key)
- result = syncer.call
-
- expect(result.errors).to include(a_string_matching(/can't be blank/i))
- end
-
- it "does not attempt to save anything" do
- expect do
- syncer = described_class.new(record: record_with_invalid_facility_data, api_key: api_key)
- syncer.call
- end.not_to change(Facility, :count)
- end
- end
- end
-end
-# rubocop:enable RSpec/SpecFilePathFormat
diff --git a/spec/services/external/vancouver_city/facility_syncer/initialize_spec.rb b/spec/services/external/vancouver_city/facility_syncer/initialize_spec.rb
deleted file mode 100644
index d4e2df0b..00000000
--- a/spec/services/external/vancouver_city/facility_syncer/initialize_spec.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-require "rails_helper"
-
-RSpec.describe External::VancouverCity::FacilitySyncer, "#initialize", type: :service do
- describe "#initialize" do
- let(:record) { { "name" => "Test Facility" } }
- let(:api_key) { "test-api-key" }
-
- it "sets record and api_key" do
- syncer = described_class.new(record: record, api_key: api_key)
-
- expect(syncer.record).to eq(record)
- expect(syncer.api_key).to eq(api_key)
- end
-
- it "inherits from ApplicationService" do
- syncer = described_class.new(record: record, api_key: api_key)
-
- expect(syncer).to be_a(ApplicationService)
- end
-
- it "responds to call method" do
- syncer = described_class.new(record: record, api_key: api_key)
-
- expect(syncer).to respond_to(:call)
- end
- end
-end
diff --git a/spec/services/external/vancouver_city/facility_syncer/integration_scenarios_spec.rb b/spec/services/external/vancouver_city/facility_syncer/integration_scenarios_spec.rb
deleted file mode 100644
index a250876f..00000000
--- a/spec/services/external/vancouver_city/facility_syncer/integration_scenarios_spec.rb
+++ /dev/null
@@ -1,235 +0,0 @@
-# frozen_string_literal: true
-
-# rubocop:disable RSpec/SpecFilePathFormat
-
-require "rails_helper"
-
-RSpec.describe External::VancouverCity::FacilitySyncer, "#call", type: :service do
- let(:api_key) { "drinking-fountains" }
- let(:service) { create(:water_fountain_service) }
- let(:secondary_service) { create(:service, key: "public-washrooms") }
-
- before do
- service
- secondary_service
- end
-
- describe "complex data integration" do
- context "with facility with comprehensive data" do
- let(:comprehensive_record) do
- {
- "mapid" => "COMPREHENSIVE123",
- "name" => "Downtown Community Fountain",
- "location" => "Central Plaza",
- "geo_local_area" => "Downtown Vancouver",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 },
- "phone" => "604-123-4567",
- "website" => "https://vancouver.ca/fountains",
- "maintainer" => "City of Vancouver",
- "in_operation" => "Yes",
- "pet_friendly" => "True"
- }
- end
-
- it "creates facility with all available attributes" do
- syncer = described_class.new(record: comprehensive_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_success
- facility = result.data.facility
-
- expect(facility.external_id).to eq("COMPREHENSIVE123")
- expect(facility.name).to eq("Downtown Community Fountain")
- expect(facility.address).to eq("Central Plaza, Downtown Vancouver")
- expect(facility.lat).to eq(49.2827)
- expect(facility.long).to eq(-123.1207)
- expect(facility.phone).to eq("604-123-4567")
- expect(facility.website).to eq("https://vancouver.ca/fountains")
- expect(facility.verified).to be true
- expect(facility.external?).to be true
- end
-
- it "creates associated services, schedules, and welcomes" do
- syncer = described_class.new(record: comprehensive_record, api_key: api_key)
- result = syncer.call
-
- facility = result.data.facility
-
- # Services
- expect(facility.facility_services.count).to eq(1)
- expect(facility.services.first.key).to eq("water_fountain")
-
- # Schedules - should have open-all-day for all weekdays
- expect(facility.schedules.count).to eq(7)
- facility.schedules.each do |schedule|
- expect(schedule.open_all_day).to be true
- expect(schedule.closed_all_day).to be false
- end
-
- # Welcomes - should welcome all customer types
- expect(facility.facility_welcomes.count).to be > 0
- end
- end
-
- context "with facility with minimal valid data" do
- let(:minimal_record) do
- {
- "mapid" => "MINIMAL123",
- "name" => "Basic Fountain",
- "geo_point_2d" => { "lat" => 49.0, "lon" => -123.0 }
- }
- end
-
- it "creates facility with defaults for missing optional fields" do
- syncer = described_class.new(record: minimal_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_success
- facility = result.data.facility
-
- expect(facility.external_id).to eq("MINIMAL123")
- expect(facility.name).to eq("Basic Fountain")
- expect(facility.lat).to eq(49.0)
- expect(facility.long).to eq(-123.0)
- expect(facility.verified).to be true
- expect(facility.external?).to be true
- end
- end
- end
-
- describe "edge case scenarios" do
- context "with facility with special characters in name" do
- let(:special_chars_record) do
- {
- "mapid" => "SPECIAL123",
- "name" => "O'Brien's Water Fountain & Rest Area",
- "location" => "Québec Street",
- "geo_local_area" => "Mount Pleasant",
- "geo_point_2d" => { "lat" => 49.2627, "lon" => -123.1007 }
- }
- end
-
- it "handles special characters correctly" do
- syncer = described_class.new(record: special_chars_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_success
- facility = result.data.facility
-
- expect(facility.name).to eq("O'Brien's Water Fountain & Rest Area")
- expect(facility.address).to eq("Québec Street, Mount Pleasant")
- end
- end
-
- context "with facility at edge coordinates" do
- let(:edge_coords_record) do
- {
- "mapid" => "EDGE123",
- "name" => "Edge Case Fountain",
- "location" => "Boundary Road",
- "geo_local_area" => "Boundary",
- "geo_point_2d" => { "lat" => 90.0, "lon" => -180.0 } # Edge coordinates
- }
- end
-
- it "handles edge coordinate values" do
- syncer = described_class.new(record: edge_coords_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_success
- facility = result.data.facility
-
- expect(facility.lat).to eq(90.0)
- expect(facility.long).to eq(-180.0)
- end
- end
- end
-
- describe "concurrent operation simulation" do
- context "when the same external_id is processed simultaneously" do
- let(:first_concurrent_record) do
- {
- "mapid" => "CONCURRENT123",
- "name" => "First Version Fountain",
- "location" => "First Location",
- "geo_local_area" => "Downtown",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- let(:second_concurrent_record) do
- {
- "mapid" => "CONCURRENT123",
- "name" => "Second Version Fountain",
- "location" => "Second Location",
- "geo_local_area" => "Westside",
- "geo_point_2d" => { "lat" => 49.2727, "lon" => -123.1107 }
- }
- end
-
- it "handles duplicate external_id creation gracefully" do
- # First sync
- syncer1 = described_class.new(record: first_concurrent_record, api_key: api_key)
- result1 = syncer1.call
-
- expect(result1).to be_success
- expect(result1.data.operation).to eq(:create)
-
- # Second sync with same external_id but different data
- syncer2 = described_class.new(record: second_concurrent_record, api_key: api_key)
- result2 = syncer2.call
-
- expect(result2).to be_success
- expect(result2.data.operation).to eq(:external_update)
-
- # Verify final state
- facility = Facility.find_by(external_id: "CONCURRENT123")
- expect(facility.name).to eq("Second Version Fountain")
- expect(facility.address).to eq("Second Location, Westside")
- end
- end
- end
-
- describe "data consistency verification" do
- let(:consistency_record) do
- {
- "mapid" => "CONSISTENCY123",
- "name" => "Consistency Test Fountain",
- "location" => "Test Park",
- "geo_local_area" => "Test Area",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "ensures data integrity across all related models" do
- syncer = described_class.new(record: consistency_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_success
- facility = result.data.facility
-
- # Verify facility
- expect(facility).to be_persisted
- expect(facility.external_id).to eq("CONSISTENCY123")
-
- # Verify services
- expect(facility.facility_services.count).to eq(1)
- expect(facility.facility_services.first.service.key).to eq("water_fountain")
-
- # Verify schedules
- expect(facility.schedules.count).to eq(7)
- facility.schedules.each do |schedule|
- expect(schedule.facility_id).to eq(facility.id)
- expect(schedule).to be_persisted
- end
-
- # Verify welcomes
- expect(facility.facility_welcomes.count).to be > 0
- facility.facility_welcomes.each do |welcome|
- expect(welcome.facility_id).to eq(facility.id)
- expect(welcome).to be_persisted
- end
- end
- end
-end
-# rubocop:enable RSpec/SpecFilePathFormat
diff --git a/spec/services/external/vancouver_city/facility_syncer/internal_update_operation_spec.rb b/spec/services/external/vancouver_city/facility_syncer/internal_update_operation_spec.rb
deleted file mode 100644
index ad075c0b..00000000
--- a/spec/services/external/vancouver_city/facility_syncer/internal_update_operation_spec.rb
+++ /dev/null
@@ -1,446 +0,0 @@
-# frozen_string_literal: true
-
-# rubocop:disable RSpec/SpecFilePathFormat
-
-require "rails_helper"
-
-RSpec.describe External::VancouverCity::FacilitySyncer, "#call", type: :service do
- let(:api_key) { "drinking-fountains" }
- let(:service) { create(:water_fountain_service) }
- let(:other_service) { create(:service, key: "public-washrooms") }
-
- before do
- service
- other_service
- end
-
- describe "internal_update operation (:internal_update)" do
- context "when update succeeds" do
- let!(:existing_internal_facility) do
- create(:facility,
- external_id: nil,
- name: "Internal Fountain",
- address: "Original Address",
- lat: 49.1111,
- long: -123.1111,
- verified: false)
- end
-
- let(:update_record) do
- {
- "mapid" => "NEW_EXT_ID123",
- "name" => "Internal Fountain", # Matches by name
- "location" => "Different Location",
- "geo_local_area" => "Different Area",
- "geo_point_2d" => { "lat" => 49.9999, "lon" => -123.9999 }
- }
- end
-
- it "adds missing services only" do
- expect(existing_internal_facility.services).not_to include(service)
-
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- facility = result.data.facility
- expect(facility.services).to include(service)
- end
-
- it "does not update facility attributes" do
- original_name = existing_internal_facility.name
- original_address = existing_internal_facility.address
- original_lat = existing_internal_facility.lat
- original_long = existing_internal_facility.long
- original_verified = existing_internal_facility.verified
-
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- facility = result.data.facility
- expect(facility.name).to eq(original_name)
- expect(facility.address).to eq(original_address)
- expect(facility.lat).to eq(original_lat)
- expect(facility.long).to eq(original_long)
- expect(facility.verified).to eq(original_verified)
- end
-
- it "returns existing facility in result" do
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.facility.id).to eq(existing_internal_facility.id)
- expect(result.data.operation).to eq(:internal_update)
- end
-
- it "logs warning message with facility name" do
- allow(Rails.logger).to receive(:warn)
-
- syncer = described_class.new(record: update_record, api_key: api_key)
- syncer.call
-
- expect(Rails.logger).to have_received(:warn).with("Facility with name 'Internal Fountain' already exists internally, adding services")
- end
-
- it "returns success result" do
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_success
- expect(result.errors).to be_empty
- end
-
- it "does not create new facility" do
- expect do
- syncer = described_class.new(record: update_record, api_key: api_key)
- syncer.call
- end.not_to change(Facility, :count)
- end
- end
-
- context "when facility already has the service" do
- let!(:existing_internal_facility) do
- facility = create(:facility,
- external_id: nil,
- name: "Fountain with Service",
- verified: false)
- facility.facility_services.create!(service: service)
- facility
- end
-
- let(:update_record) do
- {
- "mapid" => "SOME_ID123",
- "name" => "Fountain with Service",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "does not duplicate existing services" do
- initial_service_count = existing_internal_facility.facility_services.count
-
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- facility = result.data.facility
- expect(facility.facility_services.count).to eq(initial_service_count)
- end
-
- it "still succeeds even with no new services to add" do
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_success
- expect(result.data.operation).to eq(:internal_update)
- end
- end
-
- context "when service creation raises ActiveRecord::RecordInvalid" do
- let(:update_record) do
- {
- "mapid" => "ERROR_ID123",
- "name" => "Service Error Fountain",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- before do
- existing_facility = create(:facility,
- external_id: nil,
- name: "Service Error Fountain",
- verified: false)
-
- allow(Facility).to receive(:where).and_call_original
- allow(Facility).to receive(:where).with(name: "Service Error Fountain").and_return(
- instance_double(ActiveRecord::Relation, order: instance_double(ActiveRecord::Relation, first: existing_facility))
- )
- allow(existing_facility.facility_services).to receive(:create!).and_raise(
- ActiveRecord::RecordInvalid.new(FacilityService.new)
- )
- end
-
- it "catches exception and adds error message" do
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_failed
- expect(result.errors).to include(a_string_matching(/Failed to save facility:/))
- end
- end
-
- context "when update raises other StandardError" do
- let(:update_record) do
- {
- "mapid" => "GENERIC_ERROR123",
- "name" => "Generic Error Fountain",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- before do
- existing_facility = create(:facility,
- external_id: nil,
- name: "Generic Error Fountain",
- verified: false)
-
- allow(Facility).to receive(:where).and_call_original
- allow(Facility).to receive(:where).with(name: "Generic Error Fountain").and_return(
- instance_double(ActiveRecord::Relation, order: instance_double(ActiveRecord::Relation, first: existing_facility))
- )
- allow(existing_facility.facility_services).to receive(:create!).and_raise(
- StandardError.new("Database connection failed")
- )
- end
-
- it "catches and handles generic errors" do
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_failed
- expect(result.errors).to include(a_string_matching(/Unexpected error during facility sync:/))
- expect(result.errors.first).to include("Database connection failed")
- end
- end
-
- context "when record would create new facility but matches internal by name" do
- let!(:existing_internal_facility) do
- create(:facility,
- external_id: nil,
- name: "Exact Name Match",
- verified: false)
- end
-
- let(:new_record_matching_name) do
- {
- "mapid" => "COMPLETELY_NEW_ID",
- "name" => "Exact Name Match", # Same name but would have different external_id
- "location" => "New Location",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "treats as internal update rather than create" do
- syncer = described_class.new(record: new_record_matching_name, api_key: api_key)
- result = syncer.call
-
- expect(result.data.operation).to eq(:internal_update)
- expect(result.data.facility.id).to eq(existing_internal_facility.id)
- end
-
- it "does not change facility external_id" do
- syncer = described_class.new(record: new_record_matching_name, api_key: api_key)
- result = syncer.call
-
- facility = result.data.facility
- expect(facility.external_id).to be_nil # Should remain nil
- end
- end
-
- context "when database record updates on success" do
- let!(:internal_facility_with_services) do
- facility = create(:facility,
- external_id: nil,
- name: "Internal Service Test",
- address: "Original Internal Address",
- verified: false)
-
- # Add existing service from different API
- facility.facility_services.create!(service: other_service)
- facility
- end
-
- let(:internal_service_update_record) do
- {
- "mapid" => "NEW_EXTERNAL_ID456",
- "name" => "Internal Service Test", # Matches by name
- "location" => "Different Location", # Should NOT update
- "geo_point_2d" => { "lat" => 49.9999, "lon" => -123.9999 } # Should NOT update
- }
- end
-
- it "adds new service without modifying facility attributes" do
- original_name = internal_facility_with_services.name
- original_address = internal_facility_with_services.address
- original_lat = internal_facility_with_services.lat
- original_long = internal_facility_with_services.long
- original_verified = internal_facility_with_services.verified
- original_external_id = internal_facility_with_services.external_id
-
- syncer = described_class.new(record: internal_service_update_record, api_key: api_key)
- result = syncer.call
-
- facility = result.data.facility
-
- # Verify attributes remain unchanged
- expect(facility.name).to eq(original_name)
- expect(facility.address).to eq(original_address)
- expect(facility.lat).to eq(original_lat)
- expect(facility.long).to eq(original_long)
- expect(facility.verified).to eq(original_verified)
- expect(facility.external_id).to eq(original_external_id)
- end
-
- it "adds new service while preserving existing ones" do
- initial_service_count = internal_facility_with_services.facility_services.count
-
- syncer = described_class.new(record: internal_service_update_record, api_key: api_key)
- result = syncer.call
-
- facility = result.data.facility
- expect(facility.facility_services.count).to eq(initial_service_count + 1)
- expect(facility.services).to include(service) # New service added
- expect(facility.services).to include(other_service) # Existing service preserved
- end
-
- it "maintains referential integrity when adding services" do
- syncer = described_class.new(record: internal_service_update_record, api_key: api_key)
- result = syncer.call
-
- facility = result.data.facility
-
- # Verify all services belong to the correct facility
- expect(facility.facility_services.all? { |fs| fs.facility_id == facility.id }).to be true
-
- # Verify the new service was added correctly
- new_service_record = facility.facility_services.find_by(service: service)
- expect(new_service_record).to be_present
- expect(new_service_record.facility_id).to eq(facility.id)
- end
-
- it "does not create duplicate services for same API key" do
- # First update
- syncer1 = described_class.new(record: internal_service_update_record, api_key: api_key)
- syncer1.call
-
- initial_count = internal_facility_with_services.reload.facility_services.count
-
- # Second update with same API key
- syncer2 = described_class.new(record: internal_service_update_record, api_key: api_key)
- syncer2.call
-
- internal_facility_with_services.reload
- expect(internal_facility_with_services.facility_services.count).to eq(initial_count)
- end
- end
-
- context "when transaction rollback on failure" do
- let!(:rollback_internal_facility) do
- facility = create(:facility,
- external_id: nil,
- name: "Rollback Internal Test",
- verified: false)
-
- # Add existing service
- facility.facility_services.create!(service: other_service)
- facility
- end
-
- let(:rollback_internal_record) do
- {
- "mapid" => "ROLLBACK_INTERNAL123",
- "name" => "Rollback Internal Test",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- before do
- allow(Facility).to receive(:where).and_call_original
- allow(Facility).to receive(:where).with(name: "Rollback Internal Test").and_return(
- instance_double(ActiveRecord::Relation, order: instance_double(ActiveRecord::Relation, first: rollback_internal_facility))
- )
- allow(rollback_internal_facility.facility_services).to receive(:create!).and_raise(StandardError.new("Service creation failed"))
- end
-
- it "does not create any service records when transaction fails" do
- original_service_count = rollback_internal_facility.facility_services.count
-
- expect do
- syncer = described_class.new(record: rollback_internal_record, api_key: api_key)
- syncer.call
- end.not_to change(FacilityService, :count)
-
- rollback_internal_facility.reload
- expect(rollback_internal_facility.facility_services.count).to eq(original_service_count)
- end
-
- it "maintains existing facility state when service addition fails" do
- original_attributes = rollback_internal_facility.attributes
- original_service_ids = rollback_internal_facility.facility_services.pluck(:service_id)
-
- syncer = described_class.new(record: rollback_internal_record, api_key: api_key)
- result = syncer.call
-
- rollback_internal_facility.reload
-
- # Verify facility attributes unchanged
- # Compare all attributes, allowing updated_at and created_at to be within a small delta
- expect(rollback_internal_facility.attributes.except("updated_at", "created_at")).to eq(original_attributes.except("updated_at", "created_at"))
- expect(rollback_internal_facility.updated_at).to be_within(2.seconds).of(original_attributes["updated_at"])
- expect(rollback_internal_facility.created_at).to be_within(2.seconds).of(original_attributes["created_at"])
-
- # Verify existing services unchanged
- expect(rollback_internal_facility.facility_services.pluck(:service_id)).to match_array(original_service_ids)
-
- expect(result).to be_failed
- end
-
- it "does not affect other facilities when one fails" do
- other_facility = create(:facility, external_id: nil, name: "Other Facility")
-
- expect do
- syncer = described_class.new(record: rollback_internal_record, api_key: api_key)
- syncer.call
- end.not_to(change { other_facility.reload.facility_services.count })
- end
- end
-
- context "when validation error handling" do
- let!(:validation_internal_facility) do
- create(:facility,
- external_id: nil,
- name: "Validation Test Facility",
- verified: false)
- end
-
- let(:validation_record) do
- {
- "mapid" => "VALIDATION123",
- "name" => "Validation Test Facility",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- before do
- allow(Facility).to receive(:where).and_call_original
- allow(Facility).to receive(:where).with(name: "Validation Test Facility").and_return(
- instance_double(ActiveRecord::Relation, order: instance_double(ActiveRecord::Relation, first: validation_internal_facility))
- )
- allow(validation_internal_facility.facility_services).to receive(:create!).and_raise(
- ActiveRecord::RecordInvalid.new(FacilityService.new)
- )
- end
-
- it "does not modify facility when service validation fails" do
- original_service_count = validation_internal_facility.facility_services.count
- original_updated_at = validation_internal_facility.updated_at
-
- syncer = described_class.new(record: validation_record, api_key: api_key)
- syncer.call
-
- validation_internal_facility.reload
- expect(validation_internal_facility.facility_services.count).to eq(original_service_count)
- expect(validation_internal_facility.updated_at).to be_within(2.seconds).of(original_updated_at)
- end
-
- it "returns proper error information for validation failures" do
- syncer = described_class.new(record: validation_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_failed
- expect(result.errors).to include(a_string_matching(/Failed to save facility:/))
- expect(result.data.operation).to eq(:internal_update)
- expect(result.data.facility).to be_nil # Should be nil when operation fails
- end
- end
- end
-end
-# rubocop:enable RSpec/SpecFilePathFormat
diff --git a/spec/services/external/vancouver_city/facility_syncer/operation_detection_spec.rb b/spec/services/external/vancouver_city/facility_syncer/operation_detection_spec.rb
deleted file mode 100644
index 14841666..00000000
--- a/spec/services/external/vancouver_city/facility_syncer/operation_detection_spec.rb
+++ /dev/null
@@ -1,163 +0,0 @@
-# frozen_string_literal: true
-
-# rubocop:disable RSpec/SpecFilePathFormat
-
-require "rails_helper"
-
-RSpec.describe External::VancouverCity::FacilitySyncer, "#call", type: :service do
- let(:api_key) { "drinking-fountains" }
- let(:service) { create(:water_fountain_service) }
-
- before { service } # Ensure service exists
-
- describe "operation detection" do
- context "when no existing facility found" do
- let(:new_facility_record) do
- {
- "mapid" => "NEW123",
- "name" => "Brand New Fountain",
- "location" => "New Park",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "sets operation to :create" do
- syncer = described_class.new(record: new_facility_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.operation).to eq(:create)
- end
-
- it "creates a new facility" do
- expect do
- syncer = described_class.new(record: new_facility_record, api_key: api_key)
- syncer.call
- end.to change(Facility, :count).by(1)
- end
- end
-
- context "when existing facility has external_id" do
- let!(:existing_external_facility) do
- create(:facility,
- :with_verified,
- external_id: "EXT123",
- name: "External Fountain")
- end
-
- let(:update_record) do
- {
- "mapid" => "EXT123",
- "name" => "Updated External Fountain",
- "location" => "Updated Park",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "sets operation to :external_update" do
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.operation).to eq(:external_update)
- end
-
- it "returns the existing facility" do
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.facility.id).to eq(existing_external_facility.id)
- end
-
- it "does not create a new facility" do
- expect do
- syncer = described_class.new(record: update_record, api_key: api_key)
- syncer.call
- end.not_to change(Facility, :count)
- end
- end
-
- context "when existing facility found by name only" do
- let!(:existing_internal_facility) do
- create(:facility,
- external_id: nil,
- name: "Internal Fountain",
- verified: false)
- end
-
- let(:name_match_record) do
- {
- "mapid" => "NEW456",
- "name" => "Internal Fountain", # Matches existing facility name
- "location" => "Same Park",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "sets operation to :internal_update" do
- syncer = described_class.new(record: name_match_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.operation).to eq(:internal_update)
- end
-
- it "returns the existing facility" do
- syncer = described_class.new(record: name_match_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.facility.id).to eq(existing_internal_facility.id)
- end
-
- it "does not create a new facility" do
- expect do
- syncer = described_class.new(record: name_match_record, api_key: api_key)
- syncer.call
- end.not_to change(Facility, :count)
- end
- end
-
- context "with complex matching scenarios" do
- let!(:facility_with_external_id) do
- create(:facility,
- :with_verified,
- external_id: "EXT789",
- name: "Shared Name Fountain")
- end
-
- let!(:facility_with_same_name) do
- create(:facility,
- external_id: nil,
- name: "Shared Name Fountain",
- verified: false)
- end
-
- it "prioritizes external_id match over name match" do
- record = {
- "mapid" => "EXT789",
- "name" => "Shared Name Fountain",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
-
- syncer = described_class.new(record: record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.operation).to eq(:external_update)
- expect(result.data.facility.id).to eq(facility_with_external_id.id)
- end
-
- it "handles facilities with same name but different external_id" do
- record = {
- "mapid" => "DIFFERENT123",
- "name" => "Shared Name Fountain",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
-
- syncer = described_class.new(record: record, api_key: api_key)
- result = syncer.call
-
- # Should match by name since external_id is different
- expect(result.data.operation).to eq(:internal_update)
- expect(result.data.facility.id).to eq(facility_with_same_name.id)
- end
- end
- end
-end
-# rubocop:enable RSpec/SpecFilePathFormat
diff --git a/spec/services/external/vancouver_city/facility_syncer/result_structure_spec.rb b/spec/services/external/vancouver_city/facility_syncer/result_structure_spec.rb
deleted file mode 100644
index e73297d4..00000000
--- a/spec/services/external/vancouver_city/facility_syncer/result_structure_spec.rb
+++ /dev/null
@@ -1,261 +0,0 @@
-# frozen_string_literal: true
-
-# rubocop:disable RSpec/SpecFilePathFormat
-
-require "rails_helper"
-
-RSpec.describe External::VancouverCity::FacilitySyncer, "#call", type: :service do
- let(:api_key) { "drinking-fountains" }
- let(:service) { create(:water_fountain_service) }
-
- before { service }
-
- describe "ResultData structure" do
- let(:valid_record) do
- {
- "mapid" => "RESULT123",
- "name" => "Test Fountain",
- "location" => "Test Park",
- "geo_local_area" => "Downtown",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "returns ResultData with operation and facility" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data).to be_a(External::VancouverCity::FacilitySyncer::ResultData)
- expect(result.data).to respond_to(:operation)
- expect(result.data).to respond_to(:facility)
- end
-
- it "delegates present? and blank? to facility" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
- result = syncer.call
-
- # When facility is present
- expect(result.data.present?).to be true
- expect(result.data.blank?).to be false
- end
-
- context "when FacilityBuilder fails with empty name" do
- let(:invalid_record) do
- {
- "mapid" => "INVALID123",
- "name" => "", # Empty name causes FacilityBuilder to fail
- "location" => "Test Location",
- "geo_local_area" => "Downtown",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "ResultData reflects early failure state" do
- syncer = described_class.new(record: invalid_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.operation).to be_nil # No operation determined when FacilityBuilder fails
- expect(result.data.facility).to be_nil
- expect(result.data.blank?).to be true
- expect(result.data.present?).to be false
- end
- end
-
- context "when FacilityBuilder fails with nil mapid" do
- let(:malformed_record) do
- {
- "mapid" => nil,
- "location" => "Test Location"
- }
- end
-
- it "ResultData shows nil operation and facility" do
- syncer = described_class.new(record: malformed_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.operation).to be_nil
- expect(result.data.facility).to be_nil
- expect(result.data.blank?).to be true
- expect(result.data.present?).to be false
- end
- end
- end
-
- describe "Result object compliance with ApplicationService::Result" do
- let(:valid_record) do
- {
- "mapid" => "COMPLIANCE123",
- "name" => "Test Fountain",
- "location" => "Test Park",
- "geo_local_area" => "Downtown",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "returns ApplicationService::Result object" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
- result = syncer.call
-
- expect(result).to be_a(ApplicationService::Result)
- expect(result).to respond_to(:data)
- expect(result).to respond_to(:errors)
- expect(result).to respond_to(:success?)
- expect(result).to respond_to(:failed?)
- end
-
- context "when operation succeeds" do
- it "has success? true and failed? false" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
- result = syncer.call
-
- expect(result.success?).to be true
- expect(result.failed?).to be false
- expect(result.errors).to be_blank
- end
- end
-
- context "when operation fails" do
- let(:invalid_record) do
- {
- "mapid" => "FAIL123",
- "name" => "",
- "location" => "Test Location",
- "geo_local_area" => "Downtown",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "has success? false and failed? true" do
- syncer = described_class.new(record: invalid_record, api_key: api_key)
- result = syncer.call
-
- expect(result.success?).to be false
- expect(result.failed?).to be true
- expect(result.errors).to be_present
- end
- end
- end
-
- describe "operation type consistency" do
- context "when for create operations" do
- let(:create_record) do
- {
- "mapid" => "CREATE_OP123",
- "name" => "New Fountain",
- "location" => "New Park",
- "geo_local_area" => "Downtown",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "consistently reports :create operation" do
- syncer = described_class.new(record: create_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.operation).to eq(:create)
- end
- end
-
- context "when for external_update operations" do
- let(:existing_external_facility) do
- create(:facility,
- external_id: "EXT_OP123",
- name: "Old Name")
- end
-
- let(:update_record) do
- {
- "mapid" => "EXT_OP123",
- "name" => "Updated Name",
- "location" => "Updated Location",
- "geo_local_area" => "Downtown",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "consistently reports :external_update operation" do
- existing_external_facility
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.operation).to eq(:external_update)
- end
- end
-
- context "when for internal_update operations" do
- let(:existing_internal_facility) do
- create(:facility,
- external_id: nil,
- name: "Internal Facility")
- end
-
- let(:update_record) do
- {
- "mapid" => "INT_OP123",
- "name" => "Internal Facility", # Same name triggers internal_update
- "location" => "Updated Location",
- "geo_local_area" => "Downtown",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "consistently reports :internal_update operation" do
- existing_internal_facility
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.operation).to eq(:internal_update)
- end
- end
- end
-
- describe "facility reference consistency" do
- let(:valid_record) do
- {
- "mapid" => "REF123",
- "name" => "Reference Test Fountain",
- "location" => "Test Park",
- "geo_local_area" => "Downtown",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "result facility matches database record" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
- result = syncer.call
-
- db_facility = Facility.find(result.data.facility.id)
- expect(result.data.facility).to eq(db_facility)
- expect(result.data.facility.external_id).to eq("REF123")
- expect(result.data.facility.name).to eq("Reference Test Fountain")
- end
-
- context "with update operations" do
- let(:existing_facility) do
- create(:facility,
- external_id: "UPDATE_REF123",
- name: "Original Name")
- end
-
- let(:update_record) do
- {
- "mapid" => "UPDATE_REF123",
- "name" => "Updated Reference Name",
- "location" => "Updated Location",
- "geo_local_area" => "Downtown",
- "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
- }
- end
-
- it "result facility is the same instance as existing facility" do
- existing_facility
- syncer = described_class.new(record: update_record, api_key: api_key)
- result = syncer.call
-
- expect(result.data.facility.id).to eq(existing_facility.id)
- expect(result.data.facility).to be_a(Facility)
- end
- end
- end
-end
-# rubocop:enable RSpec/SpecFilePathFormat
diff --git a/spec/services/external/vancouver_city/facility_syncer/service_synchronization_spec.rb b/spec/services/external/vancouver_city/facility_syncer/service_synchronization_spec.rb
index 9d511ad9..9ae8138b 100644
--- a/spec/services/external/vancouver_city/facility_syncer/service_synchronization_spec.rb
+++ b/spec/services/external/vancouver_city/facility_syncer/service_synchronization_spec.rb
@@ -5,6 +5,8 @@
require "rails_helper"
RSpec.describe External::VancouverCity::FacilitySyncer, "#call", type: :service do
+ subject(:syncer) { described_class.new(operation:, record:, api_key:, current:) }
+
let(:api_key) { "drinking-fountains" }
let(:service) { create(:water_fountain_service) }
let(:other_service) { create(:service, key: "public-washrooms") }
@@ -16,13 +18,17 @@
describe "service synchronization logic" do
context "when built facility has new services" do
+ let(:current) { existing_facility }
+ let(:record) { valid_record }
+ let(:operation) { External::SyncOperations.external_update }
+
let!(:existing_facility) do
facility = create(:facility, external_id: "SYNC_TEST123")
facility.facility_services.create!(service: other_service)
facility
end
- let(:record_with_new_service) do
+ let(:valid_record) do
{
"mapid" => "SYNC_TEST123",
"name" => "Service Sync Test",
@@ -35,26 +41,28 @@
expect(existing_facility.services).to include(other_service)
expect(existing_facility.services).not_to include(service)
- syncer = described_class.new(record: record_with_new_service, api_key: api_key)
result = syncer.call
+ facility = result.data.reload
- facility = result.data.facility
+ expect(result).to be_success
expect(facility.services).to include(other_service) # Keeps existing
expect(facility.services).to include(service) # Adds new one
end
it "increases facility services count" do
initial_count = existing_facility.facility_services.count
-
- syncer = described_class.new(record: record_with_new_service, api_key: api_key)
result = syncer.call
- facility = result.data.facility
+ facility = result.data
expect(facility.facility_services.count).to eq(initial_count + 1)
end
end
context "when built facility has existing services" do
+ let(:current) { existing_facility }
+ let(:record) { record_with_existing_services }
+ let(:operation) { External::SyncOperations.external_update }
+
let!(:existing_facility) do
facility = create(:facility, external_id: "EXISTING_SERVICES123")
facility.facility_services.create!(service: service)
@@ -72,28 +80,34 @@
it "does not duplicate existing services" do
initial_count = existing_facility.facility_services.count
-
- syncer = described_class.new(record: record_with_existing_services, api_key: api_key)
result = syncer.call
- facility = result.data.facility
+ facility = result.data
expect(facility.facility_services.count).to eq(initial_count)
end
it "maintains all existing services" do
- syncer = described_class.new(record: record_with_existing_services, api_key: api_key)
result = syncer.call
- facility = result.data.facility
+ facility = result.data
expect(facility.services).to include(service)
expect(facility.services).to include(other_service)
end
end
context "when built facility has duplicate services in builder" do
+ let(:current) { existing_facility }
+ let(:record) { valid_record }
+ let(:operation) { External::SyncOperations.external_update }
+
# This tests the .uniq call in add_missing_services
+ let!(:existing_facility) do
+ facility = create(:facility, external_id: "DUPLICATE_TEST123")
+ facility.facility_services.create!(service: service)
+ facility
+ end
- let(:record) do
+ let(:valid_record) do
{
"mapid" => "DUPLICATE_TEST123",
"name" => "Duplicate Test",
@@ -102,15 +116,12 @@
end
it "handles duplicate services gracefully" do
- syncer = described_class.new(record: record, api_key: api_key)
-
- allow(syncer).to receive(:add_missing_services).and_call_original
-
result = syncer.call
# Should succeed without errors
expect(result).to be_success
- facility = result.data.facility
+ facility = result.data.reload
+ expect(facility.services.count).to eq(1) # Should not duplicate
expect(facility.services).to include(service)
end
end
diff --git a/spec/services/external/vancouver_city/facility_syncer/undelete_facility_spec.rb b/spec/services/external/vancouver_city/facility_syncer/undelete_facility_spec.rb
new file mode 100644
index 00000000..a0977186
--- /dev/null
+++ b/spec/services/external/vancouver_city/facility_syncer/undelete_facility_spec.rb
@@ -0,0 +1,281 @@
+# frozen_string_literal: true
+
+# rubocop:disable RSpec/SpecFilePathFormat
+
+require "rails_helper"
+
+RSpec.describe External::VancouverCity::FacilitySyncer, "#call - undelete scenarios", type: :service do
+ subject(:syncer) { described_class.new(operation:, record:, api_key:, current: current) }
+
+ let(:api_key) { "drinking-fountains" }
+ let(:service) { create(:water_fountain_service) }
+
+ before { service }
+
+ describe "undelete support" do
+ context "when discarded facility has matching external_id" do
+ let(:current) { discarded_facility }
+ let(:record) { update_record }
+ let(:operation) { External::SyncOperations.external_update }
+
+ let(:discarded_facility) do
+ create(:facility,
+ :with_verified,
+ :discarded,
+ external_id: "DISCARDED123",
+ name: "Discarded Fountain",
+ discard_reason: :sync_removed)
+ end
+
+ let(:update_record) do
+ {
+ "mapid" => "DISCARDED123",
+ "name" => "Updated Discarded Fountain",
+ "location" => "Updated Park",
+ "geo_local_area" => "Downtown",
+ "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
+ }
+ end
+
+ before do
+ discarded_facility
+ end
+
+ it "undeletes the facility before updating" do
+ result = syncer.call
+
+ expect(result).to be_success
+ expect(result.data.id).to eq(discarded_facility.id)
+ expect(result.data).not_to be_discarded
+ end
+
+ it "restores facility to active state" do
+ expect do
+ syncer.call
+ end.to change { discarded_facility.reload.undiscarded? }.from(false).to(true)
+ end
+
+ it "updates the facility attributes" do
+ result = syncer.call
+
+ facility = result.data.reload
+ expect(facility.name).to eq("Updated Discarded Fountain")
+ expect(facility.address).to eq("Updated Park, Downtown")
+ expect(facility.lat).to eq(49.2827)
+ expect(facility.long).to eq(-123.1207)
+ end
+
+ it "clears the discard_reason" do
+ result = syncer.call
+
+ facility = result.data.reload
+ expect(facility.discard_reason).to be_nil
+ end
+ end
+
+ context "when discarded facility has matching name (internal update)" do
+ let(:current) { discarded_internal_facility }
+ let(:record) { name_match_record }
+ let(:operation) { External::SyncOperations.internal_update }
+
+ let!(:discarded_internal_facility) do
+ create(:facility,
+ :discarded,
+ external_id: nil,
+ name: "Internal Discarded Fountain",
+ verified: false,
+ discard_reason: :sync_removed)
+ end
+
+ let(:name_match_record) do
+ {
+ "mapid" => "NEW789",
+ "name" => "Internal Discarded Fountain",
+ "location" => "New Park",
+ "geo_local_area" => "East Vancouver",
+ "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
+ }
+ end
+
+ it "undeletes the facility before adding services" do
+ result = syncer.call
+
+ expect(result).to be_success
+ expect(result.data.id).to eq(discarded_internal_facility.id)
+ expect(result.data).not_to be_discarded
+ end
+
+ it "adds new services to the undeleted facility" do
+ original_service_count = discarded_internal_facility.facility_services.count
+ result = syncer.call
+
+ facility = result.data.reload
+ expect(facility.facility_services.count).to eq(original_service_count + 1)
+ expect(facility.services).to include(service)
+ end
+
+ it "restores facility to active state" do
+ expect do
+ syncer.call
+ end.to change { discarded_internal_facility.reload.undiscarded? }.from(false).to(true)
+ end
+ end
+
+ context "when multiple discarded facilities exist" do
+ let(:operation) { External::SyncOperations.external_update }
+ let!(:first_discarded) do
+ create(:facility,
+ :with_verified,
+ :discarded,
+ external_id: "FIRST123",
+ name: "First Discarded",
+ discard_reason: :sync_removed)
+ end
+
+ let!(:second_discarded) do
+ create(:facility,
+ :with_verified,
+ :discarded,
+ external_id: "SECOND456",
+ name: "Second Discarded",
+ discard_reason: :closed)
+ end
+
+ let(:first_record) do
+ {
+ "mapid" => "FIRST123",
+ "name" => "First Updated",
+ "location" => "First Park",
+ "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
+ }
+ end
+
+ let(:second_record) do
+ {
+ "mapid" => "SECOND456",
+ "name" => "Second Updated",
+ "location" => "Second Park",
+ "geo_point_2d" => { "lat" => 49.2828, "lon" => -123.1208 }
+ }
+ end
+
+ it "undeletes both facilities independently" do
+ # First sync
+ syncer1 = described_class.new(operation:, record: first_record, api_key: api_key, current: first_discarded)
+ result1 = syncer1.call
+
+ expect(result1).to be_success
+ expect(result1.data.id).to eq(first_discarded.id)
+ expect(result1.data).not_to be_discarded
+
+ # Second sync
+ syncer2 = described_class.new(operation:, record: second_record, api_key: api_key, current: second_discarded)
+ result2 = syncer2.call
+
+ expect(result2).to be_success
+ expect(result2.data.id).to eq(second_discarded.id)
+ expect(result2.data).not_to be_discarded
+ end
+ end
+
+ context "when discarded facility matches by external_id but name differs" do
+ let(:operation) { External::SyncOperations.external_update }
+ let(:current) { discarded_facility }
+ let(:record) { renamed_record }
+
+ let!(:discarded_facility) do
+ create(:facility,
+ :with_verified,
+ :discarded,
+ external_id: "EXTERNAL789",
+ name: "Old Name",
+ discard_reason: :sync_removed)
+ end
+
+ let(:renamed_record) do
+ {
+ "mapid" => "EXTERNAL789",
+ "name" => "Completely New Name",
+ "location" => "New Park",
+ "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
+ }
+ end
+
+ it "undeletes and updates based on external_id match" do
+ result = syncer.call
+
+ expect(result).to be_success
+ expect(result.data.id).to eq(discarded_facility.id)
+ expect(result.data.name).to eq("Completely New Name")
+ end
+ end
+
+ context "when interaction with kept facilities" do
+ let(:operation) { External::SyncOperations.external_update }
+ let(:current) { kept_facility }
+ let(:record) { update_record }
+ let!(:kept_facility) do
+ create(:facility,
+ :with_verified,
+ external_id: "KEPT123",
+ name: "Kept Fountain",
+ verified: true)
+ end
+
+ let(:update_record) do
+ {
+ "mapid" => "KEPT123",
+ "name" => "Updated Kept Fountain",
+ "location" => "Park",
+ "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
+ }
+ end
+
+ it "updates kept facilities without undelete" do
+ result = syncer.call
+
+ expect(result).to be_success
+ expect(result.data.id).to eq(kept_facility.id)
+ end
+
+ it "does not change discard state of kept facilities" do
+ expect do
+ syncer.call
+ end.not_to(change { kept_facility.reload.discarded? })
+ end
+ end
+
+ context "when name match with discarded internal facility" do
+ let(:operation) { External::SyncOperations.internal_update }
+ let(:current) { discarded_internal }
+ let(:record) { name_record }
+
+ let!(:discarded_internal) do
+ create(:facility,
+ :discarded,
+ external_id: nil,
+ name: "Match By Name",
+ verified: false,
+ discard_reason: :sync_removed)
+ end
+
+ let(:name_record) do
+ {
+ "mapid" => "NEWID123",
+ "name" => "Match By Name",
+ "location" => "Park",
+ "geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
+ }
+ end
+
+ it "undeletes and performs internal update" do
+ result = syncer.call
+
+ expect(result).to be_success
+ expect(result.data.id).to eq(discarded_internal.id)
+ expect(result.data).not_to be_discarded
+ end
+ end
+ end
+end
+# rubocop:enable RSpec/SpecFilePathFormat
diff --git a/spec/services/external/vancouver_city/syncer_spec.rb b/spec/services/external/vancouver_city/syncer_spec.rb
index 866b01d0..e81bbacc 100644
--- a/spec/services/external/vancouver_city/syncer_spec.rb
+++ b/spec/services/external/vancouver_city/syncer_spec.rb
@@ -14,7 +14,6 @@
end
let(:page_size) { described_class::PAGE_SIZE }
- # Mock Rails.logger
before do
allow(Rails).to receive(:logger).and_return(logger)
end
@@ -34,6 +33,33 @@
end
end
+ describe "#initialize with full_sync option" do
+ let(:syncer_with_full_sync) { described_class.new(api_key: api_key, api_client: api_client, full_sync: full_sync) }
+
+ context "when full_sync is not specified" do
+ it "defaults to full_sync: true" do
+ syncer = described_class.new(api_key: api_key, api_client: api_client)
+ expect(syncer.full_sync).to be true
+ end
+ end
+
+ context "when full_sync is true" do
+ let(:full_sync) { true }
+
+ it "sets full_sync to true" do
+ expect(syncer_with_full_sync.full_sync).to be true
+ end
+ end
+
+ context "when full_sync is false" do
+ let(:full_sync) { false }
+
+ it "sets full_sync to false" do
+ expect(syncer_with_full_sync.full_sync).to be false
+ end
+ end
+ end
+
describe "#validate" do
context "with valid parameters" do
it "returns no errors" do
@@ -111,35 +137,12 @@
result = syncer.call
expect(result.success?).to be false
expect(result.errors).to include("Unsupported API: unsupported-api")
- 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 = instance_double(External::VancouverCity::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
@@ -152,61 +155,49 @@
.and_return(empty_response)
end
- it "logs fetch request and processes no facilities" do
- allow(logger).to receive(:info).with("Fetching facilities from #{api_key} API (offset: 0, limit: #{page_size})")
- allow(logger).to receive(:info).with("Successfully processed 0 facilities from #{api_key} API")
-
+ it "returns empty result" do
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)
- expect(logger).to have_received(:info).with("Fetching facilities from #{api_key} API (offset: 0, limit: #{page_size})")
- expect(logger).to have_received(:info).with("Successfully processed 0 facilities from #{api_key} API")
+ expect(result.data).to be_empty
end
end
context "with single page of results" do
- let(:response) do
- instance_double(Faraday::Response, body: { "results" => sample_records })
+ let(:geom) { { geometry: { coordinates: [-123.1207, 49.2827] } } }
+ let(:sample_records) do
+ [
+ { "name" => "Fountain 1", "mapid" => "FOO123", "geom" => geom },
+ { "name" => "Fountain 2", "mapid" => "FOO456", "geom" => geom }
+ ]
end
before do
+ response = instance_double(Faraday::Response, body: { "results" => sample_records })
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
- allow(logger).to receive(:info).with("Fetching facilities from #{api_key} API (offset: 0, limit: #{page_size})")
- allow(External::VancouverCity::FacilitySyncer).to receive(:call).twice.and_return(syncer_result)
- allow(logger).to receive(:info).with("Successfully processed 2 facilities from #{api_key} API")
+ it "processes records and creates facilities in database" do
+ expect do
+ result = syncer.call
- 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)
- expect(logger).to have_received(:info).with("Fetching facilities from #{api_key} API (offset: 0, limit: #{page_size})")
- expect(External::VancouverCity::FacilitySyncer).to have_received(:call).twice
- expect(logger).to have_received(:info).with("Successfully processed 2 facilities from #{api_key} API")
+ expect(result).to be_success
+ expect(result.error_messages).to be_empty
+ expect(result.data.count).to eq(2)
+ end.to change(Facility, :count).by(2)
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
+ let(:geom) { { geometry: { coordinates: [-123.1207, 49.2827] } } }
+ let(:full_page_records) { Array.new(page_size) { |i| { "name" => "Fountain #{i}", "mapid" => "ID#{i}", "geom" => geom } } }
before do
+ first_response = instance_double(Faraday::Response, body: { "results" => full_page_records })
+ second_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(first_response)
@@ -217,33 +208,23 @@
end
it "fetches all pages and processes all records" do
- allow(logger).to receive(:info).with("Fetching facilities from #{api_key} API (offset: 0, limit: #{page_size})")
- allow(logger).to receive(:info).with("Fetching facilities from #{api_key} API (offset: #{page_size}, limit: #{page_size})")
- allow(External::VancouverCity::FacilitySyncer).to receive(:call).exactly(page_size).times.and_return(syncer_result)
- allow(logger).to receive(:info).with("Successfully processed #{page_size} facilities from #{api_key} API")
-
- result = syncer.call
+ expect do
+ result = syncer.call
- expect(result.success?).to be true
- expect(result.data[:total_count]).to eq(page_size)
- expect(logger).to have_received(:info).with("Fetching facilities from #{api_key} API (offset: 0, limit: #{page_size})")
- expect(logger).to have_received(:info).with("Fetching facilities from #{api_key} API (offset: #{page_size}, limit: #{page_size})")
- expect(External::VancouverCity::FacilitySyncer).to have_received(:call).exactly(page_size).times
- expect(logger).to have_received(:info).with("Successfully processed #{page_size} facilities from #{api_key} API")
+ expect(result.success?).to be true
+ expect(result.error_messages).to be_empty
+ # expect(Facility.where(external_id: external_ids).count).to eq(page_size)
+ end.to change(Facility, :count).by(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
+ let(:full_page_records) { Array.new(page_size) { |i| { "name" => "Fountain #{i}", "mapid" => "ID#{i}" } } }
before do
+ full_page_response = instance_double(Faraday::Response, body: { "results" => full_page_records })
+ 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(full_page_response)
@@ -251,44 +232,15 @@
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
- allow(api_client).to receive(:get_dataset_records)
- .with(api_key, limit: page_size, offset: page_size)
-
syncer.call
expect(api_client).to have_received(:get_dataset_records)
.with(api_key, limit: page_size, offset: page_size)
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
- allow(api_client).to receive(:get_dataset_records)
- .with(api_key, limit: page_size, offset: page_size)
-
- syncer.call
-
- expect(api_client).not_to have_received(:get_dataset_records)
- .with(api_key, limit: page_size, offset: page_size)
- end
- end
end
context "when error handling" do
@@ -316,11 +268,13 @@
it "handles API error and returns failure result" do
result = syncer.call
+ result_facilities = result.data.map(&:facility).compact
+ error_messages = result.error_messages
- 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)
+ expect(result.partial_failed?).to be true
+ expect(result.success?).to be true
+ expect(error_messages).to include(/API request failed: API rate limit exceeded/)
+ expect(result_facilities).to be_empty
end
end
@@ -331,202 +285,130 @@
.and_raise(StandardError.new("Unexpected network error"))
end
- it "handles unexpected error and returns failure result" do
+ it "handles unexpected error and partially fails" do
result = syncer.call
+ result_facilities = result.data.map(&:facility).compact
+ error_messages = result.error_messages
- 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)
+ expect(result.partial_failed?).to be true
+ expect(result.success?).to be true
+ expect(result.errors).to be_empty
+ expect(error_messages).to include(/Unexpected error during sync/)
+ expect(error_messages).to include(/StandardError: Unexpected network error/)
+ expect(result_facilities).to be_empty
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
-
+ context "when some records have invalid data" do
let(:mixed_records) do
[
- { "name" => "Valid Facility", "lat" => 49.2827, "long" => -123.1207 },
+ { "name" => "Valid Facility", "lat" => 49.2827, "long" => -123.1207, "mapid" => "VALID123" },
{ "name" => "Invalid Facility" }
]
end
- let(:response) do
- instance_double(Faraday::Response, body: { "results" => mixed_records })
- end
-
before do
+ allow(logger).to receive(:warn)
+ response = instance_double(Faraday::Response, body: { "results" => mixed_records })
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
+ it "returns partial success with error entries" do
result = syncer.call
+ result.data.map(&:facility).compact
+ error_messages = result.error_messages
- 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")
+ expect(result.partial_failed?).to be true
+ expect(result.success?).to be true
+ expect(result.data.size).to eq(2)
+ expect(error_messages).not_to be_empty
end
end
end
- context "with 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: []
- )
+ context "with full_sync: true (default)" do
+ let(:sample_records) { [{ "mapid" => "FOO123", "name" => "Test Fountain" }] }
+
+ let(:existing_facility) do
+ create(:facility, :with_verified, external_id: "EXISTING456", name: "Existing Fountain")
end
before do
+ response = instance_double(Faraday::Response, body: { "results" => sample_records })
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)
+ allow(logger).to receive(:warn)
end
- it "logs fetch progress with correct offset and limit" do
- allow(logger).to receive(:info).with("Fetching facilities from #{api_key} API (offset: 0, limit: #{page_size})")
- allow(logger).to receive(:info).with("Successfully processed 1 facilities from #{api_key} API")
+ it "discards facilities not in the API response" do
+ expect do
+ result = syncer.call
+
+ expect(result.success?).to be true
+ expect(existing_facility.reload).to be_discarded
+ expect(existing_facility.discard_reason).to eq("sync_removed")
+ end.to change(existing_facility, :discarded?).from(false).to(true)
+ end
- syncer.call
+ it "returns discard entries in result data" do
+ result = syncer.call
- expect(logger).to have_received(:info).with("Fetching facilities from #{api_key} API (offset: 0, limit: #{page_size})")
- expect(logger).to have_received(:info).with("Successfully processed 1 facilities from #{api_key} API")
+ discard_entries = result.data.map { |entry| entry.operation == External::SyncOperations.discard }
+ expect(discard_entries.size).to eq(1)
end
- it "logs final processing summary" do
- allow(logger).to receive(:info).with("Fetching facilities from #{api_key} API (offset: 0, limit: #{page_size})")
- allow(logger).to receive(:info).with(/Successfully processed \d+ facilities from #{api_key} API/)
+ it "does not re-discard facilities that were previously sync_removed" do
+ discarded_facility = create(:facility, :with_verified,
+ external_id: "DISCARDED789",
+ name: "Previously Discarded",
+ discard_reason: :sync_removed)
+ discarded_facility.discard!
- syncer.call
+ expect(discarded_facility.reload).to be_discarded
- expect(logger).to have_received(:info).with("Fetching facilities from #{api_key} API (offset: 0, limit: #{page_size})")
- expect(logger).to have_received(:info).with(/Successfully processed \d+ facilities from #{api_key} API/)
+ result = syncer.call
+
+ expect(result.success?).to be true
+ expect(discarded_facility.reload).to be_discarded
end
end
- context "with 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: []
- )
+ context "with full_sync: false" do
+ let(:sample_records) { [{ "mapid" => "FOO123", "name" => "Test Fountain" }] }
+
+ let(:syncer) { described_class.new(api_key: api_key, api_client: api_client, full_sync: false) }
+
+ let!(:orphan_facility) do
+ create(:facility, :with_verified, external_id: "ORPHAN456", name: "Orphan Fountain")
end
before do
+ allow(logger).to receive(:warn)
+ response = instance_double(Faraday::Response, body: { "results" => sample_records })
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
+ it "does not discard orphan facilities" 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: []
- )
+ expect(result.success?).to be true
+ expect(orphan_facility.reload).not_to be_discarded
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)
+ it "returns no discard entries in result data" do
+ result = syncer.call
- expect(facilities.size).to eq(2) # Only successful ones
- expect(syncer.send(:errors)).to include("Processing failed")
- end
+ discard_entries = result.data.select { |entry| entry.operation == External::SyncOperations.discard }
+ expect(discard_entries.size).to eq(0)
end
end
end
diff --git a/spec/support/pages/admin_tools_page.rb b/spec/support/pages/admin_tools_page.rb
new file mode 100644
index 00000000..465d139b
--- /dev/null
+++ b/spec/support/pages/admin_tools_page.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require_relative "base_page"
+
+class AdminToolsPage < BasePage
+ def visit_tools
+ visit_page admin_tools_path
+ self
+ end
+
+ def has_tools_content?
+ has_content?("Tools") && has_content?("Vancouver City API")
+ end
+
+ def has_sync_tab?
+ page.has_css?(".tabs ul li", text: "Sync")
+ end
+
+ def has_discard_tab?
+ page.has_css?(".tabs ul li", text: "Discard")
+ end
+
+ def click_sync_tab
+ click_link "Sync"
+ self
+ end
+
+ def click_discard_tab
+ page.find(".tabs ul li", text: "Discard").click
+ self
+ end
+
+ def has_import_form?
+ page.has_css?("#import-form")
+ end
+
+ def has_discard_form?
+ page.body
+ page.has_css?("#discard-form")
+ end
+end
diff --git a/spec/system/admin/authentication_system_spec.rb b/spec/system/admin/authentication_system_spec.rb
index e5066d5f..58185cd6 100644
--- a/spec/system/admin/authentication_system_spec.rb
+++ b/spec/system/admin/authentication_system_spec.rb
@@ -34,7 +34,7 @@
context "with non-admin user" do
it "redirects to login after attempting admin access" do
- sign_in non_admin_user
+ login_as non_admin_user, scope: :user
dashboard_page.visit_dashboard
# Should be redirected away from admin
@@ -44,7 +44,7 @@
context "when performing logout workflow" do
it "allows admin to logout successfully" do
- sign_in admin_user
+ login_as admin_user, scope: :user
dashboard_page.visit_dashboard
dashboard_page.logout
@@ -63,20 +63,5 @@
expect(login_page.has_login_form?).to be true
end
end
-
- context "with different admin roles" do
- let(:super_admin) { create(:admin_user) }
- let(:zone_admin) { create(:admin_user) } # Assuming zones exist
- let(:facility_admin) { create(:admin_user) }
-
- it "allows all admin types to access dashboard" do
- [super_admin, zone_admin, facility_admin].each do |user|
- sign_in user
- dashboard_page.visit_dashboard
- expect(dashboard_page.has_dashboard_content?).to be true
- sign_out user
- end
- end
- end
end
end
diff --git a/spec/system/admin/tools_system_spec.rb b/spec/system/admin/tools_system_spec.rb
new file mode 100644
index 00000000..4dbbcddb
--- /dev/null
+++ b/spec/system/admin/tools_system_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+require_relative "../../support/pages/admin_tools_page"
+require_relative "../../support/shared_contexts/admin_authentication"
+
+RSpec.describe "Admin Tools", type: :system do
+ include_context "with admin authentication"
+
+ let(:tools_page) { AdminToolsPage.new }
+
+ describe "page load" do
+ it "loads without errors" do
+ tools_page.visit_tools
+ expect(page.current_path).to eq(admin_tools_path)
+ expect(tools_page.has_tools_content?).to be true
+ end
+
+ it "displays Vancouver City API section" do
+ tools_page.visit_tools
+ expect(page).to have_content("Vancouver City API")
+ end
+
+ it "displays Sync and Discard tabs" do
+ tools_page.visit_tools
+ expect(tools_page.has_sync_tab?).to be true
+ expect(tools_page.has_discard_tab?).to be true
+ end
+
+ it "shows Sync tab content by default" do
+ tools_page.visit_tools
+ expect(tools_page.has_import_form?).to be true
+ end
+
+ it "switches to Discard tab when clicked" do
+ tools_page.visit_tools
+ tools_page.click_discard_tab
+ expect(tools_page.has_discard_form?).to be true
+ end
+ end
+end
diff --git a/yarn.lock b/yarn.lock
index d51e5b42..32fd1e08 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -12,18 +12,18 @@
resolved "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz"
integrity sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==
-"@hotwired/turbo-rails@^8.0.18":
- version "8.0.18"
- resolved "https://registry.npmjs.org/@hotwired/turbo-rails/-/turbo-rails-8.0.18.tgz"
- integrity sha512-iRxd922VSTVH0NzlLDx9T9S8Ep0NPnrLCKva31WIMLNApJgUZKa/a90EFiBa2G6Do+x4xuKZk53dlweiwTyXkQ==
+"@hotwired/turbo-rails@^8.0.23":
+ version "8.0.23"
+ resolved "https://registry.yarnpkg.com/@hotwired/turbo-rails/-/turbo-rails-8.0.23.tgz#7a84ad041cb0f3e5d9ff97d1a1f0291550a93fc9"
+ integrity sha512-iBILwda3qmQC7FYM70+4s6kEQ7Fx9dJ6+yGxjPyrz9a5JDx1+y7OAA5TA7GGVOZJoicMLrKGdFDNorl40X35lw==
dependencies:
- "@hotwired/turbo" "^8.0.18"
+ "@hotwired/turbo" "^8.0.23"
"@rails/actioncable" ">=7.0"
-"@hotwired/turbo@^8.0.18":
- version "8.0.18"
- resolved "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.18.tgz"
- integrity sha512-dG0N7khQsP8sujclodQE3DYkI4Lq7uKA04fhT0DCC/DwMgn4T4WM3aji6EC6+iCfABQeJncY0SraXqVeOq0vvQ==
+"@hotwired/turbo@^8.0.23":
+ version "8.0.23"
+ resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-8.0.23.tgz#a6eebc9ab4a5faadae265a4cbec8cfcb5731e77c"
+ integrity sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ==
"@parcel/watcher-android-arm64@2.5.6":
version "2.5.6"
@@ -114,29 +114,34 @@
"@parcel/watcher-win32-ia32" "2.5.6"
"@parcel/watcher-win32-x64" "2.5.6"
-"@rails/actioncable@>=7.0", "@rails/actioncable@^8.1.0":
- version "8.1.200"
- resolved "https://registry.npmjs.org/@rails/actioncable/-/actioncable-8.1.200.tgz"
- integrity sha512-on0DSb7AFUkq1ocxivDNQhhGW/RQpY91zvRVyyaEWP4gOOZWy33P/UyxjQk74IENWNrTqs8+zOGHwTjiiFruRw==
+"@rails/actioncable@>=7.0", "@rails/actioncable@^8.1.300":
+ version "8.1.300"
+ resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-8.1.300.tgz#bfd50813be0225a0403bddb11e8ba9a00b1fb135"
+ integrity sha512-zOENQsq3NM2jyBY6Z2qtZa3V/R/6OEqA+LGKixQbBMl7kk/J3FXDRcszPe74LsHNgB01jCl/DXu/xA8sHt4I/g==
-"@rails/actiontext@^8.1.0":
- version "8.1.200"
- resolved "https://registry.npmjs.org/@rails/actiontext/-/actiontext-8.1.200.tgz"
- integrity sha512-l4OuFLZbQB+A3yCNOzX0Y4Tn7XSekfuYjy20TiBuf+4Q5JKTnfuybHrQ5cDk/9DbwWE9sdWcdbODFUIYd4tczg==
+"@rails/actiontext@^8.1.300":
+ version "8.1.300"
+ resolved "https://registry.yarnpkg.com/@rails/actiontext/-/actiontext-8.1.300.tgz#0b6d0a40e8ca8ba2caf5a0077e10b5503183a044"
+ integrity sha512-KPbLfZTt6aFKbdeTlU7ZzmX0uSSNhhfZx3G+2lfa6HltQ/r23HDcJnJOEVt72WKJo13IS5C5HK0sYwplVMCdNw==
dependencies:
"@rails/activestorage" ">= 8.1.0-alpha"
-"@rails/activestorage@>= 8.1.0-alpha", "@rails/activestorage@^8.1.0":
- version "8.1.200"
- resolved "https://registry.npmjs.org/@rails/activestorage/-/activestorage-8.1.200.tgz"
- integrity sha512-bPZqv447REBd1NQfba//FjgUqbUd93zKh7+BWhh3vRZ7Nm+RUgm6c5GbWctmik/rMHjsruTHhusYGyoKyf60pg==
+"@rails/activestorage@>= 8.1.0-alpha", "@rails/activestorage@^8.1.300":
+ version "8.1.300"
+ resolved "https://registry.yarnpkg.com/@rails/activestorage/-/activestorage-8.1.300.tgz#624a6f291e0414c07c3d02ed3d1e6ee85c94277b"
+ integrity sha512-AOv3zZrTJjQlzm4L9uzOQtCz6zaM4IqUyux6yzSqmS+PZ8EzwD1F2JAc4LlJNJAv4MSyNYriG+CaCm1QbQTjsA==
dependencies:
spark-md5 "^3.0.1"
-"@rails/request.js@^0.0.12":
- version "0.0.12"
- resolved "https://registry.npmjs.org/@rails/request.js/-/request.js-0.0.12.tgz"
- integrity sha512-g3//JBja1s04Zflj7IoMLQuXza9i4ZvtLmm0r0dMwh1QQUs6rL2iKUOGGyERfLsd81SnXC5ucfVV//rtsDlEEA==
+"@rails/request.js@^0.0.13":
+ version "0.0.13"
+ resolved "https://registry.yarnpkg.com/@rails/request.js/-/request.js-0.0.13.tgz#3c7cbd0303ea9ef51bd3e7acaab86e9324efc52f"
+ integrity sha512-7MXmjFOPuaxpjG8brqKJG0EfIe9ak6R0wRnjCBtRuADNFbdlRxETdKx1T5NVU4Ato3iZOkEpeSUEuLboL3tCGA==
+
+"@types/trusted-types@^2.0.7":
+ version "2.0.7"
+ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
+ integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
babel-helper-builder-react-jsx@^6.24.1:
version "6.26.0"
@@ -261,6 +266,13 @@ detect-libc@^2.0.3:
resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz"
integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
+dompurify@^3.2.5:
+ version "3.4.0"
+ resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.0.tgz#b1fc33ebdadb373241621e0a30e4ad81573dfd0b"
+ integrity sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==
+ optionalDependencies:
+ "@types/trusted-types" "^2.0.7"
+
esutils@^2.0.2:
version "2.0.3"
resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz"
@@ -288,15 +300,15 @@ is-glob@^4.0.3:
dependencies:
is-extglob "^2.1.1"
-jquery@^3.6.0:
- version "3.6.1"
- resolved "https://registry.npmjs.org/jquery/-/jquery-3.6.1.tgz"
- integrity sha512-opJeO4nCucVnsjiXOE+/PcCgYw9Gwpvs/a6B1LL/lQhwWwpbVEVYDZ1FokFr8PRc7ghYlrFPuyHuiiDNTQxmcw==
+jquery@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/jquery/-/jquery-4.0.0.tgz#95c33ac29005ff72ec444c5ba1cf457e61404fbb"
+ integrity sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==
lodash@^4.17.4:
- version "4.17.21"
- resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
- integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
+ version "4.18.1"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c"
+ integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==
node-addon-api@^7.0.0:
version "7.1.1"
@@ -304,9 +316,9 @@ node-addon-api@^7.0.0:
integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==
picomatch@^4.0.3:
- version "4.0.3"
- resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz"
- integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
+ version "4.0.4"
+ resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
+ integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
readdirp@^4.0.1:
version "4.1.2"
@@ -318,10 +330,10 @@ regenerator-runtime@^0.11.0:
resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz"
integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
-sass@^1.98.0:
- version "1.98.0"
- resolved "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz"
- integrity sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==
+sass@^1.99.0:
+ version "1.99.0"
+ resolved "https://registry.yarnpkg.com/sass/-/sass-1.99.0.tgz#ff9d1594da4886249dfaafabbeea2dea2dc74b26"
+ integrity sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==
dependencies:
chokidar "^4.0.0"
immutable "^5.1.5"
@@ -344,7 +356,9 @@ to-fast-properties@^1.0.3:
resolved "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz"
integrity sha512-lxrWP8ejsq+7E3nNjwYmUBMAgjMTZoTI+sdBOpvNyijeDLa29LUn9QaoXAHv4+Z578hbmHHJKZknzxVtvo77og==
-trix@^2.1.4:
- version "2.1.4"
- resolved "https://registry.npmjs.org/trix/-/trix-2.1.4.tgz"
- integrity sha512-f0AGnqBV8J2qW+fCtVU71JmvzjcxnO5Xbbd6Cl2KrHVRpgXKDqNGTmDmQzNHWU7T2OgtwHwvNiN+OIf3Z3KmHQ==
+trix@^2.1.19:
+ version "2.1.19"
+ resolved "https://registry.yarnpkg.com/trix/-/trix-2.1.19.tgz#1eb5dfd44a41e6179df5b0c17fdea13d10ae805d"
+ integrity sha512-E7RA3EOeUiUwNJlrF5onIOkqCA06xUU6GmHOVxXyMnGMValrDK3Ce7uaMVgiVUOvVt4mzUERAHAzD10mxoLpOg==
+ dependencies:
+ dompurify "^3.2.5"