+ <%= 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/spec/components/facilities/discard_reason_component_spec.rb b/spec/components/facilities/discard_reason_component_spec.rb
index 92d8e594..b2e7fc0b 100644
--- a/spec/components/facilities/discard_reason_component_spec.rb
+++ b/spec/components/facilities/discard_reason_component_spec.rb
@@ -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", :none], ["Closed", :closed], ["Duplicated", :duplicated], ["Removed by Sync", :sync_removed]]
expect(described_class.select_options).to eq(expected)
end
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..8e9f92f2
--- /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, params: { api: api_key } }
+
+ let(:api_key) { "drinking-fountains" }
+
+ 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 valid api_key" do
+ it "discards all external facilities" do
+ discard_facilities
+
+ expect(Facility.external.kept.count).to eq(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 invalid api_key" do
+ let(:api_key) { "invalid-api" }
+
+ it "redirects with alert" do
+ discard_facilities
+
+ expect(response).to redirect_to(admin_tools_path)
+ expect(flash[:alert]).to include("Invalid API")
+ 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
+
+ before do
+ allow(External::VancouverCity::Syncer).to receive(:call)
+ .and_return(syncer_result)
+ 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/discard_service_spec.rb b/spec/services/external/vancouver_city/discard_service_spec.rb
new file mode 100644
index 00000000..a0cfc15c
--- /dev/null
+++ b/spec/services/external/vancouver_city/discard_service_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe External::VancouverCity::DiscardService, type: :service do
+ describe ".call" do
+ let(:api_key) { "drinking-fountains" }
+ let(:api_client) { External::VancouverCity.default_client }
+
+ context "with valid api_key" do
+ let(:drinking_fountains_key) { "drinking-fountains" }
+ let(:internal_facility) { create(:facility, :with_verified, external_id: nil, name: "Internal Fountain") }
+ 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
+
+ it "discards all external facilities for the api_key" do
+ result = described_class.call(api_key: api_key)
+
+ expect(result.success?).to be true
+ expect(Facility.external.kept.count).to eq(0)
+ end
+
+ it "discards facilities with sync_removed reason" do
+ result = described_class.call(api_key: api_key)
+
+ expect(result.success?).to be true
+ foo = Facility.external.with_discarded.find_by(external_id: "FOO123")
+ expect(foo).to be_discarded
+ expect(foo.discard_reason).to eq("sync_removed")
+ bar = Facility.external.with_discarded.find_by(external_id: "BAR456")
+ expect(bar).to be_discarded
+ expect(bar.discard_reason).to eq("sync_removed")
+ end
+
+ it "does not affect internal facilities" do
+ result = described_class.call(api_key: api_key)
+
+ expect(result.success?).to be true
+ expect(internal_facility.reload).not_to be_discarded
+ end
+
+ it "returns discarded_count" do
+ result = described_class.call(api_key: api_key)
+
+ expect(result.success?).to be true
+ expect(result.data[:discarded_count]).to eq(2)
+ end
+ end
+
+ context "with no external facilities" do
+ before { create(:facility, :with_verified, external_id: nil, name: "Internal Fountain") }
+
+ it "returns success with zero discarded count" do
+ result = described_class.call(api_key: api_key)
+
+ expect(result.success?).to be true
+ expect(result.data[:discarded_count]).to eq(0)
+ end
+ end
+
+ context "with unsupported api_key" do
+ it "returns failure result" do
+ result = described_class.call(api_key: "unsupported-api")
+
+ expect(result.success?).to be false
+ expect(result.errors).to include("Unsupported API: unsupported-api")
+ end
+ end
+
+ context "with facilities already discarded" do
+ let(:drinking_fountains_key) { "drinking-fountains" }
+ let(:facility2) do
+ create(:facility, :with_verified, external_id: "BAR456", name: "Fountain 2", discard_reason: :sync_removed)
+ end
+ let(:public_washrooms_key) { "public-washrooms" }
+
+ before do
+ create(:facility, :with_verified, external_id: "FOO123", name: "Fountain 1")
+ facility2.discard!
+ end
+
+ it "only discards facilities that are not already discarded" do
+ result = described_class.call(api_key: api_key)
+
+ expect(result.success?).to be true
+ expect(result.data[:discarded_count]).to eq(1)
+ expect(Facility.external.with_discarded.find_by(external_id: "FOO123")).to be_discarded
+ expect(facility2.reload).to be_discarded # Already was discarded
+ end
+ end
+ end
+end
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..fec90d9b 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
@@ -12,6 +12,8 @@
describe "create operation (:create)" do
context "when built facility is valid" do
+ subject(:syncer) { described_class.new(record: valid_record, current: nil, api_key: api_key) }
+
let(:valid_record) do
{
"mapid" => "CREATE123",
@@ -26,13 +28,11 @@
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
@@ -41,7 +41,6 @@
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
@@ -52,7 +51,6 @@
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
@@ -67,7 +65,6 @@
end
it "creates facility services" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
result = syncer.call
facility = result.data.facility
@@ -78,7 +75,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,6 +82,8 @@
end
context "when FacilityBuilder fails due to invalid data" do
+ subject(:syncer) { described_class.new(record: invalid_record, current: nil, api_key: api_key) }
+
let(:invalid_record) do
{
"mapid" => "INVALID123",
@@ -96,13 +94,11 @@
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,14 +106,12 @@
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
@@ -126,6 +120,8 @@
end
context "when save! raises other StandardError" do
+ subject(:syncer) { described_class.new(record: valid_record, current: nil, api_key: api_key) }
+
let(:valid_record) do
{
"mapid" => "ERROR123",
@@ -148,7 +144,6 @@
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
@@ -156,7 +151,6 @@
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")
@@ -164,20 +158,20 @@
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
+ subject(:syncer) { described_class.new(record: invalid_save_record, current: nil, api_key: api_key) }
+
let(:invalid_save_record) do
{
"mapid" => "INVALID_SAVE123",
@@ -202,7 +196,6 @@
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
@@ -211,20 +204,20 @@
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
+ subject(:syncer) { described_class.new(record: service_fail_record, current: nil, api_key: api_key) }
+
let(:service_fail_record) do
{
"mapid" => "SERVICE_FAIL123",
@@ -252,27 +245,23 @@
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)
end
it "returns failed result with proper error message" do
- syncer = described_class.new(record: service_fail_record, api_key: api_key)
result = syncer.call
expect(result).to be_failed
@@ -281,6 +270,8 @@
end
context "when creating database record on success" do
+ subject(:syncer) { described_class.new(record: success_record, current: nil, api_key: api_key) }
+
let(:success_record) do
{
"mapid" => "SUCCESS123",
@@ -292,8 +283,6 @@
end
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,7 +290,6 @@
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
@@ -318,7 +306,6 @@
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
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
index 9c174a0d..a5720c81 100644
--- a/spec/services/external/vancouver_city/facility_syncer/error_handling_spec.rb
+++ b/spec/services/external/vancouver_city/facility_syncer/error_handling_spec.rb
@@ -11,14 +11,14 @@
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!(:existing_facility) do
+ create(:facility,
+ external_id: "FAIL_UPDATE123",
+ name: "Test Facility",
+ address: "Test Address")
+ end
+ context "when ActiveRecord::RecordInvalid occurs during external_update" do
let(:update_record) do
{
"mapid" => "FAIL_UPDATE123",
@@ -31,7 +31,9 @@
before do
# Stub update! to raise RecordInvalid to simulate validation failure
- allow(Facility).to receive(:find_by).and_return(existing_facility)
+ relation_stub = instance_double(ActiveRecord::Relation)
+ allow(relation_stub).to receive(:find_by).with(external_id: "FAIL_UPDATE123").and_return(existing_facility)
+ allow(Facility).to receive(:with_discarded).and_return(relation_stub)
allow(existing_facility).to receive(:update!).and_raise(
ActiveRecord::RecordInvalid.new(existing_facility)
)
@@ -39,7 +41,7 @@
it "rolls back transaction and reports error" do
original_name = existing_facility.name
- syncer = described_class.new(record: update_record, api_key: api_key)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_facility)
result = syncer.call
existing_facility.reload
@@ -70,13 +72,15 @@
before do
# Stub facility_services.create! to raise StandardError
- allow(Facility).to receive(:find_by).and_return(existing_facility)
+ relation_stub = instance_double(ActiveRecord::Relation)
+ allow(relation_stub).to receive(:find_by).with(external_id: "SERVICE_ERROR123").and_return(existing_facility)
+ allow(Facility).to receive(:with_discarded).and_return(relation_stub)
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)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_facility)
result = syncer.call
existing_facility.reload
@@ -89,43 +93,6 @@
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
@@ -139,7 +106,7 @@
end
it "includes detailed validation errors from FacilityBuilder" do
- syncer = described_class.new(record: invalid_facility_record, api_key: api_key)
+ syncer = described_class.new(record: invalid_facility_record, api_key: api_key, current: nil)
result = syncer.call
expect(result).to be_failed
@@ -152,35 +119,23 @@
context "when ActiveRecord::RecordInvalid provides detailed message" do
let(:valid_record) do
{
- "mapid" => "DETAILED_ERROR123",
- "name" => "Test Facility",
+ "mapid" => "VALID_RECORD",
+ "name" => "Valid 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)
+ # Create a facility with the same external_id to trigger a unique constraint violation on save
+ create(:facility, external_id: "VALID_RECORD")
+
+ syncer = described_class.new(record: valid_record, api_key: api_key, current: nil)
result = syncer.call
expect(result).to be_failed
- expect(result.errors).to include(a_string_matching(/Failed to save facility.*Custom validation error/))
+ expect(result.errors).to include(a_string_matching(/Failed to save facility/))
end
end
end
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
index e0032968..ef85226f 100644
--- 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
@@ -38,7 +38,7 @@
end
it "updates facility attributes" do
- syncer = described_class.new(record: update_record, api_key: api_key)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_external_facility)
result = syncer.call
facility = result.data.facility
@@ -52,7 +52,7 @@
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)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_external_facility)
result = syncer.call
facility = result.data.facility
@@ -60,7 +60,7 @@
end
it "returns existing facility in result" do
- syncer = described_class.new(record: update_record, api_key: api_key)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_external_facility)
result = syncer.call
expect(result.data.facility.id).to eq(existing_external_facility.id)
@@ -70,14 +70,14 @@
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 = described_class.new(record: update_record, api_key: api_key, current: existing_external_facility)
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)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_external_facility)
result = syncer.call
expect(result).to be_success
@@ -86,7 +86,7 @@
it "does not create new facility" do
expect do
- syncer = described_class.new(record: update_record, api_key: api_key)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_external_facility)
syncer.call
end.not_to change(Facility, :count)
end
@@ -112,7 +112,7 @@
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)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_external_facility)
result = syncer.call
facility = result.data.facility
@@ -120,7 +120,7 @@
end
it "still updates facility attributes" do
- syncer = described_class.new(record: update_record, api_key: api_key)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_external_facility)
result = syncer.call
facility = result.data.facility
@@ -145,14 +145,16 @@
before do
# Simulate a validation error during update
- allow(Facility).to receive(:find_by).and_return(existing_external_facility)
+ relation_stub = instance_double(ActiveRecord::Relation)
+ allow(relation_stub).to receive(:find_by).with(external_id: "EXT_INVALID123").and_return(existing_external_facility)
+ allow(Facility).to receive(:with_discarded).and_return(relation_stub)
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)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_external_facility)
result = syncer.call
expect(result).to be_failed
@@ -161,6 +163,12 @@
end
context "when create! raises ActiveRecord::RecordInvalid during service creation" do
+ let!(:existing_facility) do
+ create(:facility,
+ external_id: "EXT_SERVICE_ERROR123",
+ name: "Test Facility")
+ end
+
let(:update_record) do
{
"mapid" => "EXT_SERVICE_ERROR123",
@@ -170,18 +178,17 @@
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)
+ relation_stub = instance_double(ActiveRecord::Relation)
+ allow(relation_stub).to receive(:find_by).with(external_id: "EXT_SERVICE_ERROR123").and_return(existing_facility)
+ allow(Facility).to receive(:with_discarded).and_return(relation_stub)
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)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_facility)
result = syncer.call
expect(result).to be_failed
@@ -206,12 +213,14 @@
before do
# Force service creation to fail during add_missing_services
- allow(Facility).to receive(:find_by).and_return(existing_external_facility)
+ relation_stub = instance_double(ActiveRecord::Relation)
+ allow(relation_stub).to receive(:find_by).with(external_id: "EXT_STD_ERROR123").and_return(existing_external_facility)
+ allow(Facility).to receive(:with_discarded).and_return(relation_stub)
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)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_external_facility)
result = syncer.call
expect(result).to be_failed
@@ -223,7 +232,7 @@
original_name = existing_external_facility.name
original_address = existing_external_facility.address
- syncer = described_class.new(record: update_record, api_key: api_key)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_external_facility)
syncer.call
existing_external_facility.reload
@@ -233,7 +242,7 @@
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 = described_class.new(record: update_record, api_key: api_key, current: existing_external_facility)
syncer.call
end.not_to change(FacilityService, :count)
end
@@ -267,7 +276,7 @@
end
it "updates all facility attributes correctly" do
- syncer = described_class.new(record: comprehensive_update_record, api_key: api_key)
+ syncer = described_class.new(record: comprehensive_update_record, api_key: api_key, current: external_facility_with_data)
result = syncer.call
facility = result.data.facility
@@ -287,7 +296,7 @@
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)
+ syncer = described_class.new(record: comprehensive_update_record, api_key: api_key, current: external_facility_with_data)
result = syncer.call
facility = result.data.facility
@@ -297,7 +306,7 @@
end
it "maintains referential integrity during updates" do
- syncer = described_class.new(record: comprehensive_update_record, api_key: api_key)
+ syncer = described_class.new(record: comprehensive_update_record, api_key: api_key, current: external_facility_with_data)
result = syncer.call
facility = result.data.facility
@@ -310,13 +319,13 @@
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 = described_class.new(record: comprehensive_update_record, api_key: api_key, current: external_facility_with_data)
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 = described_class.new(record: comprehensive_update_record, api_key: api_key, current: external_facility_with_data)
syncer2.call
external_facility_with_data.reload
@@ -344,7 +353,9 @@
before do
# Force failure after attribute update but before service creation
- allow(Facility).to receive(:find_by).and_return(rollback_facility)
+ relation_stub = instance_double(ActiveRecord::Relation)
+ allow(relation_stub).to receive(:find_by).with(external_id: "ROLLBACK123").and_return(rollback_facility)
+ allow(Facility).to receive(:with_discarded).and_return(relation_stub)
allow(rollback_facility.facility_services).to receive(:create!).and_raise(StandardError.new("Service creation failed"))
end
@@ -353,7 +364,7 @@
original_address = rollback_facility.address
original_verified = rollback_facility.verified
- syncer = described_class.new(record: rollback_record, api_key: api_key)
+ syncer = described_class.new(record: rollback_record, api_key: api_key, current: rollback_facility)
syncer.call
rollback_facility.reload
@@ -364,7 +375,7 @@
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 = described_class.new(record: rollback_record, api_key: api_key, current: rollback_facility)
syncer.call
end.not_to change(FacilityService, :count)
end
@@ -372,7 +383,7 @@
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 = described_class.new(record: rollback_record, api_key: api_key, current: rollback_facility)
syncer.call
rollback_facility.reload
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
index 63d59a78..9eb22402 100644
--- 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
@@ -23,7 +23,7 @@
end
it "proceeds with sync operations" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
+ syncer = described_class.new(record: valid_record, api_key: api_key, current: nil)
result = syncer.call
expect(result).to be_success
@@ -32,7 +32,7 @@
end
it "facility is created and persisted" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
+ syncer = described_class.new(record: valid_record, api_key: api_key, current: nil)
result = syncer.call
expect(result.data.facility).to be_persisted
@@ -49,7 +49,7 @@
end
it "returns early with FacilityBuilder errors" do
- syncer = described_class.new(record: invalid_record, api_key: api_key)
+ syncer = described_class.new(record: invalid_record, api_key: api_key, current: nil)
result = syncer.call
expect(result).to be_failed
@@ -57,7 +57,7 @@
end
it "returns ResultData with operation: nil, facility: nil" do
- syncer = described_class.new(record: invalid_record, api_key: api_key)
+ syncer = described_class.new(record: invalid_record, api_key: api_key, current: nil)
result = syncer.call
expect(result.data.operation).to be_nil
@@ -67,7 +67,7 @@
it "does not attempt database operations" do
allow(Facility).to receive(:where)
- syncer = described_class.new(record: invalid_record, api_key: api_key)
+ syncer = described_class.new(record: invalid_record, api_key: api_key, current: nil)
syncer.call
expect(Facility).not_to have_received(:where)
@@ -86,7 +86,7 @@
end
it "returns early with validation errors" do
- syncer = described_class.new(record: record_with_invalid_facility_data, api_key: api_key)
+ syncer = described_class.new(record: record_with_invalid_facility_data, api_key: api_key, current: nil)
result = syncer.call
expect(result).to be_failed
@@ -95,7 +95,7 @@
end
it "includes FacilityBuilder validation errors" do
- syncer = described_class.new(record: record_with_invalid_facility_data, api_key: api_key)
+ syncer = described_class.new(record: record_with_invalid_facility_data, api_key: api_key, current: nil)
result = syncer.call
expect(result.errors).to include(a_string_matching(/can't be blank/i))
@@ -103,7 +103,7 @@
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 = described_class.new(record: record_with_invalid_facility_data, api_key: api_key, current: nil)
syncer.call
end.not_to change(Facility, :count)
end
diff --git a/spec/services/external/vancouver_city/facility_syncer/initialize_spec.rb b/spec/services/external/vancouver_city/facility_syncer/initialize_spec.rb
index d4e2df0b..c851b408 100644
--- a/spec/services/external/vancouver_city/facility_syncer/initialize_spec.rb
+++ b/spec/services/external/vancouver_city/facility_syncer/initialize_spec.rb
@@ -8,20 +8,20 @@
let(:api_key) { "test-api-key" }
it "sets record and api_key" do
- syncer = described_class.new(record: record, api_key: api_key)
+ syncer = described_class.new(record: record, api_key: api_key, current: nil)
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)
+ syncer = described_class.new(record: record, api_key: api_key, current: nil)
expect(syncer).to be_a(ApplicationService)
end
it "responds to call method" do
- syncer = described_class.new(record: record, api_key: api_key)
+ syncer = described_class.new(record: record, api_key: api_key, current: nil)
expect(syncer).to respond_to(:call)
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
index a250876f..0266ad20 100644
--- a/spec/services/external/vancouver_city/facility_syncer/integration_scenarios_spec.rb
+++ b/spec/services/external/vancouver_city/facility_syncer/integration_scenarios_spec.rb
@@ -32,7 +32,7 @@
end
it "creates facility with all available attributes" do
- syncer = described_class.new(record: comprehensive_record, api_key: api_key)
+ syncer = described_class.new(record: comprehensive_record, api_key: api_key, current: nil)
result = syncer.call
expect(result).to be_success
@@ -50,7 +50,7 @@
end
it "creates associated services, schedules, and welcomes" do
- syncer = described_class.new(record: comprehensive_record, api_key: api_key)
+ syncer = described_class.new(record: comprehensive_record, api_key: api_key, current: nil)
result = syncer.call
facility = result.data.facility
@@ -81,7 +81,7 @@
end
it "creates facility with defaults for missing optional fields" do
- syncer = described_class.new(record: minimal_record, api_key: api_key)
+ syncer = described_class.new(record: minimal_record, api_key: api_key, current: nil)
result = syncer.call
expect(result).to be_success
@@ -110,7 +110,7 @@
end
it "handles special characters correctly" do
- syncer = described_class.new(record: special_chars_record, api_key: api_key)
+ syncer = described_class.new(record: special_chars_record, api_key: api_key, current: nil)
result = syncer.call
expect(result).to be_success
@@ -133,7 +133,7 @@
end
it "handles edge coordinate values" do
- syncer = described_class.new(record: edge_coords_record, api_key: api_key)
+ syncer = described_class.new(record: edge_coords_record, api_key: api_key, current: nil)
result = syncer.call
expect(result).to be_success
@@ -169,14 +169,14 @@
it "handles duplicate external_id creation gracefully" do
# First sync
- syncer1 = described_class.new(record: first_concurrent_record, api_key: api_key)
+ syncer1 = described_class.new(record: first_concurrent_record, api_key: api_key, current: nil)
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)
+ syncer2 = described_class.new(record: second_concurrent_record, api_key: api_key, current: result1.data.facility)
result2 = syncer2.call
expect(result2).to be_success
@@ -202,7 +202,7 @@
end
it "ensures data integrity across all related models" do
- syncer = described_class.new(record: consistency_record, api_key: api_key)
+ syncer = described_class.new(record: consistency_record, api_key: api_key, current: nil)
result = syncer.call
expect(result).to be_success
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
index ad075c0b..4806f99f 100644
--- 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
@@ -39,7 +39,7 @@
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)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_internal_facility)
result = syncer.call
facility = result.data.facility
@@ -53,7 +53,7 @@
original_long = existing_internal_facility.long
original_verified = existing_internal_facility.verified
- syncer = described_class.new(record: update_record, api_key: api_key)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_internal_facility)
result = syncer.call
facility = result.data.facility
@@ -65,7 +65,7 @@
end
it "returns existing facility in result" do
- syncer = described_class.new(record: update_record, api_key: api_key)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_internal_facility)
result = syncer.call
expect(result.data.facility.id).to eq(existing_internal_facility.id)
@@ -75,14 +75,14 @@
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 = described_class.new(record: update_record, api_key: api_key, current: existing_internal_facility)
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)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_internal_facility)
result = syncer.call
expect(result).to be_success
@@ -91,7 +91,7 @@
it "does not create new facility" do
expect do
- syncer = described_class.new(record: update_record, api_key: api_key)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_internal_facility)
syncer.call
end.not_to change(Facility, :count)
end
@@ -118,7 +118,7 @@
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)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_internal_facility)
result = syncer.call
facility = result.data.facility
@@ -126,7 +126,7 @@
end
it "still succeeds even with no new services to add" do
- syncer = described_class.new(record: update_record, api_key: api_key)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_internal_facility)
result = syncer.call
expect(result).to be_success
@@ -149,17 +149,21 @@
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))
- )
+ # Stub the with_discarded chain to return the existing facility via where
+ # First, stub find_by to return nil so it falls through to where
+ with_discarded_relation = instance_double(ActiveRecord::Relation)
+ order_relation = instance_double(ActiveRecord::Relation, first: existing_facility)
+ where_relation = instance_double(ActiveRecord::Relation, order: order_relation)
+ allow(with_discarded_relation).to receive(:find_by).and_return(nil)
+ allow(with_discarded_relation).to receive(:where).with(name: "Service Error Fountain").and_return(where_relation)
+ allow(Facility).to receive(:with_discarded).and_return(with_discarded_relation)
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)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_internal_facility)
result = syncer.call
expect(result).to be_failed
@@ -182,17 +186,21 @@
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))
- )
+ # Stub the with_discarded chain to return the existing facility via where
+ # First, stub find_by to return nil so it falls through to where
+ with_discarded_relation = instance_double(ActiveRecord::Relation)
+ order_relation = instance_double(ActiveRecord::Relation, first: existing_facility)
+ where_relation = instance_double(ActiveRecord::Relation, order: order_relation)
+ allow(with_discarded_relation).to receive(:find_by).and_return(nil)
+ allow(with_discarded_relation).to receive(:where).with(name: "Generic Error Fountain").and_return(where_relation)
+ allow(Facility).to receive(:with_discarded).and_return(with_discarded_relation)
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)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_internal_facility)
result = syncer.call
expect(result).to be_failed
@@ -219,7 +227,7 @@
end
it "treats as internal update rather than create" do
- syncer = described_class.new(record: new_record_matching_name, api_key: api_key)
+ syncer = described_class.new(record: new_record_matching_name, api_key: api_key, current: existing_internal_facility)
result = syncer.call
expect(result.data.operation).to eq(:internal_update)
@@ -227,7 +235,7 @@
end
it "does not change facility external_id" do
- syncer = described_class.new(record: new_record_matching_name, api_key: api_key)
+ syncer = described_class.new(record: new_record_matching_name, api_key: api_key, current: existing_internal_facility)
result = syncer.call
facility = result.data.facility
@@ -265,7 +273,7 @@
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)
+ syncer = described_class.new(record: internal_service_update_record, api_key: api_key, current: existing_internal_facility)
result = syncer.call
facility = result.data.facility
@@ -282,7 +290,7 @@
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)
+ syncer = described_class.new(record: internal_service_update_record, api_key: api_key, current: existing_internal_facility)
result = syncer.call
facility = result.data.facility
@@ -292,7 +300,7 @@
end
it "maintains referential integrity when adding services" do
- syncer = described_class.new(record: internal_service_update_record, api_key: api_key)
+ syncer = described_class.new(record: internal_service_update_record, api_key: api_key, current: existing_internal_facility)
result = syncer.call
facility = result.data.facility
@@ -308,13 +316,13 @@
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 = described_class.new(record: internal_service_update_record, api_key: api_key, current: existing_internal_facility)
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 = described_class.new(record: internal_service_update_record, api_key: api_key, current: existing_internal_facility)
syncer2.call
internal_facility_with_services.reload
@@ -343,10 +351,14 @@
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))
- )
+ # Stub the with_discarded chain to return the existing facility via where
+ # First, stub find_by to return nil so it falls through to where
+ with_discarded_relation = instance_double(ActiveRecord::Relation)
+ order_relation = instance_double(ActiveRecord::Relation, first: rollback_internal_facility)
+ where_relation = instance_double(ActiveRecord::Relation, order: order_relation)
+ allow(with_discarded_relation).to receive(:find_by).and_return(nil)
+ allow(with_discarded_relation).to receive(:where).with(name: "Rollback Internal Test").and_return(where_relation)
+ allow(Facility).to receive(:with_discarded).and_return(with_discarded_relation)
allow(rollback_internal_facility.facility_services).to receive(:create!).and_raise(StandardError.new("Service creation failed"))
end
@@ -354,7 +366,7 @@
original_service_count = rollback_internal_facility.facility_services.count
expect do
- syncer = described_class.new(record: rollback_internal_record, api_key: api_key)
+ syncer = described_class.new(record: rollback_internal_record, api_key: api_key, current: existing_internal_facility)
syncer.call
end.not_to change(FacilityService, :count)
@@ -366,7 +378,7 @@
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)
+ syncer = described_class.new(record: rollback_internal_record, api_key: api_key, current: existing_internal_facility)
result = syncer.call
rollback_internal_facility.reload
@@ -387,7 +399,7 @@
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 = described_class.new(record: rollback_internal_record, api_key: api_key, current: existing_internal_facility)
syncer.call
end.not_to(change { other_facility.reload.facility_services.count })
end
@@ -410,10 +422,14 @@
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))
- )
+ # Stub the with_discarded chain to return the existing facility via where
+ # First, stub find_by to return nil so it falls through to where
+ with_discarded_relation = instance_double(ActiveRecord::Relation)
+ order_relation = instance_double(ActiveRecord::Relation, first: validation_internal_facility)
+ where_relation = instance_double(ActiveRecord::Relation, order: order_relation)
+ allow(with_discarded_relation).to receive(:find_by).and_return(nil)
+ allow(with_discarded_relation).to receive(:where).with(name: "Validation Test Facility").and_return(where_relation)
+ allow(Facility).to receive(:with_discarded).and_return(with_discarded_relation)
allow(validation_internal_facility.facility_services).to receive(:create!).and_raise(
ActiveRecord::RecordInvalid.new(FacilityService.new)
)
@@ -423,7 +439,7 @@
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 = described_class.new(record: validation_record, api_key: api_key, current: existing_internal_facility)
syncer.call
validation_internal_facility.reload
@@ -432,7 +448,7 @@
end
it "returns proper error information for validation failures" do
- syncer = described_class.new(record: validation_record, api_key: api_key)
+ syncer = described_class.new(record: validation_record, api_key: api_key, current: existing_internal_facility)
result = syncer.call
expect(result).to be_failed
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
index 14841666..609c3ebd 100644
--- a/spec/services/external/vancouver_city/facility_syncer/operation_detection_spec.rb
+++ b/spec/services/external/vancouver_city/facility_syncer/operation_detection_spec.rb
@@ -22,7 +22,7 @@
end
it "sets operation to :create" do
- syncer = described_class.new(record: new_facility_record, api_key: api_key)
+ syncer = described_class.new(record: new_facility_record, api_key: api_key, current: nil)
result = syncer.call
expect(result.data.operation).to eq(:create)
@@ -30,7 +30,7 @@
it "creates a new facility" do
expect do
- syncer = described_class.new(record: new_facility_record, api_key: api_key)
+ syncer = described_class.new(record: new_facility_record, api_key: api_key, current: nil)
syncer.call
end.to change(Facility, :count).by(1)
end
@@ -54,14 +54,14 @@
end
it "sets operation to :external_update" do
- syncer = described_class.new(record: update_record, api_key: api_key)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_external_facility)
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)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_external_facility)
result = syncer.call
expect(result.data.facility.id).to eq(existing_external_facility.id)
@@ -69,7 +69,7 @@
it "does not create a new facility" do
expect do
- syncer = described_class.new(record: update_record, api_key: api_key)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_external_facility)
syncer.call
end.not_to change(Facility, :count)
end
@@ -93,14 +93,14 @@
end
it "sets operation to :internal_update" do
- syncer = described_class.new(record: name_match_record, api_key: api_key)
+ syncer = described_class.new(record: name_match_record, api_key: api_key, current: existing_internal_facility)
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)
+ syncer = described_class.new(record: name_match_record, api_key: api_key, current: existing_internal_facility)
result = syncer.call
expect(result.data.facility.id).to eq(existing_internal_facility.id)
@@ -108,7 +108,7 @@
it "does not create a new facility" do
expect do
- syncer = described_class.new(record: name_match_record, api_key: api_key)
+ syncer = described_class.new(record: name_match_record, api_key: api_key, current: existing_internal_facility)
syncer.call
end.not_to change(Facility, :count)
end
@@ -136,7 +136,7 @@
"geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
}
- syncer = described_class.new(record: record, api_key: api_key)
+ syncer = described_class.new(record: record, api_key: api_key, current: facility_with_external_id)
result = syncer.call
expect(result.data.operation).to eq(:external_update)
@@ -150,7 +150,7 @@
"geo_point_2d" => { "lat" => 49.2827, "lon" => -123.1207 }
}
- syncer = described_class.new(record: record, api_key: api_key)
+ syncer = described_class.new(record: record, api_key: api_key, current: facility_with_same_name)
result = syncer.call
# Should match by name since external_id is different
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
index e73297d4..8dafda51 100644
--- a/spec/services/external/vancouver_city/facility_syncer/result_structure_spec.rb
+++ b/spec/services/external/vancouver_city/facility_syncer/result_structure_spec.rb
@@ -22,7 +22,7 @@
end
it "returns ResultData with operation and facility" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
+ syncer = described_class.new(record: valid_record, api_key: api_key, current: nil)
result = syncer.call
expect(result.data).to be_a(External::VancouverCity::FacilitySyncer::ResultData)
@@ -31,7 +31,7 @@
end
it "delegates present? and blank? to facility" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
+ syncer = described_class.new(record: valid_record, api_key: api_key, current: nil)
result = syncer.call
# When facility is present
@@ -51,7 +51,7 @@
end
it "ResultData reflects early failure state" do
- syncer = described_class.new(record: invalid_record, api_key: api_key)
+ syncer = described_class.new(record: invalid_record, api_key: api_key, current: nil)
result = syncer.call
expect(result.data.operation).to be_nil # No operation determined when FacilityBuilder fails
@@ -70,7 +70,7 @@
end
it "ResultData shows nil operation and facility" do
- syncer = described_class.new(record: malformed_record, api_key: api_key)
+ syncer = described_class.new(record: malformed_record, api_key: api_key, current: nil)
result = syncer.call
expect(result.data.operation).to be_nil
@@ -93,7 +93,7 @@
end
it "returns ApplicationService::Result object" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
+ syncer = described_class.new(record: valid_record, api_key: api_key, current: nil)
result = syncer.call
expect(result).to be_a(ApplicationService::Result)
@@ -105,7 +105,7 @@
context "when operation succeeds" do
it "has success? true and failed? false" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
+ syncer = described_class.new(record: valid_record, api_key: api_key, current: nil)
result = syncer.call
expect(result.success?).to be true
@@ -126,7 +126,7 @@
end
it "has success? false and failed? true" do
- syncer = described_class.new(record: invalid_record, api_key: api_key)
+ syncer = described_class.new(record: invalid_record, api_key: api_key, current: nil)
result = syncer.call
expect(result.success?).to be false
@@ -149,7 +149,7 @@
end
it "consistently reports :create operation" do
- syncer = described_class.new(record: create_record, api_key: api_key)
+ syncer = described_class.new(record: create_record, api_key: api_key, current: nil)
result = syncer.call
expect(result.data.operation).to eq(:create)
@@ -174,8 +174,7 @@
end
it "consistently reports :external_update operation" do
- existing_external_facility
- syncer = described_class.new(record: update_record, api_key: api_key)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_external_facility)
result = syncer.call
expect(result.data.operation).to eq(:external_update)
@@ -200,8 +199,7 @@
end
it "consistently reports :internal_update operation" do
- existing_internal_facility
- syncer = described_class.new(record: update_record, api_key: api_key)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_internal_facility)
result = syncer.call
expect(result.data.operation).to eq(:internal_update)
@@ -221,7 +219,7 @@
end
it "result facility matches database record" do
- syncer = described_class.new(record: valid_record, api_key: api_key)
+ syncer = described_class.new(record: valid_record, api_key: api_key, current: nil)
result = syncer.call
db_facility = Facility.find(result.data.facility.id)
@@ -248,8 +246,7 @@
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)
+ syncer = described_class.new(record: update_record, api_key: api_key, current: existing_facility)
result = syncer.call
expect(result.data.facility.id).to eq(existing_facility.id)
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..6a273f5e 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
@@ -35,7 +35,7 @@
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)
+ syncer = described_class.new(record: record_with_new_service, api_key: api_key, current: existing_facility)
result = syncer.call
facility = result.data.facility
@@ -46,7 +46,7 @@
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)
+ syncer = described_class.new(record: record_with_new_service, api_key: api_key, current: existing_facility)
result = syncer.call
facility = result.data.facility
@@ -73,7 +73,7 @@
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)
+ syncer = described_class.new(record: record_with_existing_services, api_key: api_key, current: existing_facility)
result = syncer.call
facility = result.data.facility
@@ -81,7 +81,7 @@
end
it "maintains all existing services" do
- syncer = described_class.new(record: record_with_existing_services, api_key: api_key)
+ syncer = described_class.new(record: record_with_existing_services, api_key: api_key, current: existing_facility)
result = syncer.call
facility = result.data.facility
@@ -92,6 +92,9 @@
context "when built facility has duplicate services in builder" do
# This tests the .uniq call in add_missing_services
+ let!(:existing_facility) do
+ create(:facility, external_id: "DUPLICATE_TEST123")
+ end
let(:record) do
{
@@ -102,7 +105,7 @@
end
it "handles duplicate services gracefully" do
- syncer = described_class.new(record: record, api_key: api_key)
+ syncer = described_class.new(record: record, api_key: api_key, current: existing_facility)
allow(syncer).to receive(:add_missing_services).and_call_original
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..5113fb73
--- /dev/null
+++ b/spec/services/external/vancouver_city/facility_syncer/undelete_facility_spec.rb
@@ -0,0 +1,272 @@
+# frozen_string_literal: true
+
+# rubocop:disable RSpec/SpecFilePathFormat
+
+require "rails_helper"
+
+RSpec.describe External::VancouverCity::FacilitySyncer, "#call - undelete scenarios", type: :service do
+ 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!(: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
+
+ it "undeletes the facility before updating" do
+ syncer = described_class.new(record: update_record, api_key: api_key, current: discarded_facility)
+ result = syncer.call
+
+ expect(result).to be_success
+ expect(result.data.facility.id).to eq(discarded_facility.id)
+ expect(result.data.facility).not_to be_discarded
+ expect(result.data.operation).to eq(:external_update)
+ end
+
+ it "restores facility to active state" do
+ expect do
+ syncer = described_class.new(record: update_record, api_key: api_key, current: discarded_facility)
+ syncer.call
+ end.to change { discarded_facility.reload.undiscarded? }.from(false).to(true)
+ end
+
+ it "updates the facility attributes" do
+ syncer = described_class.new(record: update_record, api_key: api_key, current: discarded_facility)
+ result = syncer.call
+
+ facility = result.data.facility.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
+ syncer = described_class.new(record: update_record, api_key: api_key, current: discarded_facility)
+ result = syncer.call
+
+ facility = result.data.facility.reload
+ expect(facility.discard_reason).to be_nil
+ end
+ end
+
+ context "when discarded facility has matching name (internal update)" do
+ 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
+ syncer = described_class.new(record: name_match_record, api_key: api_key, current: discarded_internal_facility)
+ result = syncer.call
+
+ expect(result).to be_success
+ expect(result.data.facility.id).to eq(discarded_internal_facility.id)
+ expect(result.data.facility).not_to be_discarded
+ expect(result.data.operation).to eq(:internal_update)
+ end
+
+ it "adds new services to the undeleted facility" do
+ original_service_count = discarded_internal_facility.facility_services.count
+
+ syncer = described_class.new(record: name_match_record, api_key: api_key, current: discarded_internal_facility)
+ result = syncer.call
+
+ facility = result.data.facility.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 = described_class.new(record: name_match_record, api_key: api_key, current: discarded_internal_facility)
+ syncer.call
+ end.to change { discarded_internal_facility.reload.undiscarded? }.from(false).to(true)
+ end
+ end
+
+ context "when multiple discarded facilities exist" do
+ 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(record: first_record, api_key: api_key, current: first_discarded)
+ result1 = syncer1.call
+
+ expect(result1).to be_success
+ expect(result1.data.facility.id).to eq(first_discarded.id)
+ expect(result1.data.facility).not_to be_discarded
+
+ # Second sync
+ syncer2 = described_class.new(record: second_record, api_key: api_key, current: second_discarded)
+ result2 = syncer2.call
+
+ expect(result2).to be_success
+ expect(result2.data.facility.id).to eq(second_discarded.id)
+ expect(result2.data.facility).not_to be_discarded
+ end
+ end
+
+ context "when discarded facility matches by external_id but name differs" do
+ 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
+ syncer = described_class.new(record: renamed_record, api_key: api_key, current: discarded_facility)
+ result = syncer.call
+
+ expect(result).to be_success
+ expect(result.data.facility.id).to eq(discarded_facility.id)
+ expect(result.data.operation).to eq(:external_update)
+ expect(result.data.facility.name).to eq("Completely New Name")
+ end
+ end
+
+ context "when interaction with kept facilities" do
+ 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
+ syncer = described_class.new(record: update_record, api_key: api_key, current: kept_facility)
+ result = syncer.call
+
+ expect(result).to be_success
+ expect(result.data.facility.id).to eq(kept_facility.id)
+ expect(result.data.operation).to eq(:external_update)
+ end
+
+ it "does not change discard state of kept facilities" do
+ expect do
+ syncer = described_class.new(record: update_record, api_key: api_key, current: kept_facility)
+ syncer.call
+ end.not_to(change { kept_facility.reload.discarded? })
+ end
+ end
+
+ context "when name match with discarded internal facility" do
+ 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
+ syncer = described_class.new(record: name_record, api_key: api_key, current: discarded_internal)
+ result = syncer.call
+
+ expect(result).to be_success
+ expect(result.data.facility.id).to eq(discarded_internal.id)
+ expect(result.data.operation).to eq(:internal_update)
+ expect(result.data.facility).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..30dcc978 100644
--- a/spec/services/external/vancouver_city/syncer_spec.rb
+++ b/spec/services/external/vancouver_city/syncer_spec.rb
@@ -34,6 +34,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
@@ -468,6 +495,168 @@
expect(result.data[:api_key]).to eq(api_key)
end
end
+
+ context "with full_sync: true (default)" do
+ let(:sample_records) { [{ "mapid" => "FOO123", "name" => "Test Fountain" }] }
+ let(:sample_facility) { create(:facility, :with_verified, external_id: "FOO123", name: "Test Fountain") }
+ let(:response) do
+ instance_double(Faraday::Response, body: { "results" => sample_records })
+ end
+
+ let(:syncer_result) do
+ ApplicationService::Result.new(
+ data: External::VancouverCity::FacilitySyncer::ResultData.new(
+ operation: :create,
+ facility: sample_facility
+ ),
+ errors: []
+ )
+ end
+
+ let!(:existing_facility) do
+ create(:facility, :with_verified, external_id: "EXISTING456", name: "Existing Fountain")
+ end
+
+ before do
+ allow(External::ApiHelper).to receive(:supported_api?).with(api_key).and_return(true)
+ allow(api_client).to receive(:get_dataset_records)
+ .with(api_key, limit: page_size, offset: 0)
+ .and_return(response)
+ allow(External::VancouverCity::FacilitySyncer).to receive(:call).and_return(syncer_result)
+ allow(logger).to receive(:info)
+ allow(logger).to receive(:warn)
+ end
+
+ it "discards facilities not in the API response" 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
+
+ it "returns deleted_count in result" do
+ result = syncer.call
+
+ expect(result.data[:deleted_count]).to eq(1)
+ end
+
+ it "does not re-discard facilities that were previously sync_removed" do
+ # Create a facility with external_id NOT in the API response (simulating previously removed)
+ # The facility is actually discarded with discard_reason = sync_removed
+ discarded_facility = create(:facility, :with_verified,
+ external_id: "DISCARDED789",
+ name: "Previously Discarded",
+ discard_reason: :sync_removed)
+ # Actually discard it (soft-delete) since that's what sync_removed means
+ discarded_facility.discard!
+
+ # Verify it's actually discarded
+ expect(discarded_facility.reload).to be_discarded
+
+ # Run the syncer - the DISCARDED789 facility should NOT be re-discarded
+ # because it was already removed during a previous sync
+ result = syncer.call
+
+ expect(result.success?).to be true
+ # The facility should remain discarded (not re-discarded)
+ expect(discarded_facility.reload).to be_discarded
+ end
+ end
+
+ context "with full_sync: false" do
+ let(:sample_records) { [{ "mapid" => "FOO123", "name" => "Test Fountain" }] }
+ let(:sample_facility) { create(:facility, :with_verified, external_id: "FOO123", name: "Test Fountain") }
+ let(:response) do
+ instance_double(Faraday::Response, body: { "results" => sample_records })
+ end
+
+ let(:syncer) { described_class.new(api_key: api_key, api_client: api_client, full_sync: false) }
+
+ let(:syncer_result) do
+ ApplicationService::Result.new(
+ data: External::VancouverCity::FacilitySyncer::ResultData.new(
+ operation: :create,
+ facility: sample_facility
+ ),
+ errors: []
+ )
+ end
+
+ let!(:orphan_facility) do
+ create(:facility, :with_verified, external_id: "ORPHAN456", name: "Orphan Fountain")
+ end
+
+ before do
+ allow(External::ApiHelper).to receive(:supported_api?).with(api_key).and_return(true)
+ allow(api_client).to receive(:get_dataset_records)
+ .with(api_key, limit: page_size, offset: 0)
+ .and_return(response)
+ allow(External::VancouverCity::FacilitySyncer).to receive(:call).and_return(syncer_result)
+ allow(logger).to receive(:info)
+ end
+
+ it "does not discard orphan facilities" do
+ result = syncer.call
+
+ expect(result.success?).to be true
+ expect(orphan_facility.reload).not_to be_discarded
+ end
+
+ it "returns deleted_count of 0" do
+ result = syncer.call
+
+ expect(result.data[:deleted_count]).to eq(0)
+ end
+ end
+
+ context "with operation counts in result" do
+ let(:sample_records) { [{ "mapid" => "NEW123", "name" => "New Fountain" }] }
+ let(:response) do
+ instance_double(Faraday::Response, body: { "results" => sample_records })
+ end
+
+ let(:created_facility) { create(:facility, :with_verified, external_id: "NEW123", name: "New Fountain") }
+ let(:updated_facility) { create(:facility, :with_verified, external_id: "OLD123", name: "Old Fountain") }
+
+ let(:create_result) do
+ ApplicationService::Result.new(
+ data: External::VancouverCity::FacilitySyncer::ResultData.new(
+ operation: :create,
+ facility: created_facility
+ ),
+ errors: []
+ )
+ end
+
+ let(:update_result) do
+ ApplicationService::Result.new(
+ data: External::VancouverCity::FacilitySyncer::ResultData.new(
+ operation: :external_update,
+ facility: updated_facility
+ ),
+ errors: []
+ )
+ end
+
+ before do
+ allow(External::ApiHelper).to receive(:supported_api?).with(api_key).and_return(true)
+ allow(api_client).to receive(:get_dataset_records)
+ .with(api_key, limit: page_size, offset: 0)
+ .and_return(response)
+ allow(External::VancouverCity::FacilitySyncer).to receive(:call)
+ .and_return(create_result)
+ allow(logger).to receive(:info)
+ end
+
+ it "returns created_count, updated_count, and deleted_count" do
+ result = syncer.call
+
+ expect(result.data[:created_count]).to be_an(Integer)
+ expect(result.data[:updated_count]).to be_an(Integer)
+ expect(result.data[:deleted_count]).to be_an(Integer)
+ end
+ end
end
describe "private methods" do
diff --git a/spec/support/pages/admin_tools_page.rb b/spec/support/pages/admin_tools_page.rb
new file mode 100644
index 00000000..405b2127
--- /dev/null
+++ b/spec/support/pages/admin_tools_page.rb
@@ -0,0 +1,40 @@
+# 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
+ click_link "Discard"
+ self
+ end
+
+ def has_import_form?
+ page.has_css?("#import-form")
+ end
+
+ def has_discard_form?
+ page.has_css?("#discard-form")
+ 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