+
Downloads by File
diff --git a/better_together.gemspec b/better_together.gemspec
index 9c1d05289..f7b86ab58 100644
--- a/better_together.gemspec
+++ b/better_together.gemspec
@@ -65,6 +65,7 @@ Gem::Specification.new do |spec|
spec.add_dependency 'reform-rails', '>= 0.2', '< 0.4'
spec.add_dependency 'rswag', '>= 2.3.1', '< 2.17.0'
spec.add_dependency 'ruby-openai'
+ spec.add_dependency 'sidekiq-scheduler'
spec.add_dependency 'simple_calendar'
spec.add_dependency 'sprockets-rails'
spec.add_dependency 'stackprof'
diff --git a/config/initializers/sidekiq_cron.rb b/config/initializers/sidekiq_cron.rb
new file mode 100644
index 000000000..0dc3bf7ea
--- /dev/null
+++ b/config/initializers/sidekiq_cron.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# Load sidekiq-cron schedule from YAML on Sidekiq server boot.
+if defined?(Sidekiq) && Sidekiq.server?
+ schedule_file = Rails.root.join('config', 'sidekiq_cron.yml')
+
+ if schedule_file.exist?
+ begin
+ schedule = YAML.safe_load(schedule_file.read) || {}
+ Sidekiq::Cron::Job.load_from_hash(schedule)
+ Rails.logger.info "Loaded sidekiq-cron schedule from #{schedule_file}"
+ rescue StandardError => e
+ Rails.logger.error "Failed to load sidekiq-cron schedule: #{e.message}"
+ end
+ end
+end
diff --git a/config/initializers/sidekiq_scheduler.rb b/config/initializers/sidekiq_scheduler.rb
new file mode 100644
index 000000000..9cfbec67c
--- /dev/null
+++ b/config/initializers/sidekiq_scheduler.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+# Load Sidekiq Scheduler schedule file when Sidekiq server starts.
+if defined?(Sidekiq) && Sidekiq.server?
+ schedule_file = Rails.root.join('config', 'sidekiq_scheduler.yml')
+
+ if schedule_file.exist?
+ begin
+ schedule = YAML.safe_load(schedule_file.read) || {}
+ Sidekiq.schedule = schedule
+ Sidekiq::Scheduler.reload_schedule!
+ Rails.logger.info "Loaded Sidekiq Scheduler from #{schedule_file}"
+ rescue StandardError => e
+ Rails.logger.error "Failed to load Sidekiq Scheduler: #{e.message}"
+ end
+ end
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 0d0df6c58..e41e41d65 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -2121,7 +2121,9 @@ en:
new: New %{resource}
view_page: View Page
headers:
+ link_checker_reports: Link checker reports
link_click_reports: Link Click Reports
+ new_link_checker_report: New link checker report
new_link_click_report: New Link Click Report
new_page_view_report: New Page View Report
page_view_reports: Page View Reports
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 58afa9906..f74b325c7 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -2124,7 +2124,9 @@ es:
new: Nuevo %{resource}
view_page: Ver página
headers:
+ link_checker_reports: Informes del verificador de enlaces
link_click_reports: Informes de clics en enlaces
+ new_link_checker_report: Nuevo informe del verificador de enlaces
new_link_click_report: Nuevo informe de clics en enlaces
new_page_view_report: Nuevo informe de vistas de página
page_view_reports: Informes de vistas de página
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 0b6fd8b8f..8f4b69b70 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -2152,7 +2152,9 @@ fr:
new: Nouveau %{resource}
view_page: Voir la page
headers:
+ link_checker_reports: Rapports du vérificateur de liens
link_click_reports: Rapports de clics sur les liens
+ new_link_checker_report: Nouveau rapport du vérificateur de liens
new_link_click_report: Nouveau rapport de clics sur les liens
new_page_view_report: Nouveau rapport de vues de page
page_view_reports: Rapports de vues de page
diff --git a/config/routes.rb b/config/routes.rb
index 4c488b124..db92c2685 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -199,6 +199,12 @@
end
end
+ resources :link_checker_reports, only: %i[index new create] do
+ member do
+ get :download
+ end
+ end
+
resources :page_view_reports, only: %i[index new create] do
member do
get :download
diff --git a/config/schedule.rb b/config/schedule.rb
new file mode 100644
index 000000000..9f495c624
--- /dev/null
+++ b/config/schedule.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+# Use this file with the 'whenever' gem to schedule the daily link checker report.
+# Example: whenever --update-crontab
+
+set :output, 'log/cron.log'
+
+every 1.day, at: '2:00 am' do
+ rake 'metrics:link_checker_daily'
+end
diff --git a/config/sidekiq_cron.README.md b/config/sidekiq_cron.README.md
new file mode 100644
index 000000000..8b356eb38
--- /dev/null
+++ b/config/sidekiq_cron.README.md
@@ -0,0 +1,37 @@
+Sidekiq Cron schedules
+
+This repository includes a `config/sidekiq_cron.yml` file with scheduled jobs for
+use with the `sidekiq-cron` gem. The file currently defines:
+
+- `better_together:link_checker_report_daily` — runs
+ `BetterTogether::Metrics::LinkCheckerReportSchedulerJob` daily at 02:00 UTC on
+ the `metrics` queue. The job will call the report generator and email the
+ generated report to the application's default `from` address.
+
+How to enable
+
+1. Add `gem 'sidekiq-cron'` to your Gemfile (if not already present) and run
+ `bundle install`.
+2. In your Sidekiq initializer (for example `config/initializers/sidekiq.rb`),
+ load the schedule on boot:
+
+```ruby
+# config/initializers/sidekiq.rb
+if defined?(Sidekiq) && Sidekiq.server?
+ schedule_file = Rails.root.join('config', 'sidekiq_cron.yml')
+ if schedule_file.exist?
+ Sidekiq::Cron::Job.load_from_hash YAML.safe_load(schedule_file.read)
+ end
+end
+```
+
+3. Ensure your Sidekiq process is started in server mode (not only client). In
+ Docker/compose setups, run the Sidekiq container with the application code
+ and environment variables as usual.
+
+Notes
+
+- The cron times in `sidekiq_cron.yml` are interpreted by the host running
+ Sidekiq. Use UTC or adjust the times to your preferred timezone.
+- Alternatively you can keep the existing `lib/tasks`/`whenever` approach; this
+ file is provided so Sidekiq-based scheduling is available as an option.
diff --git a/config/sidekiq_cron.yml b/config/sidekiq_cron.yml
new file mode 100644
index 000000000..cc0090c3a
--- /dev/null
+++ b/config/sidekiq_cron.yml
@@ -0,0 +1,12 @@
+"better_together:link_checker_report_daily":
+ class: "BetterTogether::Metrics::LinkCheckerReportSchedulerJob"
+ cron: "0 2 * * *" # daily at 02:00 UTC
+ queue: "metrics"
+ description: "Generate Link Checker daily report and email it to the app from-address"
+ args: []
+
+"better_together:event_reminder_scan_hourly":
+ class: "BetterTogether::EventReminderScanJob"
+ cron: "0 * * * *" # hourly
+ queue: "notifications"
+ description: "Scan upcoming events and schedule per-event reminders"
diff --git a/config/sidekiq_scheduler.yml b/config/sidekiq_scheduler.yml
new file mode 100644
index 000000000..a9d825ed6
--- /dev/null
+++ b/config/sidekiq_scheduler.yml
@@ -0,0 +1,11 @@
+"better_together:link_checker_report_daily":
+ cron: "0 2 * * *" # daily at 02:00 UTC
+ class: "BetterTogether::Metrics::LinkCheckerReportSchedulerJob"
+ queue: "metrics"
+ description: "Generate Link Checker daily report and email it to the app from-address"
+
+"better_together:event_reminder_scan_hourly":
+ cron: "0 * * * *" # hourly
+ class: "BetterTogether::EventReminderScanJob"
+ queue: "notifications"
+ description: "Scan upcoming events and schedule per-event reminders"
diff --git a/db/migrate/20241124181740_create_better_together_content_links.rb b/db/migrate/20241124181740_create_better_together_content_links.rb
new file mode 100644
index 000000000..1ace76359
--- /dev/null
+++ b/db/migrate/20241124181740_create_better_together_content_links.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# Migration to create the persistent links table used by the
+# BetterTogether rich text link metrics system.
+class CreateBetterTogetherContentLinks < ActiveRecord::Migration[7.1]
+ # rubocop:disable Metrics/MethodLength
+ def change
+ create_bt_table :links, prefix: :better_together_content do |t|
+ t.string :link_type, null: false, index: true
+ t.string :url, null: false, index: true
+ t.string :scheme
+ t.string :host, index: true
+ t.boolean :external, index: true
+ t.boolean :valid_link, index: true
+ t.datetime :last_checked_at, index: true
+ t.string :latest_status_code, index: true
+ t.text :error_message
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+end
diff --git a/db/migrate/20241125190948_create_better_together_metrics_rich_text_links.rb b/db/migrate/20241125190948_create_better_together_metrics_rich_text_links.rb
new file mode 100644
index 000000000..1b4165545
--- /dev/null
+++ b/db/migrate/20241125190948_create_better_together_metrics_rich_text_links.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# Migration to create the RichText link join table used for metrics and
+# associations between ActionText content and discovered links.
+class CreateBetterTogetherMetricsRichTextLinks < ActiveRecord::Migration[7.1]
+ def change
+ return if table_exists? :better_together_metrics_rich_text_links
+
+ create_bt_table :rich_text_links, prefix: :better_together_metrics do |t|
+ t.bt_references :link, foreign_key: { to_table: :better_together_content_links }
+ t.bt_references :rich_text, foreign_key: { to_table: :action_text_rich_texts }
+ t.bt_references :rich_text_record, polymorphic: true, index: { name: 'by_rich_text_link_record' }
+ t.bt_position # index in the RichText links array
+ t.bt_locale # locale of the RichText content
+
+ t.index %i[rich_text_id position locale], unique: true
+ end
+ end
+end
diff --git a/db/migrate/20250902203000_add_rich_text_link_associations.rb b/db/migrate/20250902203000_add_rich_text_link_associations.rb
new file mode 100644
index 000000000..bbd627170
--- /dev/null
+++ b/db/migrate/20250902203000_add_rich_text_link_associations.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+# Migration to add association columns to better_together_metrics_rich_text_links
+class AddRichTextLinkAssociations < ActiveRecord::Migration[7.1]
+ def change
+ add_column :better_together_metrics_rich_text_links, :link_id, :uuid
+ add_column :better_together_metrics_rich_text_links, :rich_text_record_type, :string
+ add_column :better_together_metrics_rich_text_links, :rich_text_record_id, :uuid
+
+ add_index :better_together_metrics_rich_text_links, :link_id,
+ name: 'bt_metrics_rich_text_links_on_link_id'
+ add_index :better_together_metrics_rich_text_links, %i[rich_text_record_type rich_text_record_id],
+ name: 'bt_metrics_rich_text_links_on_rich_text_record'
+ end
+end
diff --git a/db/migrate/20250902203001_create_better_together_metrics_link_checker_reports.rb b/db/migrate/20250902203001_create_better_together_metrics_link_checker_reports.rb
new file mode 100644
index 000000000..4dd663a09
--- /dev/null
+++ b/db/migrate/20250902203001_create_better_together_metrics_link_checker_reports.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+# Migration to create the better_together_metrics_link_checker_reports table.
+#
+# This migration defines the following columns:
+# - filters: JSONB column to store filter criteria, defaults to an empty object, cannot be null.
+# - file_format: String column to specify the format of the report file, defaults to 'csv', cannot be null.
+# - report_data: JSONB column to store the report data, defaults to an empty object, cannot be null.
+#
+# Additionally, it adds a GIN index on the filters column to optimize queries involving JSONB data.
+# Migration to create the metrics link checker reports table used by the LinkCheckerReport model
+class CreateBetterTogetherMetricsLinkCheckerReports < ActiveRecord::Migration[7.1]
+ def change
+ return if table_exists? :better_together_metrics_link_checker_reports
+
+ create_bt_table :metrics_link_checker_reports do |t|
+ t.jsonb :filters, default: {}, null: false
+ t.string :file_format, default: 'csv', null: false
+ t.jsonb :report_data, default: {}, null: false
+ end
+
+ add_index :better_together_metrics_link_checker_reports, :filters, using: :gin, name: 'index_better_together_metrics_link_checker_reports_on_filters' # rubocop:disable Layout/LineLength
+ end
+end
diff --git a/db/migrate/20250902203002_add_position_and_locale_to_rich_text_links.rb b/db/migrate/20250902203002_add_position_and_locale_to_rich_text_links.rb
new file mode 100644
index 000000000..0356667b4
--- /dev/null
+++ b/db/migrate/20250902203002_add_position_and_locale_to_rich_text_links.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+# Migration to add position and locale columns (and a unique index) to the
+# better_together_metrics_rich_text_links table for ordering and locale-aware
+# rich-text link tracking.
+class AddPositionAndLocaleToRichTextLinks < ActiveRecord::Migration[7.1]
+ def change
+ add_column :better_together_metrics_rich_text_links, :position, :integer, null: false, default: 0
+ add_column :better_together_metrics_rich_text_links, :locale, :string, limit: 5, null: false,
+ default: I18n.default_locale
+
+ add_index :better_together_metrics_rich_text_links, %i[rich_text_id position locale],
+ name: 'idx_bt_rtl_on_rich_text_pos_loc', unique: true
+ end
+end
diff --git a/db/migrate/20250902203003_rename_valid_to_valid_link_on_metrics_rich_text_links.rb b/db/migrate/20250902203003_rename_valid_to_valid_link_on_metrics_rich_text_links.rb
new file mode 100644
index 000000000..9ac9c2d38
--- /dev/null
+++ b/db/migrate/20250902203003_rename_valid_to_valid_link_on_metrics_rich_text_links.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+# Migration to rename a legacy `valid` boolean column to `valid_link` on the
+# better_together_metrics_rich_text_links table to avoid method name collisions
+# with ActiveRecord predicate methods.
+class RenameValidToValidLinkOnMetricsRichTextLinks < ActiveRecord::Migration[7.1]
+ def change
+ table = :better_together_metrics_rich_text_links
+ return unless column_exists?(table, :valid) && !column_exists?(table, :valid_link)
+
+ rename_column table, :valid, :valid_link
+ end
+end
diff --git a/db/migrate/20250902203004_ensure_link_type_default_on_content_links.rb b/db/migrate/20250902203004_ensure_link_type_default_on_content_links.rb
new file mode 100644
index 000000000..25027ee37
--- /dev/null
+++ b/db/migrate/20250902203004_ensure_link_type_default_on_content_links.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+# Migration to ensure `link_type` on better_together_content_links has a sensible
+# default (`'website'`) and is non-nullable to satisfy callers that expect a
+# present link_type value.
+class EnsureLinkTypeDefaultOnContentLinks < ActiveRecord::Migration[7.1]
+ def up
+ table = :better_together_content_links
+ if column_exists?(table, :link_type)
+ change_column_default table, :link_type, 'website'
+ # Set existing nulls to default before enforcing NOT NULL
+ execute <<-SQL.squish
+ UPDATE #{table} SET link_type = 'website' WHERE link_type IS NULL
+ SQL
+ change_column_null table, :link_type, false
+ else
+ add_column table, :link_type, :string, null: false, default: 'website'
+ end
+ end
+
+ def down
+ table = :better_together_content_links
+ return unless column_exists?(table, :link_type)
+
+ change_column_null table, :link_type, true
+ change_column_default table, :link_type, nil
+ end
+end
diff --git a/diagrams/source/rich_text_link_checker_flow.mmd b/diagrams/source/rich_text_link_checker_flow.mmd
new file mode 100644
index 000000000..06fc308f6
--- /dev/null
+++ b/diagrams/source/rich_text_link_checker_flow.mmd
@@ -0,0 +1,11 @@
+%% Mermaid source: RichText Link Checker Flow
+flowchart TD
+ A[ActionText::RichText records] --> B[RichTextLinkIdentifier]
+ B --> C[BetterTogether::Content::Link]
+ B --> D[BetterTogether::Metrics::RichTextLink]
+ E[Rake: check task] --> F[RichTextLinkCheckerQueueJob (internal)]
+ E --> G[RichTextLinkCheckerQueueJob (external)]
+ F --> H[InternalLinkCheckerJob]
+ G --> I[ExternalLinkCheckerJob]
+ H --> C
+ I --> C
diff --git a/docs/rich_text_link_checker.md b/docs/rich_text_link_checker.md
new file mode 100644
index 000000000..92f65c8a8
--- /dev/null
+++ b/docs/rich_text_link_checker.md
@@ -0,0 +1,29 @@
+# RichText Link Checker
+
+This document describes the pipeline that identifies links in ActionText rich content and checks them.
+
+Process overview:
+
+1. Identify: `BetterTogether::Metrics::RichTextLinkIdentifier` scans `ActionText::RichText` records and extracts links.
+2. Persist: For each link, create or find a `BetterTogether::Content::Link` and a `BetterTogether::Metrics::RichTextLink` join record.
+3. Queue: `rich_text:links:check` Rake task enqueues two queue jobs: internal and external checker queues.
+4. Distribute: `RichTextLinkCheckerQueueJob` groups links by host and schedules child check jobs over a time window to avoid bursts.
+5. Check: Child jobs (`InternalLinkCheckerJob` and `ExternalLinkCheckerJob`) perform HTTP HEAD requests and update Link metadata.
+
+Documentation files:
+- diagrams/source/rich_text_link_checker_flow.mmd (Mermaid source)
+- diagrams/exports/png/rich_text_link_checker_flow.png (export placeholder)
+
+Running locally:
+
+Use Docker wrapper for commands that need DB access (see repo README):
+
+```
+bin/dc-run rails runner "BetterTogether::Metrics::RichTextLinkIdentifier.call"
+bin/dc-run rake better_together:qa:rich_text:links:identify
+bin/dc-run rake better_together:qa:rich_text:links:check
+```
+
+Notes:
+- External HTTP checks are rate-limited by the queueing logic. Configure behavior in the queue job if needed.
+- Tests use WebMock to stub external HTTP calls.
diff --git a/lib/tasks/link_checker_reports.rake b/lib/tasks/link_checker_reports.rake
new file mode 100644
index 000000000..c7bfcbdee
--- /dev/null
+++ b/lib/tasks/link_checker_reports.rake
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+namespace :metrics do
+ desc 'Generate and email a Link Checker report for the previous day'
+ task link_checker_daily: :environment do
+ yesterday = 1.day.ago.beginning_of_day.strftime('%Y-%m-%d')
+ today = 1.day.ago.end_of_day.strftime('%Y-%m-%d')
+ BetterTogether::Metrics::LinkCheckerReportSchedulerJob.perform_later(
+ from_date: yesterday,
+ to_date: today,
+ file_format: 'csv'
+ )
+
+ puts "Enqueued LinkCheckerReportSchedulerJob for #{yesterday} -> #{today}"
+ end
+end
diff --git a/lib/tasks/qa.experiments.txt b/lib/tasks/qa.experiments.txt
new file mode 100644
index 000000000..af5aa0b44
--- /dev/null
+++ b/lib/tasks/qa.experiments.txt
@@ -0,0 +1,62 @@
+
+
+sorted_valid_links = valid_rich_text_links.sort_by(&:link)
+sorted_invalid_links = invalid_rich_text_links.sort_by(&:link)
+
+puts 'valid links:', sorted_valid_links.size
+puts 'invalid links:', sorted_invalid_links.size
+
+# puts invalid_rich_text_links.map(&:link)
+
+valid_uri_links = sorted_valid_links.select do |link|
+ link.link_type.include?('valid:uri')
+end
+
+valid_internal_uri_links = valid_uri_links.select do |link|
+ link.external == false
+end
+
+valid_external_uri_links = valid_uri_links - valid_internal_uri_links
+
+puts 'valid URI links:', valid_uri_links.size
+puts 'valid internal URI links:', valid_internal_uri_links.size
+puts 'valid external URI links:', valid_external_uri_links.size
+
+uri_link_hosts = valid_uri_links.group_by do |link|
+ link.uri.host
+end
+
+mapped_link_hosts = uri_link_hosts.transform_values do |values|
+ values.sort_by(&:link).group_by(&:link)
+end
+
+# sorted_mapped_link_hosts = mapped_link_hosts.sort_by(&:first)
+
+unique_link_hosts = mapped_link_hosts.transform_values do |values|
+ { unique_host_links: values.keys.size, total_host_link_uses: values.map {|k, v| v.size}.sum, links: values.map {|k, v| { uri: k, code: nil, size: v.size, links: v } }}
+end
+
+potential_bad_locale_internal_links = valid_internal_uri_links.select do |link|
+ link.link.include?('/es/en/') or link.link.include?('/en/es/')
+end
+
+# puts 'mapped_link_hosts', mapped_link_hosts
+# puts 'sorted_mapped_link_hosts', sorted_mapped_link_hosts.to_h
+# puts 'uri_link_hosts', JSON.pretty_generate(uri_link_hosts)
+# puts 'sorted_mapped_link_hosts', JSON.pretty_generate(sorted_mapped_link_hosts.to_h)
+# puts 'unique_host_links', JSON.pretty_generate(unique_link_hosts)
+puts 'unique host count', unique_link_hosts.keys.size
+puts 'unique link count', unique_link_hosts.map {|k, v| v[:unique_host_links]}.sum
+puts 'total link uses', unique_link_hosts.map {|k, v| v[:total_host_link_uses]}.sum
+
+# puts 'valid internal links', valid_internal_uri_links.map(&:link)
+
+puts 'potential bad locale internal links', potential_bad_locale_internal_links.map(&:link), potential_bad_locale_internal_links.size
+
+bad_locale_link_record_gids = potential_bad_locale_internal_links.map(&:rt_record_sgid)
+
+records = GlobalID::Locator.locate_many bad_locale_link_record_gids
+
+puts 'records:', records.size
+
+puts 'page_urls', records.map { |record| record.pages.map(&:url).map {|url| url.gsub(BetterTogether.base_url, platform_uri)} if record.respond_to? :pages }
\ No newline at end of file
diff --git a/lib/tasks/quality_assurance.rake b/lib/tasks/quality_assurance.rake
new file mode 100644
index 000000000..d5ed0ab73
--- /dev/null
+++ b/lib/tasks/quality_assurance.rake
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+# rubocop:disable Metrics/BlockLength
+
+namespace :better_together do
+ namespace :qa do
+ namespace :rich_text do
+ namespace :links do
+ desc 'Generates report of status of RichText links'
+ task identify: :environment do
+ result = BetterTogether::Metrics::RichTextLinkIdentifier.call
+ puts "Valid links processed: #{result[:valid]}"
+ puts "Invalid links processed: #{result[:invalid]}"
+ end
+
+ desc 'checks rich text links and returns their status code'
+ task check: :environment do
+ BetterTogether::Metrics::RichTextInternalLinkCheckerQueueJob.perform_later
+ BetterTogether::Metrics::RichTextExternalLinkCheckerQueueJob.perform_later
+ end
+
+ def determine_link_type(uri, internal_link)
+ if uri.scheme == 'mailto'
+ 'email'
+ elsif uri.scheme == 'tel'
+ 'phone'
+ elsif internal_link
+ 'internal'
+ else
+ 'external'
+ end
+ end
+ end
+ end
+ end
+end
+
+# rubocop:enable Metrics/BlockLength
diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb
index a69eb114f..fd257d78b 100644
--- a/spec/dummy/db/schema.rb
+++ b/spec/dummy/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2025_09_01_203002) do
+ActiveRecord::Schema[7.2].define(version: 2025_09_02_203004) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -221,9 +221,16 @@
t.uuid "creator_id"
t.string "identifier", limit: 100, null: false
t.string "privacy", limit: 50, default: "private", null: false
+ t.string "interestable_type"
+ t.uuid "interestable_id"
+ t.datetime "starts_at"
+ t.datetime "ends_at"
t.index ["creator_id"], name: "by_better_together_calls_for_interest_creator"
+ t.index ["ends_at"], name: "bt_calls_for_interest_by_ends_at"
t.index ["identifier"], name: "index_better_together_calls_for_interest_on_identifier", unique: true
+ t.index ["interestable_type", "interestable_id"], name: "index_better_together_calls_for_interest_on_interestable"
t.index ["privacy"], name: "by_better_together_calls_for_interest_privacy"
+ t.index ["starts_at"], name: "bt_calls_for_interest_by_starts_at"
end
create_table "better_together_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -233,7 +240,6 @@
t.string "identifier", limit: 100, null: false
t.integer "position", null: false
t.boolean "protected", default: false, null: false
- t.boolean "visible", default: true, null: false
t.string "type", default: "BetterTogether::Category", null: false
t.string "icon", default: "fas fa-icons", null: false
t.index ["identifier", "type"], name: "index_better_together_categories_on_identifier_and_type", unique: true
@@ -243,12 +249,12 @@
t.integer "lock_version", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.string "category_type", null: false
t.uuid "category_id", null: false
t.string "categorizable_type", null: false
t.uuid "categorizable_id", null: false
- t.string "category_type", null: false
t.index ["categorizable_type", "categorizable_id"], name: "index_better_together_categorizations_on_categorizable"
- t.index ["category_id"], name: "index_better_together_categorizations_on_category_id"
+ t.index ["category_type", "category_id"], name: "index_better_together_categorizations_on_category"
end
create_table "better_together_checklist_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -348,6 +354,28 @@
t.index ["privacy"], name: "by_better_together_content_blocks_privacy"
end
+ create_table "better_together_content_links", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.integer "lock_version", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "link_type", default: "website", null: false
+ t.string "url", null: false
+ t.string "scheme"
+ t.string "host"
+ t.boolean "external"
+ t.boolean "valid_link"
+ t.datetime "last_checked_at"
+ t.string "latest_status_code"
+ t.text "error_message"
+ t.index ["external"], name: "index_better_together_content_links_on_external"
+ t.index ["host"], name: "index_better_together_content_links_on_host"
+ t.index ["last_checked_at"], name: "index_better_together_content_links_on_last_checked_at"
+ t.index ["latest_status_code"], name: "index_better_together_content_links_on_latest_status_code"
+ t.index ["link_type"], name: "index_better_together_content_links_on_link_type"
+ t.index ["url"], name: "index_better_together_content_links_on_url"
+ t.index ["valid_link"], name: "index_better_together_content_links_on_valid_link"
+ end
+
create_table "better_together_content_page_blocks", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.integer "lock_version", default: 0, null: false
t.datetime "created_at", null: false
@@ -711,8 +739,6 @@
t.uuid "invitee_id"
t.string "invitee_email", null: false
t.uuid "role_id"
- t.uuid "primary_invitation_id"
- t.integer "session_duration_mins", default: 30, null: false
t.index ["invitable_id", "status"], name: "invitations_on_invitable_id_and_status"
t.index ["invitable_type", "invitable_id"], name: "by_invitable"
t.index ["invitee_email", "invitable_id"], name: "invitations_on_invitee_email_and_invitable_id", unique: true
@@ -721,7 +747,6 @@
t.index ["invitee_type", "invitee_id"], name: "by_invitee"
t.index ["inviter_type", "inviter_id"], name: "by_inviter"
t.index ["locale"], name: "by_better_together_invitations_locale"
- t.index ["primary_invitation_id"], name: "index_better_together_invitations_on_primary_invitation_id"
t.index ["role_id"], name: "by_role"
t.index ["status"], name: "by_status"
t.index ["token"], name: "invitations_by_token", unique: true
@@ -825,6 +850,16 @@
t.index ["locale"], name: "by_better_together_metrics_downloads_locale"
end
+ create_table "better_together_metrics_link_checker_reports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.integer "lock_version", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.jsonb "filters", default: {}, null: false
+ t.string "file_format", default: "csv", null: false
+ t.jsonb "report_data", default: {}, null: false
+ t.index ["filters"], name: "index_better_together_metrics_link_checker_reports_on_filters", using: :gin
+ end
+
create_table "better_together_metrics_link_click_reports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.integer "lock_version", default: 0, null: false
t.datetime "created_at", null: false
@@ -871,6 +906,28 @@
t.index ["pageable_type", "pageable_id"], name: "index_better_together_metrics_page_views_on_pageable"
end
+ create_table "better_together_metrics_rich_text_links", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.integer "lock_version", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.uuid "rich_text_id", null: false
+ t.string "url", null: false
+ t.string "link_type", null: false
+ t.boolean "external", null: false
+ t.boolean "valid_link", default: false
+ t.string "host"
+ t.text "error_message"
+ t.uuid "link_id"
+ t.string "rich_text_record_type"
+ t.uuid "rich_text_record_id"
+ t.integer "position", default: 0, null: false
+ t.string "locale", limit: 5, default: "en", null: false
+ t.index ["link_id"], name: "bt_metrics_rich_text_links_on_link_id"
+ t.index ["rich_text_id", "position", "locale"], name: "idx_bt_rtl_on_rich_text_pos_loc", unique: true
+ t.index ["rich_text_id"], name: "index_better_together_metrics_rich_text_links_on_rich_text_id"
+ t.index ["rich_text_record_type", "rich_text_record_id"], name: "bt_metrics_rich_text_links_on_rich_text_record"
+ end
+
create_table "better_together_metrics_search_queries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.integer "lock_version", default: 0, null: false
t.datetime "created_at", null: false
@@ -942,10 +999,10 @@
t.datetime "updated_at", null: false
t.string "identifier", limit: 100, null: false
t.boolean "protected", default: false, null: false
+ t.string "privacy", limit: 50, default: "private", null: false
t.text "meta_description"
t.string "keywords"
t.datetime "published_at"
- t.string "privacy", default: "private", null: false
t.string "layout"
t.string "template"
t.uuid "sidebar_nav_id"
@@ -1010,6 +1067,29 @@
t.index ["role_id"], name: "person_community_membership_by_role"
end
+ create_table "better_together_person_platform_integrations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.integer "lock_version", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "provider", limit: 50, default: "", null: false
+ t.string "uid", limit: 50, default: "", null: false
+ t.string "name"
+ t.string "handle"
+ t.string "profile_url"
+ t.string "image_url"
+ t.string "access_token"
+ t.string "access_token_secret"
+ t.string "refresh_token"
+ t.datetime "expires_at"
+ t.jsonb "auth"
+ t.uuid "person_id"
+ t.uuid "platform_id"
+ t.uuid "user_id"
+ t.index ["person_id"], name: "bt_person_platform_conections_by_person"
+ t.index ["platform_id"], name: "bt_person_platform_conections_by_platform"
+ t.index ["user_id"], name: "bt_person_platform_conections_by_user"
+ end
+
create_table "better_together_person_platform_memberships", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.integer "lock_version", default: 0, null: false
t.datetime "created_at", null: false
@@ -1080,7 +1160,7 @@
t.index ["invitee_email"], name: "platform_invitations_by_invitee_email"
t.index ["invitee_id"], name: "platform_invitations_by_invitee"
t.index ["inviter_id"], name: "platform_invitations_by_inviter"
- t.index ["locale"], name: "platform_invitations_by_locale"
+ t.index ["locale"], name: "by_better_together_platform_invitations_locale"
t.index ["platform_role_id"], name: "platform_invitations_by_platform_role"
t.index ["status"], name: "platform_invitations_by_status"
t.index ["token"], name: "platform_invitations_by_token", unique: true
@@ -1097,7 +1177,6 @@
t.boolean "protected", default: false, null: false
t.uuid "community_id", null: false
t.string "privacy", limit: 50, default: "private", null: false
- t.string "slug"
t.string "url", null: false
t.string "time_zone", null: false
t.jsonb "settings", default: {}, null: false
@@ -1173,6 +1252,31 @@
t.index ["resource_type", "position"], name: "index_roles_on_resource_type_and_position", unique: true
end
+ create_table "better_together_seeds", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.integer "lock_version", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "type", default: "BetterTogether::Seed", null: false
+ t.string "seedable_type"
+ t.uuid "seedable_id"
+ t.uuid "creator_id"
+ t.string "identifier", limit: 100, null: false
+ t.string "privacy", limit: 50, default: "private", null: false
+ t.string "version", null: false
+ t.string "created_by", null: false
+ t.datetime "seeded_at", null: false
+ t.text "description", null: false
+ t.jsonb "origin", null: false
+ t.jsonb "payload", null: false
+ t.index ["creator_id"], name: "by_better_together_seeds_creator"
+ t.index ["identifier"], name: "index_better_together_seeds_on_identifier", unique: true
+ t.index ["origin"], name: "index_better_together_seeds_on_origin", using: :gin
+ t.index ["payload"], name: "index_better_together_seeds_on_payload", using: :gin
+ t.index ["privacy"], name: "by_better_together_seeds_privacy"
+ t.index ["seedable_type", "seedable_id"], name: "index_better_together_seeds_on_seedable"
+ t.index ["type", "identifier"], name: "index_better_together_seeds_on_type_and_identifier", unique: true
+ end
+
create_table "better_together_social_media_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.integer "lock_version", default: 0, null: false
t.datetime "created_at", null: false
diff --git a/spec/factories/better_together/content/links.rb b/spec/factories/better_together/content/links.rb
new file mode 100644
index 000000000..3ee6cfd79
--- /dev/null
+++ b/spec/factories/better_together/content/links.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :content_link, class: 'BetterTogether::Content::Link' do
+ link_type { 'website' }
+ sequence(:url) { |n| "https://example.test/#{n}" }
+ scheme { 'https' }
+ host { URI.parse(url).host }
+ external { false }
+ valid_link { false }
+ end
+end
diff --git a/spec/factories/better_together/content/rich_texts.rb b/spec/factories/better_together/content/rich_texts.rb
index 3f02381c2..6e4237540 100644
--- a/spec/factories/better_together/content/rich_texts.rb
+++ b/spec/factories/better_together/content/rich_texts.rb
@@ -1,6 +1,13 @@
# frozen_string_literal: true
FactoryBot.define do
+ factory :content_rich_text, class: 'ActionText::RichText' do
+ association :record, factory: :platform
+ name { 'body' }
+ locale { I18n.default_locale }
+ body { '' }
+ end
+
factory :content_block_rich_text, class: 'BetterTogether::Content::Block::RichText' do # rubocop:todo Lint/EmptyBlock
end
end
diff --git a/spec/factories/better_together/metrics/rich_text_links.rb b/spec/factories/better_together/metrics/rich_text_links.rb
new file mode 100644
index 000000000..54b9393ca
--- /dev/null
+++ b/spec/factories/better_together/metrics/rich_text_links.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :metrics_rich_text_link, class: 'Metrics::RichTextLink' do
+ # minimal factory to satisfy linter; expand in tests as needed
+ initialize_with { new }
+ end
+end
diff --git a/spec/jobs/better_together/metrics/external_link_checker_job_spec.rb b/spec/jobs/better_together/metrics/external_link_checker_job_spec.rb
new file mode 100644
index 000000000..763b5edf2
--- /dev/null
+++ b/spec/jobs/better_together/metrics/external_link_checker_job_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+require 'webmock/rspec'
+
+module BetterTogether
+ RSpec.describe Metrics::ExternalLinkCheckerJob do
+ let(:link) { create(:content_link, url: 'https://external.test/', valid_link: false) }
+
+ it 'updates link status on success' do
+ stub_request(:head, 'https://external.test/').to_return(status: 200)
+
+ described_class.new.perform(link.id)
+
+ link.reload
+ expect([link.valid_link, link.latest_status_code]).to eq([true, '200'])
+ end
+ end
+end
diff --git a/spec/jobs/better_together/metrics/internal_link_checker_job_spec.rb b/spec/jobs/better_together/metrics/internal_link_checker_job_spec.rb
new file mode 100644
index 000000000..47a22884c
--- /dev/null
+++ b/spec/jobs/better_together/metrics/internal_link_checker_job_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+require 'webmock/rspec'
+
+module BetterTogether
+ RSpec.describe Metrics::InternalLinkCheckerJob do
+ let(:link) { create(:content_link, url: 'https://example.com/', valid_link: false) }
+
+ it 'updates link status on success' do
+ stub_request(:head, 'https://example.com/').to_return(status: 200)
+
+ described_class.new.perform(link.id)
+
+ link.reload
+ expect([link.valid_link, link.latest_status_code]).to eq([true, '200'])
+ end
+ end
+end
diff --git a/spec/jobs/better_together/metrics/link_checker_report_scheduler_job_spec.rb b/spec/jobs/better_together/metrics/link_checker_report_scheduler_job_spec.rb
new file mode 100644
index 000000000..95af6621e
--- /dev/null
+++ b/spec/jobs/better_together/metrics/link_checker_report_scheduler_job_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe BetterTogether::Metrics::LinkCheckerReportSchedulerJob do
+ include ActiveJob::TestHelper
+
+ let(:from_date) { Date.parse('2025-09-01') }
+ let(:to_date) { Date.parse('2025-09-01') }
+
+ before do
+ fake_report_class = Class.new do
+ def self.create_and_generate!(_opts = {})
+ file_struct = Struct.new(:attached?)
+ report_file = file_struct.new(true)
+ Struct.new(:id, :report_file).new(1, report_file)
+ end
+ end
+
+ stub_const('BetterTogether::Metrics::LinkCheckerReport', fake_report_class)
+ end
+
+ it 'runs without error' do
+ expect { described_class.perform_now(from_date: from_date, to_date: to_date) }.not_to raise_error
+ end
+end
diff --git a/spec/jobs/better_together/metrics/rich_text_link_checker_queue_job_spec.rb b/spec/jobs/better_together/metrics/rich_text_link_checker_queue_job_spec.rb
new file mode 100644
index 000000000..5baf7aaf8
--- /dev/null
+++ b/spec/jobs/better_together/metrics/rich_text_link_checker_queue_job_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+module BetterTogether
+ module Metrics
+ RSpec.describe RichTextLinkCheckerQueueJob do
+ let(:job) { described_class.new }
+
+ before do
+ dummy_job = Class.new(ActiveJob::Base)
+ allow(job).to receive(:child_job_class).and_return(dummy_job)
+
+ # Prepare fake links grouped by host: two hosts with two links each
+ @links_by_host = {
+ 'a.test' => [instance_double(BetterTogether::Content::Link, id: 1),
+ instance_double(BetterTogether::Content::Link, id: 2)],
+ 'b.test' => [instance_double(BetterTogether::Content::Link, id: 3),
+ instance_double(BetterTogether::Content::Link, id: 4)]
+ }
+
+ total_links_count = @links_by_host.values.map(&:size).sum
+
+ # Stub model_collection.where(host: host) to return the array of links for that host
+ model_collection_double = instance_double(ActiveRecord::Relation, size: total_links_count)
+ allow(model_collection_double).to receive_messages(group: model_collection_double,
+ order: model_collection_double)
+ allow(model_collection_double).to receive_messages(size: total_links_count,
+ count: @links_by_host.transform_values(&:size))
+ allow(model_collection_double).to receive(:where) do |h|
+ @links_by_host[h[:host]] || []
+ end
+ allow(job).to receive_messages(records_by_host: @links_by_host.transform_values(&:size),
+ model_collection: model_collection_double)
+ # Use ActiveJob test adapter to capture enqueued jobs
+ ActiveJob::Base.queue_adapter = :test
+ end
+
+ it 'schedules child jobs spread across time window per host' do
+ expect { job.perform }.to change { ActiveJob::Base.queue_adapter.enqueued_jobs.size }.by(4)
+ end
+ end
+ end
+end
diff --git a/spec/mailers/better_together/metrics/report_mailer_spec.rb b/spec/mailers/better_together/metrics/report_mailer_spec.rb
new file mode 100644
index 000000000..f1c5c511e
--- /dev/null
+++ b/spec/mailers/better_together/metrics/report_mailer_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe BetterTogether::Metrics::ReportMailer do
+ describe '.link_checker_report' do
+ before do
+ fake_report_class = Class.new do
+ def self.find(_id)
+ file_struct = Struct.new(:attached?, :filename, :content_type, :download)
+ file = file_struct.new(true, 'report.csv', 'text/csv', "a,b\n1,2\n")
+
+ Struct.new(:id, :report_file, :created_at).new(1, file, Time.current)
+ end
+ end
+
+ stub_const('BetterTogether::Metrics::LinkCheckerReport', fake_report_class)
+ # We'll stub mail on the mailer instance in the examples rather than
+ # using allow_any_instance_of
+ end
+
+ it 'calls mail with the app from address' do
+ mailer = described_class.new
+ allow(mailer).to receive(:mail).and_return(Mail::Message.new)
+ mailer.link_checker_report(1)
+
+ expect(mailer).to have_received(:mail).with(hash_including(:to))
+ end
+
+ it 'builds attachments on the mailer instance' do
+ mailer = described_class.new
+ allow(mailer).to receive(:mail).and_return(Mail::Message.new)
+ mailer.link_checker_report(1)
+
+ expect(mailer.attachments['report.csv']).not_to be_nil
+ end
+ end
+end
diff --git a/spec/models/better_together/metrics/link_checker_report_spec.rb b/spec/models/better_together/metrics/link_checker_report_spec.rb
new file mode 100644
index 000000000..83e7e5fef
--- /dev/null
+++ b/spec/models/better_together/metrics/link_checker_report_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+class ReportPORO
+ # Minimal PORO reproducing CSV and filename logic so tests run without ActiveRecord
+ attr_accessor :report_data, :file_format, :filters
+
+ # These methods are intentionally slightly large for test clarity. Disable
+ # Metrics cops which are noisy for PORO test helpers.
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
+ def initialize(filters: {}, file_format: 'csv')
+ @filters = filters
+ @file_format = file_format
+ @report_data = {}
+ end
+
+ def build_filename
+ filters_summary = []
+
+ if filters['from_date'].present?
+ from_stamp = Date.parse(filters['from_date']).strftime('%Y-%m-%d')
+ filters_summary << "from_#{from_stamp}"
+ end
+
+ if filters['to_date'].present?
+ to_stamp = Date.parse(filters['to_date']).strftime('%Y-%m-%d')
+ filters_summary << "to_#{to_stamp}"
+ end
+
+ filters_summary = filters_summary.join('_')
+ filters_summary = 'all' if filters_summary.blank?
+
+ timestamp = Time.current.strftime('%Y-%m-%d_%H%M%S')
+
+ "LinkCheckerReport_#{timestamp}_#{filters_summary}.#{file_format}"
+ end
+
+ def generate_csv_file
+ file_path = Rails.root.join('tmp', build_filename)
+
+ CSV.open(file_path, 'w') do |csv|
+ csv << ['Host', 'Total Links', 'Invalid Links']
+
+ hosts = (report_data['by_host'] || {}).keys
+ hosts.each do |host|
+ total = (report_data['by_host'] || {})[host] || 0
+ invalid = (report_data['invalid_by_host'] || {})[host] || 0
+ csv << [host, total, invalid]
+ end
+
+ csv << []
+ csv << ['Date', 'Invalid Count']
+ (report_data['failures_daily'] || {}).each do |date, count|
+ csv << [date.to_s, count]
+ end
+ end
+
+ file_path
+ end
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
+end
+
+RSpec.describe ReportPORO do # rubocop:disable RSpec/SpecFilePathFormat
+ describe 'CSV generation and filename' do
+ let(:report) do
+ described_class.new(filters: { 'from_date' => '2025-09-01', 'to_date' => '2025-09-02' })
+ end
+
+ before do
+ report.report_data = {
+ 'by_host' => { 'example.com' => 5, 'other.test' => 3 },
+ 'invalid_by_host' => { 'example.com' => 2, 'other.test' => 1 },
+ 'failures_daily' => { Date.parse('2025-09-01') => 2, Date.parse('2025-09-02') => 1 }
+ }
+ end
+
+ it 'creates a CSV with the expected rows' do
+ file_path = report.generate_csv_file
+ csv = CSV.read(file_path)
+
+ expect(csv.size).to eq(7)
+
+ require 'fileutils'
+ FileUtils.rm_f(file_path)
+ end
+
+ it 'builds a filename with stamps and extension' do
+ fn = report.build_filename
+ expect(fn).to match(/LinkCheckerReport_\d{4}-\d{2}-\d{2}_\d{6}_from_2025-09-01_to_2025-09-02.csv/)
+ end
+ end
+end
diff --git a/spec/models/better_together/metrics/rich_text_link_spec.rb b/spec/models/better_together/metrics/rich_text_link_spec.rb
new file mode 100644
index 000000000..159d9ad55
--- /dev/null
+++ b/spec/models/better_together/metrics/rich_text_link_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+module BetterTogether
+ RSpec.describe Metrics::RichTextLink do
+ pending "add some examples to (or delete) #{__FILE__}"
+ end
+end
diff --git a/spec/requests/better_together/metrics/link_checker_reports_controller_spec.rb b/spec/requests/better_together/metrics/link_checker_reports_controller_spec.rb
new file mode 100644
index 000000000..a71825744
--- /dev/null
+++ b/spec/requests/better_together/metrics/link_checker_reports_controller_spec.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe BetterTogether::Metrics::LinkCheckerReportsController, :as_platform_manager do
+ let(:locale) { I18n.default_locale }
+
+ before do
+ fake_report_class = Class.new do
+ # Provide a model_name so Rails form_with/form_for can render without ActiveRecord
+ def self.model_name
+ ActiveModel::Name.new(self, nil, 'LinkCheckerReport')
+ end
+
+ # Instances should also respond to model_name so form helpers that receive
+ # a record (not the class) work correctly.
+ def model_name
+ self.class.model_name
+ end
+
+ # form helpers may check persisted? when rendering forms
+ def persisted?
+ false
+ end
+
+ # Instances should expose filters so form helpers like f.select can read values
+ def filters
+ {}
+ end
+
+ def self.order(*)
+ []
+ end
+
+ def self.create_and_generate!(*_args)
+ file = Struct.new(:attached?).new(false)
+ Struct.new(:id, :persisted?, :report_file).new(SecureRandom.uuid, true, file)
+ end
+
+ def self.find(id)
+ # default find used in download test will be stubbed further inside that example
+ file = Struct.new(:attached?).new(false)
+ Struct.new(:id, :report_file).new(id, file)
+ end
+ end
+
+ stub_const('BetterTogether::Metrics::LinkCheckerReport', fake_report_class)
+ end
+
+ describe 'GET /:locale/.../metrics/link_checker_reports' do
+ before do
+ get better_together.metrics_link_checker_reports_path(locale:)
+ end
+
+ it 'renders index' do
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'renders new' do
+ get better_together.new_metrics_link_checker_report_path(locale:)
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ describe 'POST /:locale/.../metrics/link_checker_reports' do
+ before do
+ post better_together.metrics_link_checker_reports_path(locale:), params: {
+ metrics_link_checker_report: {
+ file_format: 'csv',
+ filters: { from_date: '', to_date: '' }
+ }
+ }
+ end
+
+ it 'creates a report and redirects with valid params' do
+ expect(response).to have_http_status(:found)
+ end
+
+ it 'follows the redirect and renders ok' do
+ follow_redirect!
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ describe 'GET /:locale/.../metrics/link_checker_reports/:id/download' do
+ let(:file_contents) { 'a,b\n1,2\n' }
+ let(:file_struct) { Struct.new(:attached?, :filename, :content_type, :download, :byte_size) }
+ let(:fake_file) { file_struct.new(true, 'report.csv', 'text/csv', file_contents, file_contents.bytesize) }
+ let(:fake_report) { Struct.new(:id, :report_file, :created_at).new('fake-id', fake_file, Time.current) }
+
+ before do
+ allow(BetterTogether::Metrics::LinkCheckerReport).to receive(:find).with('fake-id').and_return(fake_report)
+ allow(BetterTogether::Metrics::TrackDownloadJob).to receive(:perform_later)
+ get better_together.download_metrics_link_checker_report_path(locale:, id: 'fake-id')
+ end
+
+ it 'enqueues TrackDownloadJob when file is attached' do
+ expect(BetterTogether::Metrics::TrackDownloadJob).to have_received(:perform_later)
+ .with(fake_report, 'report.csv', 'text/csv', kind_of(Integer), I18n.locale.to_s)
+ end
+
+ it 'sends the file when attached' do
+ expect(response.header['Content-Disposition']).to include('attachment')
+ end
+ end
+end
diff --git a/spec/services/better_together/metrics/http_link_checker_spec.rb b/spec/services/better_together/metrics/http_link_checker_spec.rb
new file mode 100644
index 000000000..2a05ebc00
--- /dev/null
+++ b/spec/services/better_together/metrics/http_link_checker_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+require 'webmock/rspec'
+
+module BetterTogether
+ RSpec.describe Metrics::HttpLinkChecker do
+ it 'returns success for 200 head response' do
+ stub_request(:head, 'https://ok.test/').to_return(status: 200)
+
+ result = described_class.new('https://ok.test/').call
+
+ # success + status code present and no error
+ expect([result.success, result.status_code, result.error]).to eq([true, '200', nil])
+ end
+
+ it 'retries and returns failure for unreachable host' do # rubocop:todo RSpec/MultipleExpectations
+ stub_request(:head, 'https://nope.test/').to_timeout
+
+ result = described_class.new('https://nope.test/', retries: 1).call
+
+ # failed, no status_code, error present (error should be a StandardError ancestor)
+ expect(result.success).to be(false)
+ expect(result.status_code).to be_nil
+ expect(result.error).to be_a(StandardError)
+ end
+ end
+end
diff --git a/spec/services/better_together/metrics/rich_text_link_identifier_spec.rb b/spec/services/better_together/metrics/rich_text_link_identifier_spec.rb
new file mode 100644
index 000000000..75defa496
--- /dev/null
+++ b/spec/services/better_together/metrics/rich_text_link_identifier_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+module BetterTogether
+ RSpec.describe Metrics::RichTextLinkIdentifier do
+ let(:rich_text) { create(:content_rich_text, body: '
link') }
+
+ it 'creates link and rich_text_link records' do
+ result = described_class.call(rich_texts: [rich_text])
+
+ # One valid link created with a corresponding RichTextLink join
+ link = BetterTogether::Content::Link.find_by(url: 'https://example.com/foo')
+ expect([result[:valid], !link.nil?,
+ BetterTogether::Metrics::RichTextLink.where(rich_text_id: rich_text.id).count]).to eq([1, true, 1])
+ end
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index e3d8d147e..820c01caa 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -21,6 +21,12 @@
require 'simplecov'
require 'coveralls'
require 'rspec/rebound'
+require 'webmock/rspec'
+
+# Disable real external HTTP connections in tests but allow localhost so
+# Capybara drivers (cuprite/ferrum/selenium) can communicate with the app
+# server started by the test suite.
+WebMock.disable_net_connect!(allow_localhost: true)
# Allow CI/local runs to override coverage output to avoid permission issues
SimpleCov.coverage_dir ENV['SIMPLECOV_DIR'] if ENV['SIMPLECOV_DIR']