diff --git a/.coveralls.yml b/.coveralls.yml deleted file mode 100644 index 2c0280866..000000000 --- a/.coveralls.yml +++ /dev/null @@ -1,2 +0,0 @@ - -service_name: travis-pro diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ba81eac26..20c331c66 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -269,3 +269,28 @@ Note: The helper set lives under `spec/support/automatic_test_configuration.rb` expect(response).to redirect_to(person_blocks_path) ``` - **Factory Requirements**: Every Better Together model needs a corresponding FactoryBot factory with proper engine namespace handling + +### RSpec Best Practices +- **Named subjects for explicit references**: When using `expect(subject)` with complex matchers (like `have_many`), always define a named subject in the describe block: + ```ruby + describe 'associations' do + subject(:model_name) { build(:factory_name) } + + it { is_expected.to belong_to(:association) } + + it do + expect(model_name).to have_many(:items) + .class_name('Namespace::Item') + .dependent(:destroy) + end + end + ``` +- **Pending tests require reasons**: Use `skip` inside `it` blocks with a descriptive reason instead of `xit`: + ```ruby + it 'complex feature requiring external service' do + skip 'External service not available in test environment' + # test code here + end + ``` +- **Use `is_expected.to` for simple one-line matchers**: Prefer implicit subject with `is_expected.to` for single-assertion tests +- **Use named subject for multi-line or complex matchers**: Define `subject(:name)` when tests need explicit subject references diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b6425b608..000000000 --- a/.travis.yml +++ /dev/null @@ -1,26 +0,0 @@ -sudo: required -language: ruby -services: - - docker -env: - - DOCKER_COMPOSE_VERSION=1.22.0 CI=true TRAVIS=true -before_install: - - curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - - - sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" - - sudo apt-get update - - sudo apt-get -y install docker-ce - - sudo rm /usr/local/bin/docker-compose - - curl -L https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` > docker-compose - - chmod +x docker-compose - - sudo mv docker-compose /usr/local/bin - - echo "POSTGRES_USER=postgres" >> docker-env.conf - - echo "POSTGRES_PASSWORD=postgres" >> docker-env.conf - # - docker-compose up -d bundle - # - docker-compose up -d db - - docker-compose build -after_success: -- CI=true TRAVIS=true coveralls --verbose -script: - - docker-compose run app bundle - - docker-compose run app bash -c "cd ./spec/dummy && bundle exec rake db:setup" - - CI=true TRAVIS=true docker-compose run app bundle exec rspec diff --git a/AGENTS.md b/AGENTS.md index f754090af..c9210f208 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -276,6 +276,31 @@ Note: The helper set lives under `spec/support/automatic_test_configuration.rb` - **Integration**: Test complete user workflows and cross-model interactions. - **Feature Tests**: End-to-end stakeholder workflows validating acceptance criteria. +### RSpec Best Practices +- **Named subjects for explicit references**: When using `expect(subject)` with complex matchers (like `have_many`), always define a named subject in the describe block: + ```ruby + describe 'associations' do + subject(:model_name) { build(:factory_name) } + + it { is_expected.to belong_to(:association) } + + it do + expect(model_name).to have_many(:items) + .class_name('Namespace::Item') + .dependent(:destroy) + end + end + ``` +- **Pending tests require reasons**: Use `skip` inside `it` blocks with a descriptive reason instead of `xit`: + ```ruby + it 'complex feature requiring external service' do + skip 'External service not available in test environment' + # test code here + end + ``` +- **Use `is_expected.to` for simple one-line matchers**: Prefer implicit subject with `is_expected.to` for single-assertion tests +- **Use named subject for multi-line or complex matchers**: Define `subject(:name)` when tests need explicit subject references + ## TDD Test Types by Stakeholder Need ### End User-Focused Tests diff --git a/Gemfile.lock b/Gemfile.lock index 7bd229b29..071fe185a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -55,6 +55,7 @@ PATH rack-cors (>= 1.1.1, < 3.1.0) rack-mini-profiler rails (>= 7.2, < 8.1) + redcarpet (~> 3.6) reform-rails (>= 0.2, < 0.4) rswag (>= 2.3.1, < 2.18.0) ruby-openai @@ -609,6 +610,7 @@ GEM erb psych (>= 4.0.0) tsort + redcarpet (3.6.1) redis (5.4.1) redis-client (>= 0.22.0) redis-client (0.26.1) diff --git a/app/assets/stylesheets/better_together/content_blocks.scss b/app/assets/stylesheets/better_together/content_blocks.scss index 6e3e0fe38..aff07cf41 100644 --- a/app/assets/stylesheets/better_together/content_blocks.scss +++ b/app/assets/stylesheets/better_together/content_blocks.scss @@ -241,3 +241,197 @@ margin-left: 5px; } } + +// Markdown content styling - GitHub-flavored markdown styles +.markdown-content { + line-height: 1.6; + color: #24292e; + + // Headings + h1, h2, h3, h4, h5, h6 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: 600; + line-height: 1.25; + border-bottom: 1px solid #eaecef; + padding-bottom: 0.3em; + } + + h1 { font-size: 2em; } + h2 { font-size: 1.5em; } + h3 { font-size: 1.25em; } + h4 { font-size: 1em; } + h5 { font-size: 0.875em; } + h6 { font-size: 0.85em; color: #6a737d; } + + // Paragraphs and text + p { + margin-top: 0; + margin-bottom: 16px; + } + + // Links + a { + color: #0366d6; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + // Lists + ul, ol { + padding-left: 2em; + margin-top: 0; + margin-bottom: 16px; + } + + li { + margin-bottom: 0.25em; + + > p { + margin-top: 16px; + } + + + li { + margin-top: 0.25em; + } + } + + // Blockquotes + blockquote { + padding: 0 1em; + color: #6a737d; + border-left: 0.25em solid #dfe2e5; + margin: 0 0 16px 0; + + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + } + + // Code blocks + pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + background-color: #f6f8fa; + border-radius: 6px; + margin-bottom: 16px; + + code { + display: inline; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; + } + } + + // Inline code + code { + padding: 0.2em 0.4em; + margin: 0; + font-size: 85%; + background-color: rgba(27, 31, 35, 0.05); + border-radius: 6px; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + } + + // Tables + table { + border-spacing: 0; + border-collapse: collapse; + margin-top: 0; + margin-bottom: 16px; + width: 100%; + overflow: auto; + + th { + font-weight: 600; + padding: 6px 13px; + border: 1px solid #dfe2e5; + background-color: #f6f8fa; + } + + td { + padding: 6px 13px; + border: 1px solid #dfe2e5; + } + + tr { + background-color: #fff; + border-top: 1px solid #c6cbd1; + + &:nth-child(2n) { + background-color: #f6f8fa; + } + } + } + + // Horizontal rules + hr { + height: 0.25em; + padding: 0; + margin: 24px 0; + background-color: #e1e4e8; + border: 0; + } + + // Task lists + .task-list-item { + list-style-type: none; + + input { + margin: 0 0.2em 0.25em -1.6em; + vertical-align: middle; + } + } + + // Images + img { + max-width: 100%; + box-sizing: content-box; + background-color: #fff; + border-radius: 6px; + } + + // Strikethrough + del { + text-decoration: line-through; + } + + // Superscript + sup { + vertical-align: super; + font-size: smaller; + } + + // Highlight + mark { + background-color: #fff740; + padding: 0.2em; + } + + // Footnotes + .footnotes { + margin-top: 32px; + padding-top: 16px; + border-top: 1px solid #e1e4e8; + font-size: 0.9em; + color: #6a737d; + + ol { + padding-left: 1.5em; + } + } +} diff --git a/app/builders/better_together/documentation_builder.rb b/app/builders/better_together/documentation_builder.rb new file mode 100644 index 000000000..1a2c34ca5 --- /dev/null +++ b/app/builders/better_together/documentation_builder.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +# app/builders/better_together/documentation_builder.rb + +module BetterTogether + # Builds documentation navigation from markdown files in the docs/ directory + class DocumentationBuilder < Builder # rubocop:todo Metrics/ClassLength + class << self + def build # rubocop:todo Metrics/MethodLength, Metrics/AbcSize + I18n.with_locale(:en) do + entries = documentation_entries + return if entries.blank? + + area = if (existing_area = ::BetterTogether::NavigationArea.i18n.find_by(slug: 'documentation')) + existing_area.navigation_items.delete_all + existing_area.update!(name: 'Documentation', visible: true, protected: true) + existing_area + else + ::BetterTogether::NavigationArea.create! do |area| + area.name = 'Documentation' + area.slug = 'documentation' + area.visible = true + area.protected = true + end + end + + entries.each_with_index do |entry, index| + create_documentation_navigation_item(area, entry, index) + end + + area.reload.save! + end + end + + private + + def documentation_entries + root = documentation_root + return [] unless root.directory? + + build_documentation_entries(root) + end + + def build_documentation_entries(current_path) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength + documentation_child_paths(current_path).filter_map do |child| + if child.directory? + children = build_documentation_entries(child) + default_path = default_documentation_file(child) + next if children.blank? && default_path.blank? + + { + type: :directory, + title: documentation_title(child.basename.to_s), + slug: documentation_slug(child), + default_path: default_path, + children: children + } + elsif markdown_file?(child) + { + type: :file, + title: documentation_title(child.basename.sub_ext('').to_s), + slug: documentation_slug(child), + path: documentation_relative_path(child), + children: [] + } + end + end + end + + def documentation_child_paths(path) + Dir.children(path).sort.map { |child| path.join(child) }.select do |child_path| + next false if child_path.basename.to_s.start_with?('.') + + child_path.directory? || markdown_file?(child_path) + end + end + + def markdown_file?(path) + path.file? && path.extname.casecmp('.md').zero? + end + + def create_documentation_navigation_item(area, entry, position, parent: nil) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength + attributes = { + navigation_area: area, + title_en: entry[:title], + position: position, + visible: true, + protected: true, + parent: + } + attributes[:identifier] = entry[:slug] if entry[:slug].present? + + if entry[:type] == :directory + attributes[:item_type] = 'dropdown' + if entry[:default_path].present? + attributes[:linkable] = documentation_page_for(entry[:title], entry[:default_path], area) + else + attributes[:url] = '#' + end + item = create_documentation_item_with_context(area, attributes) + entry[:children].each_with_index do |child, index| + create_documentation_navigation_item(area, child, index, parent: item) + end + else + attributes[:item_type] = 'link' + attributes[:linkable] = documentation_page_for(entry[:title], entry[:path], area) + create_documentation_item_with_context(area, attributes) + end + end + + def documentation_title(name) + base = name.to_s.sub(/\.md\z/i, '') + return 'Overview' if base.casecmp('readme').zero? + + base.tr('_-', ' ').squeeze(' ').strip.titleize + end + + def documentation_relative_path(path) + path.relative_path_from(documentation_root).to_s + end + + def documentation_url(relative_path) + File.join(documentation_url_prefix, relative_path) + end + + def create_documentation_item_with_context(area, attributes) + puts "Creating documentation navigation item #{attributes.inspect}" if ENV['DEBUG_DOCUMENTATION_NAV'] == '1' + area.navigation_items.create!(attributes) + rescue ActiveRecord::RecordInvalid => e + raise ActiveRecord::RecordInvalid.new(e.record), "#{e.message} -- #{attributes.inspect}" + end + + def documentation_page_for(title, relative_path, sidebar_nav_area = nil) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength + slug = documentation_slug(relative_path) + attrs = documentation_page_attributes(title, slug, relative_path, sidebar_nav_area) + page = ::BetterTogether::Page.i18n.find_by(slug: slug) + + if page + locked_page = ::BetterTogether::Page.lock.find(page.id) + locked_page.page_blocks.destroy_all + locked_page.reload + locked_page.assign_attributes(attrs) + locked_page.save! + # Re-set the slug after save in case FriendlyId regenerated it + locked_page.update_columns(slug: slug) if locked_page.slug != slug + locked_page + else + new_page = ::BetterTogether::Page.create!(attrs) + # Re-set the slug after creation in case FriendlyId regenerated it + new_page.slug = slug if new_page.slug != slug + new_page.save!(validate: false) if new_page.changed? + new_page + end + end + + def documentation_page_attributes(title, slug, relative_path, sidebar_nav_area = nil) # rubocop:todo Metrics/MethodLength + attrs = { + title_en: title, + slug_en: slug, # Set slug directly via Mobility to bypass FriendlyId normalization + published_at: Time.zone.now, + privacy: 'public', + protected: true, + layout: 'layouts/better_together/full_width_page', + page_blocks_attributes: [ + { + block_attributes: { + type: 'BetterTogether::Content::Markdown', + markdown_file_path: documentation_file_path(relative_path) + } + } + ] + } + + # Associate the documentation navigation area as the sidebar nav + attrs[:sidebar_nav] = sidebar_nav_area if sidebar_nav_area.present? + + attrs + end + + def documentation_slug(path) + relative = path.is_a?(Pathname) ? documentation_relative_path(path) : path.to_s + # Remove .md extension, downcase, and preserve directory structure with slashes + base_slug = relative.sub(/\.md\z/i, '').downcase.tr('_', '-') + base_slug = 'overview' if base_slug.blank? + "docs/#{base_slug}" + end + + def documentation_file_path(relative_path) + documentation_root.join(relative_path).to_s + end + + def default_documentation_file(path) + %w[README.md readme.md index.md INDEX.md].each do |filename| + file_path = path.join(filename) + return documentation_relative_path(file_path) if file_path.exist? + end + nil + end + + def documentation_root + BetterTogether::Engine.root.join('docs') + end + + def documentation_url_prefix + '/docs' + end + end + end +end diff --git a/app/builders/better_together/navigation_builder.rb b/app/builders/better_together/navigation_builder.rb index 13b224148..d846785d8 100644 --- a/app/builders/better_together/navigation_builder.rb +++ b/app/builders/better_together/navigation_builder.rb @@ -12,6 +12,7 @@ def seed_data build_host build_better_together build_footer + # DocumentationBuilder.build # TODO: Re-enable when documentation is ready create_unassociated_pages end @@ -29,7 +30,14 @@ def build_better_together # rubocop:todo Metrics/MethodLength, Metrics/AbcSize published_at: Time.zone.now, privacy: 'public', protected: true, - template: 'better_together/static_pages/better_together' + page_blocks_attributes: [ + { + block_attributes: { + type: 'BetterTogether::Content::Template', + template_path: 'better_together/static_pages/better_together' + } + } + ] }, { title_en: 'About the Community Engine', @@ -37,8 +45,15 @@ def build_better_together # rubocop:todo Metrics/MethodLength, Metrics/AbcSize published_at: Time.zone.now, privacy: 'public', protected: true, - template: 'better_together/static_pages/community_engine', - layout: 'layouts/better_together/full_width_page' + layout: 'layouts/better_together/full_width_page', + page_blocks_attributes: [ + { + block_attributes: { + type: 'BetterTogether::Content::Template', + template_path: 'better_together/static_pages/community_engine' + } + } + ] } ] ) @@ -80,7 +95,14 @@ def build_footer # rubocop:todo Metrics/MethodLength, Metrics/AbcSize published_at: Time.zone.now, privacy: 'public', protected: true, - template: 'better_together/static_pages/faq' + page_blocks_attributes: [ + { + block_attributes: { + type: 'BetterTogether::Content::Template', + template_path: 'better_together/static_pages/faq' + } + } + ] }, { title_en: 'Privacy Policy', @@ -88,7 +110,14 @@ def build_footer # rubocop:todo Metrics/MethodLength, Metrics/AbcSize published_at: Time.zone.now, privacy: 'public', protected: true, - template: 'better_together/static_pages/privacy' + page_blocks_attributes: [ + { + block_attributes: { + type: 'BetterTogether::Content::Template', + template_path: 'better_together/static_pages/privacy' + } + } + ] }, { title_en: 'Terms of Service', @@ -96,7 +125,14 @@ def build_footer # rubocop:todo Metrics/MethodLength, Metrics/AbcSize published_at: Time.zone.now, privacy: 'public', protected: true, - template: 'better_together/static_pages/terms_of_service' + page_blocks_attributes: [ + { + block_attributes: { + type: 'BetterTogether::Content::Template', + template_path: 'better_together/static_pages/terms_of_service' + } + } + ] }, { title_en: 'Code of Conduct', @@ -104,7 +140,14 @@ def build_footer # rubocop:todo Metrics/MethodLength, Metrics/AbcSize published_at: Time.zone.now, privacy: 'public', protected: true, - template: 'better_together/static_pages/code_of_conduct' + page_blocks_attributes: [ + { + block_attributes: { + type: 'BetterTogether::Content::Template', + template_path: 'better_together/static_pages/code_of_conduct' + } + } + ] }, { title_en: 'Accessibility', @@ -112,7 +155,29 @@ def build_footer # rubocop:todo Metrics/MethodLength, Metrics/AbcSize published_at: Time.zone.now, privacy: 'public', protected: true, - template: 'better_together/static_pages/accessibility' + page_blocks_attributes: [ + { + block_attributes: { + type: 'BetterTogether::Content::Template', + template_path: 'better_together/static_pages/accessibility' + } + } + ] + }, + { + title_en: 'Cookie Policy', + slug_en: 'cookie-policy', + published_at: Time.zone.now, + privacy: 'public', + protected: true, + page_blocks_attributes: [ + { + block_attributes: { + type: 'BetterTogether::Content::Template', + template_path: 'better_together/static_pages/cookie_consent' + } + } + ] }, { title_en: 'Contact', @@ -124,10 +189,66 @@ def build_footer # rubocop:todo Metrics/MethodLength, Metrics/AbcSize { block_attributes: { type: 'BetterTogether::Content::RichText', + # rubocop:todo Lint/CopDirectiveSyntax + # rubocop:todo Metrics/MethodLength + # rubocop:todo Metrics/MethodLength + # rubocop:todo Lint/CopDirectiveSyntax + # rubocop:todo Lint/CopDirectiveSyntax + # rubocop:todo Lint/CopDirectiveSyntax + # rubocop:todo Lint/CopDirectiveSyntax content_en: <<-HTML
This is a default contact page for your platform. Be sure to write a real one!
HTML + # rubocop:enable Lint/CopDirectiveSyntax + # rubocop:enable Lint/CopDirectiveSyntax + # rubocop:enable Lint/CopDirectiveSyntax + # rubocop:enable Lint/CopDirectiveSyntax + # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/MethodLength + # rubocop:enable Lint/CopDirectiveSyntax + } + }, + { + block_attributes: { + type: 'BetterTogether::Content::Template', + template_path: 'better_together/content/blocks/template/host_community_contact_details' + } + } + ] + } + ] + ) + + # Create contributor agreement pages separately for nested navigation + contributor_agreement_pages = ::BetterTogether::Page.create!( + [ + { + title_en: 'Code Contributor Agreement', + slug_en: 'code-contributor-agreement', + published_at: Time.zone.now, + privacy: 'public', + protected: true, + page_blocks_attributes: [ + { + block_attributes: { + type: 'BetterTogether::Content::Template', + template_path: 'better_together/static_pages/code_contributor_agreement' + } + } + ] + }, + { + title_en: 'Content Contributor Agreement', + slug_en: 'content-contributor-agreement', + published_at: Time.zone.now, + privacy: 'public', + protected: true, + page_blocks_attributes: [ + { + block_attributes: { + type: 'BetterTogether::Content::Template', + template_path: 'better_together/static_pages/content_contributor_agreement' } } ] @@ -143,8 +264,33 @@ def build_footer # rubocop:todo Metrics/MethodLength, Metrics/AbcSize area.protected = true end + # Build navigation items for main footer pages area.reload.build_page_navigation_items(footer_pages) + # Create parent "Contributor Agreements" dropdown navigation item + # Position it after the existing footer items + next_position = area.navigation_items.maximum(:position).to_i + 1 + contributor_agreements_parent = area.navigation_items.create!( + title_en: 'Contributor Agreements', + item_type: 'dropdown', + position: next_position, + visible: true, + protected: true + ) + + # Build child navigation items for contributor agreements + contributor_agreement_pages.each_with_index do |page, index| + area.navigation_items.create!( + title_en: page.title, + linkable: page, + parent: contributor_agreements_parent, + position: index, + item_type: 'link', + visible: true, + protected: true + ) + end + area.save! end end @@ -314,8 +460,60 @@ def clear_existing delete_navigation_areas end - def create_unassociated_pages # rubocop:todo Metrics/MethodLength + # Reset and reseed navigation areas only (preserves pages) + # Usage: BetterTogether::NavigationBuilder.reset_navigation_areas + def reset_navigation_areas + puts 'Resetting navigation areas...' + delete_navigation_items + delete_navigation_areas + puts 'Rebuilding navigation areas...' I18n.with_locale(:en) do + build_header + build_host + build_better_together + build_footer + # DocumentationBuilder.build # TODO: Re-enable when documentation is ready + end + puts 'Navigation areas reset complete!' + end + + # Reset and reseed a specific navigation area + # Usage: BetterTogether::NavigationBuilder.reset_navigation_area('platform-header') + def reset_navigation_area(slug) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength, Lint/CopDirectiveSyntax, Metrics/MethodLength + area = ::BetterTogether::NavigationArea.i18n.find_by(slug: slug) + if area + puts "Resetting navigation area: #{area.name} (#{slug})" + # Delete items for this area + area.navigation_items.where.not(parent_id: nil).delete_all + area.navigation_items.where(parent_id: nil).delete_all + area.delete + + # Rebuild the specific area + I18n.with_locale(:en) do + case slug + when 'platform-header' + build_header + when 'platform-host' + build_host + when 'better-together' + build_better_together + when 'platform-footer' + build_footer + when 'documentation' + DocumentationBuilder.build # Available but not auto-seeded + else + puts "Unknown navigation area slug: #{slug}" + return + end + end + puts "Navigation area '#{slug}' reset complete!" + else + puts "Navigation area with slug '#{slug}' not found" + end + end + + def create_unassociated_pages # rubocop:todo Metrics/MethodLength + I18n.with_locale(:en) do # rubocop:todo Metrics/BlockLength # Create Pages not associated with a navigation area ::BetterTogether::Page.create!( [ @@ -325,8 +523,15 @@ def create_unassociated_pages # rubocop:todo Metrics/MethodLength published_at: Time.zone.now, privacy: 'public', protected: true, - template: 'better_together/static_pages/community_engine', - layout: 'layouts/better_together/full_width_page' + layout: 'layouts/better_together/full_width_page', + page_blocks_attributes: [ + { + block_attributes: { + type: 'BetterTogether::Content::Template', + template_path: 'better_together/static_pages/community_engine' + } + } + ] }, { title_en: 'Subprocessors', @@ -334,7 +539,14 @@ def create_unassociated_pages # rubocop:todo Metrics/MethodLength published_at: Time.zone.now, privacy: 'public', protected: true, - template: 'better_together/static_pages/subprocessors' + page_blocks_attributes: [ + { + block_attributes: { + type: 'BetterTogether::Content::Template', + template_path: 'better_together/static_pages/subprocessors' + } + } + ] } ] ) @@ -348,6 +560,8 @@ def delete_pages end def delete_navigation_areas + # Clear sidebar_nav references before deleting navigation areas + ::BetterTogether::Page.update_all(sidebar_nav_id: nil) ::BetterTogether::NavigationArea.delete_all end diff --git a/app/controllers/better_together/content/blocks_controller.rb b/app/controllers/better_together/content/blocks_controller.rb index 4999ebcbd..71e62e2e5 100644 --- a/app/controllers/better_together/content/blocks_controller.rb +++ b/app/controllers/better_together/content/blocks_controller.rb @@ -7,6 +7,7 @@ class BlocksController < ResourceController before_action :authenticate_user! before_action :disallow_robots before_action :set_block, only: %i[show edit update destroy] + skip_after_action :verify_authorized, only: [:preview_markdown] before_action only: %i[index], if: -> { Rails.env.development? } do # Make sure that all BLock subclasses are loaded in dev to generate new block buttons resource_class.load_all_subclasses @@ -54,14 +55,46 @@ def destroy redirect_to content_blocks_path, notice: t('flash.generic.destroyed', resource: t('resources.block')) end + def preview_markdown # rubocop:todo Metrics/MethodLength + markdown_content = params[:markdown] + + if markdown_content.blank? + render json: { html: 'Preview will appear here...
' } + return + end + + begin + rendered_html = MarkdownRendererService.new(markdown_content).render_html + render json: { html: rendered_html } + rescue StandardError => e + Rails.logger.error "Markdown preview error: #{e.message}" + render json: { + html: '
+ Preview for file: ${this.escapeHtml(filePath)}
+
+ File preview will be available after saving. +
+ ` + return + } + } + + if (markdownContent.trim() === '') { + this.previewTarget.innerHTML = ` ++ Preview will appear here... +
+ ` + return + } + + // Render markdown preview using a simple client-side markdown library + // or make an AJAX call to the server to render it + try { + const response = await this.renderMarkdown(markdownContent) + this.previewTarget.innerHTML = response + } catch (error) { + console.error('Failed to render markdown preview:', error) + this.previewTarget.innerHTML = ` ++ <%= t('better_together.content.blocks.markdown.help.preview_placeholder') %> +
+ <% end %> +Thank you for your interest in contributing to the Better Together Community Engine project ("We" or "Us").
+ +This Contributor License Agreement ("Agreement") documents the rights granted by contributors to Us. To make this document effective, please read it carefully and indicate your agreement by contributing to the project. This Agreement is for your protection as a Contributor as well as the protection of Us and our users; it does not change your rights to use your own Contributions for any other purpose.
+ +Effective Date: November 20, 2025
+ + + +"You" means the individual who Submits a Contribution to Us.
+ +"Contribution" means any work of authorship that is Submitted by You to Us in which You own or assert ownership of the Copyright. If You do not own the Copyright in the entire work of authorship, please contact us at hello@bettertogethersolutions.com.
+ +"Copyright" means all rights protecting works of authorship owned or controlled by You, including copyright, moral and neighboring rights, as appropriate, for the full term of their existence including any extensions by You.
+ +"Material" means the work of authorship which is made available by Us to third parties. When this Agreement covers more than one software project, the Material means the work of authorship to which the Contribution was Submitted. After You Submit the Contribution, it may be included in the Material.
+ +"Submit" means any form of electronic, verbal, or written communication sent to Us or our representatives, including but not limited to electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Us for the purpose of discussing and improving the Material, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
+ +"Submission Date" means the date on which You Submit a Contribution to Us.
+ +"Effective Date" means the date You execute this Agreement or the date You first Submit a Contribution to Us, whichever is earlier.
+ +Subject to the terms and conditions of this Agreement, You hereby grant to Us and to recipients of software distributed by Us a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works.
+ +Subject to the terms and conditions of this Agreement, You hereby grant to Us and to recipients of software distributed by Us a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted.
+ +If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
+ +Based on the grant of rights in Sections 2 and 3, if We include Your Contribution in a Material, We may license the Contribution under any license, including copyleft, permissive, commercial, or proprietary licenses. As a condition on the exercise of this right, We agree to also license the Contribution under the terms of the license or licenses which We are using for the Material on the Submission Date.
+ +The project is currently licensed under the GNU Lesser General Public License v3.0 (LGPL-3.0). We may change the project license in the future, and Your Contribution may be distributed under the new license.
+ +If moral rights apply to the Contribution, to the maximum extent permitted by law, You waive and agree not to assert such moral rights against Us or our successors in interest, or any of our licensees, either direct or indirect.
+ +You acknowledge that We are not obligated to use Your Contribution as part of the Material and may decide to include any Contribution We consider appropriate.
+ +UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING, YOU PROVIDE YOUR CONTRIBUTION ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
+ +TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT WILL YOU BE LIABLE FOR ANY LOSS OF PROFITS, LOSS OF ANTICIPATED SAVINGS, LOSS OF DATA, INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL AND EXEMPLARY DAMAGES ARISING OUT OF THIS AGREEMENT REGARDLESS OF THE LEGAL OR EQUITABLE THEORY (CONTRACT, TORT OR OTHERWISE) UPON WHICH THE CLAIM IS BASED.
+ +You represent that:
+You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
+ +We reserve the right to change this Agreement at any time. Your continued Contributions to the project after such changes constitutes your acceptance of the new Agreement.
+ +This Agreement will be governed by and construed in accordance with the laws of Canada, excluding its conflicts of law provisions. Under certain circumstances, the governing law in this section might be superseded by the United Nations Convention on Contracts for the International Sale of Goods ("UN Convention") and the parties intend to avoid the application of the UN Convention to this Agreement and, thus, exclude the application of the UN Convention in its entirety to this Agreement.
+ +We may assign this Agreement to any third party. You may not assign this Agreement without Our prior written consent.
+ +This Agreement constitutes the entire agreement between the parties concerning the subject matter hereof.
+ +By making a contribution to the Better Together Community Engine project through any of the following methods, you acknowledge that you have read this Contributor License Agreement and agree to be bound by its terms:
+ +If you are making a contribution on behalf of your employer or another entity, you represent that you have the authority to bind that entity to this Agreement.
+ +If you have questions about this Contributor License Agreement, please contact us at hello@bettertogethersolutions.com.
+ +This agreement might seem complicated, but here's what it means in simple terms:
+This Contributor License Agreement is adapted from the Harmony Contributor License Agreement and other industry-standard contributor agreements. Last updated: November 20, 2025.
+Thank you for contributing content to <%= host_platform.name %>. This agreement governs the content you submit to our platform.
+ +This Content Contributor Agreement ("Agreement") documents the rights you grant to us when you contribute content to the platform. By contributing content, you agree to these terms.
+ +Effective Date: November 20, 2025
+ + + +"You" or "Your" means the individual or entity contributing content to the platform.
+ +"We", "Us", or "Our" means <%= host_platform.name %> and Better Together Solutions.
+ +"Content" means any text, images, videos, audio, data, information, or other materials that you submit, upload, post, or otherwise make available on or through the platform, including but not limited to:
+"Platform" means the Better Together Community Engine platform operated at <%= host_platform.url %> and any related services.
+ +Content you mark as "public" or share in public areas of the platform, including public posts, comments, resources, and event listings.
+ +Content shared within specific communities that is visible to community members, including community-specific posts, discussions, and resources.
+ +Content you mark as private or share in private conversations, messages, and restricted areas. Private content is subject to this agreement but with more limited usage rights as described below.
+ +For public and community content, you grant to us a worldwide, non-exclusive, royalty-free, transferable license (with right to sublicense) to:
+For private content, you grant us a more limited license solely to:
+We will not make your private content publicly available or use it for promotional purposes without your explicit consent.
+ +The licenses granted in this section continue for a commercially reasonable period of time after you remove or delete content from the platform. You understand that removed content may persist in backup copies for a reasonable period of time (but will not be available to others).
+ +You retain all ownership rights in your content. This license does not transfer ownership of your content to us.
+ +You agree that all content you contribute will:
+ +By contributing content, you represent and warrant that:
+We reserve the right to:
+We are not obligated to:
+Community organizers and moderators may also have the right to moderate content within their communities according to community-specific guidelines.
+ +You may remove or delete your content at any time through the platform interface. Upon deletion:
+We may remove your content immediately if:
+If your account is terminated, we may remove all of your content from the platform. We will make reasonable efforts to provide you with an export of your data before termination, where feasible.
+ +When we display your public content, we will generally attribute it to you using your profile name and information. You can control your profile information through your account settings.
+ +If you use others' content on the platform (such as sharing or quoting), you must:
+YOU PROVIDE YOUR CONTENT "AS IS" WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
+ +WE DO NOT ENDORSE, SUPPORT, REPRESENT, OR GUARANTEE THE COMPLETENESS, TRUTHFULNESS, ACCURACY, OR RELIABILITY OF ANY CONTENT OR COMMUNICATIONS POSTED VIA THE PLATFORM.
+ +You agree to indemnify, defend, and hold harmless <%= host_platform.name %>, Better Together Solutions, and our affiliates, officers, directors, employees, and agents from and against any claims, liabilities, damages, losses, and expenses, including reasonable legal fees and costs, arising out of or in any way connected with:
+We may update this agreement from time to time. Continued contribution of content after changes constitutes acceptance of the updated agreement. We will notify users of material changes.
+ +If any provision of this agreement is found to be unenforceable, the remaining provisions will continue in full force and effect.
+ +This agreement is governed by the laws of Canada, without regard to conflict of law principles.
+ +This agreement, together with our Terms of Service and Privacy Policy, constitutes the entire agreement regarding your content contributions.
+ +Please review our <%= link_to 'Privacy Policy', better_together.render_page_path('privacy', locale: I18n.locale) %> to understand how we collect, use, and protect data associated with your content contributions, including:
+If you have questions about this Content Contributor Agreement or need clarification about content licensing, please contact us:
+ + + <%= host_platform.name %>This agreement might seem complicated, but here's what it means in simple terms:
+This summary is for your convenience only and doesn't replace the full legal terms above. If there's a conflict, the full terms apply.
+Last updated: November 20, 2025
+This Cookie Policy explains how <%= host_platform.name %> uses cookies and similar technologies to recognize you when you visit our platform.
+ +Last Updated: November 20, 2025
+ + + +Cookies are small data files that are placed on your computer or mobile device when you visit a website. Cookies are widely used by website owners in order to make their websites work, or to work more efficiently, as well as to provide reporting information.
+ +Cookies set by the website owner (in this case, <%= host_platform.name %>) are called "first-party cookies." Cookies set by parties other than the website owner are called "third-party cookies." Third-party cookies enable third-party features or functionality to be provided on or through the website (e.g., analytics, error tracking). The parties that set these third-party cookies can recognize your computer both when it visits the website in question and also when it visits certain other websites.
+ +We use first-party and third-party cookies for several reasons. Some cookies are required for technical reasons in order for our platform to operate (we refer to these as "essential" or "strictly necessary" cookies). Other cookies enable us to track and target the interests of our users to enhance the experience on our platform, provide error tracking, and analyze platform usage. These are "optional" cookies.
+ +These are cookies that we set ourselves. We use these cookies to provide our core services and functionality.
+ +These are cookies set by external service providers. We only use third-party cookies when explicitly configured by platform organizers and with your consent where required by law.
+ +These cookies are strictly necessary to provide you with services available through our platform and to use some of its features. Because these cookies are strictly necessary to deliver the platform, you cannot refuse them without impacting how our platform functions. You can block or delete them by changing your browser settings as described below under "Your Choices Regarding Cookies."
+ +| Cookie Name | +Purpose | +Type | +Duration | +
|---|---|---|---|
_better_together_session |
+ Maintains your login session and stores temporary data like invitation tokens, locale preferences, and debug settings | +First-party | +Session (6 hours of inactivity) | +
remember_user_token |
+ Keeps you logged in when you select "Remember me" during sign-in | +First-party | +2 weeks | +
_csrf_token |
+ Security token that protects against Cross-Site Request Forgery (CSRF) attacks | +First-party | +Session | +
In addition to cookies, we store the following information in your browser's session storage:
+These cookies are only used when explicitly enabled by platform organizers and, where required by law, with your consent.
+ +The platform includes built-in metrics functionality that tracks usage patterns without identifying individual users. This system is privacy-focused and does not use cookies or track personal data.
+ +Platform organizers may choose to enable third-party analytics services. If enabled, you will be notified and asked for consent where required by law.
+ +When the platform organizer configures Sentry error tracking (via the SENTRY_CLIENT_KEY environment variable), error data may be collected to help diagnose and fix technical issues. This may include:
+Sentry may use cookies to track error sessions. For more information, see Sentry's Privacy Policy.
+ +Essential cookies are necessary for the platform to function properly. While you can configure your browser to block these cookies, doing so will prevent you from using many features of the platform, including signing in and maintaining your session.
+ +For optional cookies (analytics, error tracking), you have the following choices:
+You can manage your cookie preferences for this platform by:
+You can set or amend your web browser controls to accept or refuse cookies. If you choose to reject cookies, you may still use our platform though your access to some functionality and areas may be restricted. The method for disabling cookies varies by browser and device. Please consult your browser's help documentation for specific instructions:
+ +Most mobile devices allow you to control cookies through their settings. Please refer to your device manufacturer's instructions for specific guidance.
+ +This Cookie Policy is part of our compliance with Canada's Personal Information Protection and Electronic Documents Act (PIPEDA). Under PIPEDA:
+ +Your Rights: Under PIPEDA, you have the right to:
+Privacy Officer Contact: If you have questions about our cookie practices under Canadian privacy law, contact our Privacy Officer at privacy@bettertogethersolutions.com.
+ +Filing a Complaint: You may file complaints with the Office of the Privacy Commissioner of Canada:
+If you are located in the European Economic Area (EEA), you have certain data protection rights. We aim to take reasonable steps to allow you to correct, amend, delete, or limit the use of your personal data.
+ +You have the right to:
+To exercise any of these rights, please contact us using the information in the "Contact Us" section below.
+ +We may update this Cookie Policy from time to time in order to reflect changes to the cookies we use or for other operational, legal, or regulatory reasons. Please therefore re-visit this Cookie Policy regularly to stay informed about our use of cookies and related technologies.
+ +The date at the top of this Cookie Policy indicates when it was last updated.
+ +If you have questions or concerns about this Cookie Policy or our use of cookies, please contact us:
+ + + Better Together SolutionsHere's what you need to know about cookies on our platform:
+For more comprehensive information about how we handle your personal data, please review our <%= link_to 'Privacy Policy', better_together.render_page_path('privacy', locale: I18n.locale) %>.
+We are committed to transparency about how we collect and use data. This Cookie Policy is part of our broader commitment to your privacy.
+Better Together is the company home and primary developer of Community Engine, open source software for building and hosting Internet community platforms. As a company, Better Together hosts community platforms using Community Engine for customers, as well as:
+Better Together is the company home and primary developer of Community Engine, open source software for building and hosting Internet community platforms. As a company, Better Together hosts community platforms using Community Engine for customers, as well as:
when you post, send private messages, and otherwise participate in a community platform that Better Together hosts
when you visit our website at communityengine.ca
+when you visit our website at communityengine.app
when you sign up for mailing lists and announcements
@@ -73,7 +76,7 @@Better Together collects data when you use community platforms that Community Engine hosts, whether you use the community platforms using a web browser on your own computer, or use Better Together’s Community Engine apps for mobile devices.
+Better Together collects data when you use community platforms that Community Engine hosts, whether you use the community platforms using a web browser on your own computer, or use Better Together's Community Engine apps for mobile devices.
Better Together does not buy or otherwise receive data about you from data brokers.
@@ -86,21 +89,21 @@Better Together collects data about visits to community platforms and to its websites.
-When you visit one of Better Together’s websites or a community platform that Better Together hosts, whether you have an account or not, we use cookies, server logs, and other methods to collect data about what pages you visit and when.
+When you visit one of Better Together's websites or a community platform that Better Together hosts, whether you have an account or not, we use cookies, server logs, and other methods to collect data about what pages you visit and when.
Better Together uses data about how you use the website to:
optimize the community platform, so that it’s quick and easy to use
+optimize the community platform, so that it's quick and easy to use
diagnose and debug technical errors
defend the community platform and Better Together’s websites from abuse and technical attacks
+defend the community platform and Better Together's websites from abuse and technical attacks
compile statistics on community platform and topic popularity
@@ -110,16 +113,16 @@Better Together usually stores the data identified above for just a few weeks. In special circumstances, like extended investigations about technical attacks, Better Together may preserve log data longer, for analysis. Better Together stores aggregate statistics about use of the community platform for as long as Better Together hosts the community platform, but those statistics don’t include data identifiable to you personally.
+Better Together usually stores the data identified above for just a few weeks. In special circumstances, like extended investigations about technical attacks, Better Together may preserve log data longer, for analysis. Better Together stores aggregate statistics about use of the community platform for as long as Better Together hosts the community platform, but those statistics don't include data identifiable to you personally.
-Many features of community platforms that Better Together hosts require a community platform account. For example, most community platforms that Better Together hosts require an account to create and reply to posts.
To sign up for a community platform account, Community Engine requires your name, a user name, and an e-mail address.
-Better Together uses your account data to identify you on the community platform and to create pages specific to you, such as your profile page. If the community platform is public, Better Together publishes your account data according to the community platform administrator’s configuration. If the community platform is access-restricted, Better Together makes your account data available to everyone who can access the community platform, according to the community platform administrator’s configuration.
+Better Together uses your account data to identify you on the community platform and to create pages specific to you, such as your profile page. If the community platform is public, Better Together publishes your account data according to the community platform administrator's configuration. If the community platform is access-restricted, Better Together makes your account data available to everyone who can access the community platform, according to the community platform administrator's configuration.
Better Together uses your e-mail address to:
@@ -138,7 +141,7 @@ -You may provide additional data for your account, like a short biography, your location, or your birthday, on the profile settings page for your account. Better Together makes that data available to others who can access the community platform. You don’t have to provide this additional information, and you can erase it at any time.
+You may provide additional data for your account, like a short biography, your location, or your birthday, on the profile settings page for your account. Better Together makes that data available to others who can access the community platform. You don't have to provide this additional information, and you can erase it at any time.
Better Together stores your account data as long as your account remains open.
@@ -147,8 +150,8 @@When you purchase hosting from Better Together, we require certain information from you, including your email address and the information we require to process payments, such as your name and credit card information. We use this information to perform the contract between us, and store it as long as your customer account remains open.
- -Better Together collects the content of your posts, plus data about bookmarks, likes, and links you follow in order to share that data with others, through the community platform. If the community platform is public, Better Together publishes your activity. If the community platform is access-restricted, or access restrictions apply to the specific post, Better Together makes your activity available only to users permitted to see it.
@@ -156,6 +159,64 @@Better Together stores your posts and other activity as long as your account remains open.
+ +Community Engine uses PostGIS spatial database technology to store and process geographic information. When you provide location information (such as addresses or place names), Better Together may:
+ +Store latitude and longitude coordinates for addresses you provide
+Geocode addresses to obtain geographic coordinates using third-party geocoding services
+Store map viewport boundaries and center points for interactive maps you create
+Associate geographic locations with content you create (such as events, posts, or profiles)
+This geographic data may be used to:
+ +Display your content on interactive maps
+Enable proximity-based searches and recommendations
+Provide location context for community activities and events
+Geographic data is stored according to the privacy settings of your content. Public content may include publicly visible location data. You can control location data visibility through your account settings and content privacy controls.
+ + +Better Together uses self-hosted Elasticsearch to index and search content on community platforms. Search data is stored on the same servers as your other data and is not shared with third parties. This indexing includes:
+ +Post content and metadata
+User profile information (according to privacy settings)
+Community and event information
+Other searchable content types
+Search indexes respect the same privacy controls as the original content. Private or restricted content is indexed only for authorized users.
+Contributors to Better Together’s open source software may be asked to provide identifying and contact information such as your name, email address, telephone number, and mailing address. Better Together also collects and stores information concerning your agreement to our contributor license agreement.
+Contributors to Better Together's open source software may be asked to provide identifying and contact information such as your name, email address, telephone number, and mailing address. Better Together also collects and stores information concerning your agreement to our contributor license agreement.
-Better Together uses this information to maintain the integrity of our software and software licenses, as well as the integrity of the license agreement between Better Together and our contributors. Better Together stores contributor information for as long as related contributions are incorporated into Better Together’s open source software.
+Better Together uses this information to maintain the integrity of our software and software licenses, as well as the integrity of the license agreement between Better Together and our contributors. Better Together stores contributor information for as long as related contributions are incorporated into Better Together's open source software.
- -HTTP cookies are small bits of data that websites, like Community Engine community platforms, send to your computer when you visit. When you return to those websites, your computer sends the cookies on your computer back to the website.
+When configured by platform administrators, Better Together uses Sentry for error tracking and monitoring. This service may collect:
-CommunityEngine.ca uses these cookies:
+Error messages and stack traces
+Browser and device information
+Session replay data (if enabled)
+Performance metrics
+Better Together configures Sentry to exclude personally identifiable information where possible. For more information about Sentry's data practices, see Sentry's privacy policy.
-| Name | -Essential | -Expires | -Purpose | -
|---|---|---|---|
| - | - | - | - |
HTTP cookies are small bits of data that websites, like Community Engine community platforms, send to your computer when you visit. When you return to those websites, your computer sends the cookies on your computer back to the website.
-In addition, look at the privacy notice for your specific community platform to find out which cookies that community platform uses. By default, all Community Engine community platforms use these cookies:
+Community Engine community platforms use the following essential cookies that are required for the platform to function properly:
+| _better_together_session | Yes | -Session | -remembers your e-mail as you create an account | +Session or 6 hours of inactivity | +Stores session data including language preference, invitation tokens, and authentication state | ||
| destination_url | -Yes | -Session | -helps redirect you to your requested page after logging in | +remember_user_token | +No | +2 weeks | +Keeps you logged in across browser sessions when "Remember Me" is checked during login |
| _t | -Yes | -1440 Hours | -remembers who you are when you log in | -||||
| _community_platform_session | +_csrf_token | Yes | Session | -associates an ID, and other security-related information, with your browsing session | +Protects against cross-site request forgery attacks |
Community Engine community platforms that configure Google Analytics, such as communityengine.app, also use cookies from Google. Refer to Google’s page on Google Analytics cookies for the latest details.
+When enabled by platform administrators, the following third-party services may set additional cookies:
+ +Community Engine community platforms that serve advertisements may also set cookies used to track you and serve advertisements.
+Better Together does not use advertising cookies or sell data to third parties.
-Your web browser can show you the cookies you have for any website and help you manage them.
+Your web browser can show you the cookies you have for any website and help you manage them.
Better Together may use personal information about our customers and prospective customers in order to directly market our own services and inform you about new products and features that we offer. We also use the information you give to sign up for our mailing lists and announcements to send those messages.
+Better Together may use personal information about our customers and prospective customers in order to directly market our own services and inform you about new products and features that we offer. We also use the information you give to sign up for our mailing lists and announcements to send those messages.
You can always opt out of marketing communications from us, and you have the right to object to any processing of your information for marketing purposes.
@@ -264,19 +323,58 @@You can make choices about how data about you is used on the settings page for your account. When a community platform uses access restrictions that vary by category, you can choose who will see your post by choosing the appropriate category.
-Most web browsers let you make choices about whether to accept cookies, for specific websites or more generally. aboutcookies.org has instructions for many different web browsers. youronlinechoices.eu and aboutads.info have more information specifically about cookies used for advertising.
+Most web browsers let you make choices about whether to accept cookies, for specific websites or more generally. aboutcookies.org has instructions for many different web browsers. youronlinechoices.eu and aboutads.info have more information specifically about cookies used for advertising.
Better Together does not respond to the Do Not Track HTTP header.
Most community platforms that Better Together hosts store data in Better Together’s data centers and Amazon Web Services S3 in the Canada. Some community platforms that Better Together hosts store data in data centers in other jurisdictions, such as United States and the European Union. Refer to the Privacy Policy of the community platform on which your account exists for detailed information.
+Better Together stores data in the following locations:
+ +Application, Database, and Search Index: Self-hosted servers operated by Better Together on-premises in Newfoundland and Labrador, Canada (includes PostgreSQL database and Elasticsearch search index)
+File Storage: Amazon Web Services S3 in Canada (ca-central-1 region)
+Content Delivery: Amazon CloudFront CDN in United States (us-east-1 region) for SSL/TLS certificates and content delivery
+Specific community platforms may have different storage configurations. Refer to the privacy policy of the community platform on which your account exists for detailed information about that platform's data storage.
+ + +Better Together retains different types of data for different periods:
+ +Account Data: Retained as long as your account remains open
+Posts and Activity: Retained as long as your account remains open
+Server Logs: Typically retained for a few weeks; may be retained longer for security investigations
+Database Backups: Encrypted daily backups retained for 30 days
+Session Data: Expired automatically (session cookies expire on browser close; invitation tokens expire after 30 minutes to 24 hours depending on type)
+After you close your account, your data is either deleted or anonymized according to the platform's configuration.
Better Together respects privacy rights under Regulation (EU) 2016/679, the European Union’s General Data Protection Regulation (GDPR). Information that GDPR requires Better Together to give can be found throughout this privacy notice, including information on the rights of data subjects.
+Better Together respects privacy rights under Regulation (EU) 2016/679, the European Union's General Data Protection Regulation (GDPR). Information that GDPR requires Better Together to give can be found throughout this privacy notice, including information on the rights of data subjects.
the right to restrict the processing of your personal data
the right to object to certain processing of your information, including automated decision-making and direct marketing
+the right to object to certain processing of your information, including direct marketing
the right to lodge a complaint with a supervisory authority
@@ -312,111 +410,253 @@Better Together relies on the European Commission’s standard contractual clauses for international transfers(SCCs) to legally transfer personal data out of the European Economic Area. Because national security and surveillance laws may be in conflict with European data protection rules, Better Together continually reassesses the practical reach of these laws to ensure our data transfers are adequately safeguarded.
+Better Together relies on the European Commission's standard contractual clauses for international transfers (SCCs) to legally transfer personal data out of the European Economic Area. Because national security and surveillance laws may be in conflict with European data protection rules, Better Together continually reassesses the practical reach of these laws to ensure our data transfers are adequately safeguarded.
Currently:
Better Together processes personal data on servers located outside the European Union.
+Better Together processes personal data on servers located in Canada and uses services in the United States.
Better Together uses subprocessors with personnel and computers outside the European Union.
Better Together has personnel in the United States, Australia, and other non-EU countries without EU adequacy decisions under GDPR. These people need access to community platform personal data in order to keep community platforms running, address security concerns, respond to privacy-related requests from users, field technical support requests, and otherwise assist customers.
+Better Together operates primarily in Canada with personnel who may need access to community platform personal data in order to keep community platforms running, address security concerns, respond to privacy-related requests from users, field technical support requests, and otherwise assist customers.
Better Together no longer participates in Privacy Shield, following its invalidation as an adequate safeguard for EU-US data transfers.
Better Together’s standard data processing addendum incorporates the standard contractual clauses.
+Better Together's standard data processing addendum incorporates the standard contractual clauses.
Better Together has never received any order or request for personal data under FISA 702 or any similar national security or surveillance law of any other country. Better Together is not subject to any court order or legal obligation that would prevent it from disclosing the existence or non-existence of such an order or request.
Better Together has adopted a policy for how we will respond to those orders and requests, in case we ever receive one. Better Together will suspend processing, notify any customer for community platforms we host for others, minimize disclosure, and resist disclosure of personal data, all as the law allows.
+Better Together has adopted a policy for how we will respond to those orders and requests, in case we ever receive one. Better Together will suspend processing, notify any customer for community platforms we host for others, minimize disclosure, and resist disclosure of personal data, all as the law allows.
Better Together complies with its obligations under the California Consumer Privacy Act (CCPA). Better Together does not sell your personal information within the meaning of that law. Information on CCPA user rights — such as accessing or deleting your personal information — can be found throughout this privacy notice. So can information about specific CCPA consumer rights, like requesting disclosure about information Better Together collects and requesting deletion of your personal information.
+Better Together complies with its obligations under the California Consumer Privacy Act (CCPA). Better Together does not sell your personal information within the meaning of that law. Information on CCPA user rights — such as accessing or deleting your personal information — can be found throughout this privacy notice. So can information about specific CCPA consumer rights, like requesting disclosure about information Better Together collects and requesting deletion of your personal information.
-Better Together is not presently a “business” for the purposes of the CCPA, but we may act as a service provider for CCPA businesses when we host community platforms on behalf of customers. We offer a standard Service Provider Agreement for CCPA business customers.
+Better Together is not presently a "business" for the purposes of the CCPA, but we may act as a service provider for CCPA businesses when we host community platforms on behalf of customers. We offer a standard Service Provider Agreement for CCPA business customers.
- -You can see your account data at any time by visiting your account page on the community platform. Your account page also lists your posts and other activity on the community platform.
+Better Together complies with Canada's Personal Information Protection and Electronic Documents Act (PIPEDA), which governs how private sector organizations collect, use, and disclose personal information in the course of commercial activities.
- +If you do not have account with us but have a data access request, please contact us.
+PIPEDA applies to Better Together's operations because:
- -We are a private sector organization conducting commercial activities in Canada
+We operate in Newfoundland and Labrador, where PIPEDA is the applicable privacy law for private sector commercial activities
+We handle personal information that crosses provincial and national borders
+You can change your account data at any time by visiting the profile settings page for your account. The settings for a particular community platform may also allow you to close your account, on the settings page for your account. Closing your account starts a process of erasing or anonymizing Better Together’s records of data you provided for your account. Community Platform administrators can also erase and anonymize accounts.
+Depending on the settings for your particular community platform, you may also be able to edit, anonymize, or erase your posts. When you edit posts, Better Together will keep all versions of your posts. Community Platform administrators can view old versions of posts, and optionally make them visible to other community platform visitors.
+Better Together follows PIPEDA's ten fair information principles:
+ +Accountability: Better Together is responsible for personal information under our control. We have designated a Privacy Officer who is accountable for our compliance with PIPEDA.
+Identifying Purposes: We identify the purposes for which we collect personal information at or before the time of collection. These purposes are described throughout this privacy notice.
+Consent: We obtain your meaningful consent for the collection, use, or disclosure of your personal information, except where inappropriate (such as for legal or security reasons).
+Limiting Collection: We collect only the personal information that is necessary for the purposes we have identified.
+Limiting Use, Disclosure, and Retention: We use or disclose personal information only for the purposes for which it was collected, except with your consent or as required by law. We retain personal information only as long as necessary.
+Accuracy: We strive to keep personal information as accurate, complete, and up-to-date as necessary. You can update your information at any time.
+Safeguards: We protect personal information with security safeguards appropriate to the sensitivity of the information, including encryption, access controls, and secure storage.
+Openness: This privacy notice makes information about our privacy policies and practices readily available to you.
+Individual Access: Upon request, we inform you of the existence, use, and disclosure of your personal information, and provide access to that information. You may challenge the accuracy and completeness of your information and have it amended as appropriate.
+Challenging Compliance: You may contact our Privacy Officer with questions or complaints about our compliance with PIPEDA.
+Under PIPEDA, you have the right to:
+ +Know why we collect your information: We explain our purposes throughout this privacy notice
+Expect reasonable protection: We use appropriate security safeguards
+Access your personal information: See where can I access data about me
+Challenge the accuracy of your information: See how can I change or erase data about me
+Withdraw consent: You can withdraw consent at any time, subject to legal or contractual restrictions
+File a complaint: You can file a complaint about our privacy practices with our Privacy Officer or with the Office of the Privacy Commissioner of Canada
+Better Together obtains your consent in various ways depending on the sensitivity of the information and your relationship with us:
-Better Together uses data about your posts and other activity on many community platforms to make automated decisions about whether your posts to communityengine.app and most community platforms that Better Together hosts are spam. When Akismet decides that a post is likely spam, the community platform refuses to accept the post.
+Express consent: For sensitive personal information, we obtain your explicit, opt-in consent
+Implied consent: For less sensitive information, we may rely on implied consent when the purpose would be considered obvious and you voluntarily provide the information
+Withdrawal of consent: You may withdraw consent at any time by contacting us or through your account settings, subject to legal or contractual restrictions and reasonable notice
+If you think a post has been wrongly blocked or removed, contact an administrator of your community platform. They can override the decision that a post was spam.
+In accordance with PIPEDA's breach reporting requirements, Better Together will:
-Depending on how administrators of your community platform configure the community platform, the community platform may use data about your posts and activity to award you badges and calculate a trust level for your account. Your trust level may affect how you can participate in the community platform, such as whether you can upload images, as well as give you access to moderation and management powers in the community platform. Your trust level therefore reflects community platform administrators’ confidence in you, and their willingness to delegate community management functions, like moderation.
+Report any breach of security safeguards to the Privacy Commissioner of Canada if it is reasonable to believe that the breach creates a real risk of significant harm to individuals
+Notify affected individuals of any breach that creates a real risk of significant harm
+Maintain records of all privacy breaches
+As described in where does Better Together store data about me, some of your personal information may be stored or processed outside of Canada. When we transfer personal information outside Canada, we ensure that it receives a comparable level of protection through contractual or other means.
+ +If you have concerns about our privacy practices, please first contact our Privacy Officer. If you are not satisfied with our response, you may file a complaint with:
+ +Office of the Privacy Commissioner of Canada
+ 30 Victoria Street
+ Gatineau, Quebec K1A 1H3
+ Toll-free: 1-800-282-1376
+ Phone: (819) 994-5444
+ TTY: (819) 994-6591
+ Website: www.priv.gc.ca
You can see your account data at any time by visiting your account page on the community platform. Your account page also lists your posts and other activity on the community platform.
+ +If you do not have account with us but have a data access request, please contact us.
+ + +You can change your account data at any time by visiting the profile settings page for your account. The settings for a particular community platform may also allow you to close your account, on the settings page for your account. Closing your account starts a process of erasing or anonymizing Better Together's records of data you provided for your account. Community Platform administrators can also erase and anonymize accounts.
-If you think your trust level has been set incorrectly, contact an administrator of your community platform. They can manually adjust the trust level of your account.
+Depending on the settings for your particular community platform, you may also be able to edit, anonymize, or erase your posts. When you edit posts, Better Together will keep all versions of your posts. Community Platform administrators can view old versions of posts, and optionally make them visible to other community platform visitors.
Better Together shares account data with others as mentioned in the section about account data.
-Better Together shares data about your posts and other community platform activity with others as mentioned in the section about community platform data.
+Better Together shares data about your posts and other community platform activity with others as mentioned in the section about community platform data.
-Better Together uses the subprocessors listed on our subprocessors page when providing community platforms on behalf of our customers. We may also share personal data with the service providers we use in order to transact with customers, host our website, deliver content, secure our services, store data, host and manage our open source project, market our services, and provide customer support. These service providers include:
+Better Together uses subprocessors and service providers to operate community platforms and deliver services. We may share personal data with the following service providers:
These service providers are contractually required to protect your data and use it only for the purposes we specify.
+You can send questions, requests, and complaints to:
+You can send questions, requests, and complaints to our Privacy Officer:
-Better Together Solutions +
Better Together Solutions
+ Privacy Officer
privacy@bettertogethersolutions.com
European Users with questions or complaints about GDPR compliance should also contact the above email address.
+We aim to respond to privacy inquiries within 30 days. For complex requests, we may extend this timeline and will notify you of any extension.
+ +For Canadian residents: If you have concerns about our privacy practices under PIPEDA, you may contact our Privacy Officer using the information above. You also have the right to file a complaint with the Office of the Privacy Commissioner of Canada at www.priv.gc.ca or 1-800-282-1376.
-For complaints under GDPR, European Union users may lodge complaints with their local data protection supervisory authorities.
+For European Union residents: Questions or complaints about GDPR compliance should be sent to the email address above. You may also lodge complaints with your local data protection supervisory authority.
+ +For California residents: Questions about CCPA rights should be sent to the email address above.
This version of Better Together’s privacy questions and answers took effect November 30th, 2023.
+This version of Better Together's privacy questions and answers took effect November 20th, 2025.
Better Together will post the next version at https://communityengine.app/privacy. Better Together may change how it announces changes in future versions.
In the meantime, Better Together may update its contact information without announcing a change. Please refer to https://communityengine.app/privacy for the latest contact information at any time.
+Our privacy policy is detailed, but here are the key points:
+This summary is for your convenience only. For complete details, please read the full privacy policy above.
+You may notify the company under these terms, and send questions to the company, at community@bettertogethersolutions.com.
The company may notify you under these terms using the e-mail address you provide for your account on the community platform, or by posting a message to the homepage of the community platform or your account page.
The company last updated these terms on November 30th, 2023, and may update these terms again. The company will post all updates to the community platform. For updates that contain substantial changes, the company agrees to e-mail you, if you’ve created an account and provided a valid e-mail address. The company may also announce updates with special messages or alerts on the community platform.
+The company last updated these terms on November 30th, 2023, and may update these terms again. The company will post all updates to the community platform. For updates that contain substantial changes, the company agrees to e-mail you, if you've created an account and provided a valid e-mail address. The company may also announce updates with special messages or alerts on the community platform.
Once you get notice of an update to these terms, you must agree to the new terms in order to keep using the community platform.
+ +These legal terms are important, but here's what they mean in everyday language:
+This summary is for your convenience only and doesn't replace the full legal terms above. If there's a conflict, the full terms apply.
+#{Faker::Lorem.paragraph}
" } + end + + trait :with_link do + content { "#{Faker::Lorem.words(number: 2).join(' ')}" } + end + + trait :with_list do + content do + <<-HTML +#{Faker::Lorem.paragraph}
" } + end + + after(:build) do |block, evaluator| + block.content = evaluator.content_html if evaluator.content_html.present? + end + + trait :with_heading do + content_html { "#{Faker::Lorem.paragraph}
" } + end + + trait :with_link do + content_html { "Visit our website for more info.
" } + end + + trait :with_list do + content_html do + <<-HTML +Test
') + cached = block.cached_content + + expect(cached).to have_key(:id) + expect(cached).to have_key(:type) + expect(cached).to have_key(:content) + expect(cached).to have_key(:translations) + end + + it 'includes the block id and type' do + block = create(:better_together_content_html) + cached = block.cached_content + + expect(cached[:id]).to eq(block.id) + expect(cached[:type]).to eq('BetterTogether::Content::Html') + end + end + end + + describe 'Class Methods' do + # Class methods tested on both abstract base and concrete subclasses + describe '.block_name' do + it 'returns underscored class name for concrete types' do + expect(BetterTogether::Content::Html.block_name).to eq('html') + expect(BetterTogether::Content::Hero.block_name).to eq('hero') + expect(BetterTogether::Content::RichText.block_name).to eq('rich_text') + end + end + + describe '.content_addable?' do + it 'returns true for base Block class' do + expect(described_class.content_addable?).to be true + end + + it 'returns true for concrete subclasses' do + expect(BetterTogether::Content::Html.content_addable?).to be true + expect(BetterTogether::Content::Hero.content_addable?).to be true + end + end + + describe '.inherited' do + it 'includes BlockAttributes in subclasses' do + expect(BetterTogether::Content::Html.included_modules).to include(BetterTogether::Content::BlockAttributes) + expect(BetterTogether::Content::Hero.included_modules).to include(BetterTogether::Content::BlockAttributes) + end + end + + describe '.storext_keys' do + it 'returns an array of storext definition keys' do + keys = described_class.storext_keys + expect(keys).to be_an(Array) + end + + it 'includes keys from all block types' do + keys = described_class.storext_keys + # Keys are from all descendants, checking for presence of common ones + expect(keys).not_to be_empty + expect(keys).to be_an(Array) + end + end + + describe '.extra_permitted_attributes' do + it 'returns an array' do + expect(described_class.extra_permitted_attributes).to be_an(Array) + end + + it 'includes background_image_file' do + expect(described_class.extra_permitted_attributes).to include(:background_image_file) + end + + it 'includes attributes from descendants' do + attrs = described_class.extra_permitted_attributes + # Image adds :media to permitted attributes + expect(attrs).to include(:media) + # HTML adds :html_content + expect(attrs).to include(:html_content) + end + + it 'returns unique attributes' do + attrs = described_class.extra_permitted_attributes + expect(attrs.length).to eq(attrs.uniq.length) + end + end + + describe '.localized_block_attributes' do + it 'returns an array of localized attributes from all descendants' do + attrs = described_class.localized_block_attributes + expect(attrs).to be_an(Array) + end + + it 'includes localized attributes from subclasses' do + attrs = described_class.localized_block_attributes + # Hero has heading, content, cta_text + # Html has content + # RichText has content + # These should all be in the list + expect(attrs).not_to be_empty + end + end + end + + describe 'STI (Single Table Inheritance) Pattern' do + it 'Block is an abstract base class for content block types' do + # All blocks share the same table but have different types + html = create(:better_together_content_html) + hero = create(:better_together_content_hero) + css = create(:better_together_content_css) + + expect(html.type).to eq('BetterTogether::Content::Html') + expect(hero.type).to eq('BetterTogether::Content::Hero') + expect(css.type).to eq('BetterTogether::Content::Css') + + # All stored in same table + expect(html.class.table_name).to eq(hero.class.table_name) + expect(html.class.table_name).to eq(css.class.table_name) + end + + it 'queries on base Block class return all concrete types' do + html = create(:better_together_content_html) + hero = create(:better_together_content_hero) + css = create(:better_together_content_css) + + all_blocks = described_class.where(id: [html.id, hero.id, css.id]) + expect(all_blocks.count).to eq(3) + expect(all_blocks.map(&:class)).to contain_exactly( + BetterTogether::Content::Html, + BetterTogether::Content::Hero, + BetterTogether::Content::Css + ) + end + + it 'each concrete type is a descendant of Block' do + expect(BetterTogether::Content::Html.superclass).to eq(described_class) + expect(BetterTogether::Content::Hero.superclass).to eq(described_class) + expect(BetterTogether::Content::Css.superclass).to eq(described_class) + expect(BetterTogether::Content::RichText.superclass).to eq(described_class) + expect(BetterTogether::Content::Image.superclass).to eq(described_class) + end + end + end + end +end diff --git a/spec/models/better_together/content/css_spec.rb b/spec/models/better_together/content/css_spec.rb new file mode 100644 index 000000000..7e9375095 --- /dev/null +++ b/spec/models/better_together/content/css_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module BetterTogether + module Content + RSpec.describe Css do + describe 'Factory' do + it 'has a valid factory' do + css_block = build(:better_together_content_css) + expect(css_block).to be_valid + end + + it 'creates with custom CSS content' do + css_block = create(:better_together_content_css, content_text: '.custom { color: blue; }') + expect(css_block.content).to eq('.custom { color: blue; }') + end + end + + describe 'Associations' do + it { is_expected.to have_many(:page_blocks).dependent(:destroy) } + it { is_expected.to have_many(:pages).through(:page_blocks) } + end + + describe 'Translatable Attributes' do + it { is_expected.to respond_to(:content) } + + it 'supports translations for CSS content' do + css_block = create(:better_together_content_css) + + I18n.with_locale(:en) do + css_block.content = '.en-class { color: red; }' + css_block.save! + end + + I18n.with_locale(:fr) do + css_block.content = '.fr-class { couleur: bleu; }' + css_block.save! + end + + expect(css_block.content_en).to eq('.en-class { color: red; }') + expect(css_block.content_fr).to eq('.fr-class { couleur: bleu; }') + end + end + + describe 'Store Attributes' do + describe 'css_settings' do + it { is_expected.to respond_to(:css_settings) } + end + end + + describe 'Inheritance' do + it 'inherits from Block' do + expect(described_class.superclass).to eq(Block) + end + end + end + end +end diff --git a/spec/models/better_together/content/hero_spec.rb b/spec/models/better_together/content/hero_spec.rb new file mode 100644 index 000000000..b78151570 --- /dev/null +++ b/spec/models/better_together/content/hero_spec.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module BetterTogether + module Content # rubocop:todo Metrics/ModuleLength + RSpec.describe Hero do + describe 'Factory' do + it 'has a valid factory' do + hero = build(:content_hero) + expect(hero).to be_valid + end + + it 'supports custom title and subtitle' do + hero = create(:content_hero, title: 'Custom Heading', subtitle: 'Custom Content') + expect(hero.heading).to eq('Custom Heading') + expect(hero.content.to_plain_text).to include('Custom Content') + end + end + + describe 'Associations' do + it { is_expected.to have_many(:page_blocks).dependent(:destroy) } + it { is_expected.to have_many(:pages).through(:page_blocks) } + end + + describe 'Translatable Attributes' do + it { is_expected.to respond_to(:heading) } + it { is_expected.to respond_to(:cta_text) } + it { is_expected.to respond_to(:content) } + + it 'supports translations for heading' do + hero = create(:content_hero) + + I18n.with_locale(:en) do + hero.heading = 'English Heading' + hero.save! + end + + I18n.with_locale(:fr) do + hero.heading = 'Titre Français' + hero.save! + end + + expect(hero.heading_en).to eq('English Heading') + expect(hero.heading_fr).to eq('Titre Français') + end + end + + describe 'Store Attributes' do + describe 'content_data' do + it { is_expected.to respond_to(:cta_url) } + + it 'can set cta_url' do + hero = create(:content_hero, cta_url: 'https://example.com') + expect(hero.cta_url).to eq('https://example.com') + end + end + + describe 'css_settings' do + it { is_expected.to respond_to(:css_classes) } + it { is_expected.to respond_to(:container_class) } + it { is_expected.to respond_to(:overlay_color) } + it { is_expected.to respond_to(:overlay_opacity) } + it { is_expected.to respond_to(:heading_color) } + it { is_expected.to respond_to(:paragraph_color) } + it { is_expected.to respond_to(:cta_button_style) } + + it 'has default values' do + hero = build(:content_hero) + expect(hero.css_classes).to eq('text-white') + expect(hero.container_class).to eq('') + expect(hero.overlay_color).to eq('#000') + expect(hero.overlay_opacity).to eq(0.25) + expect(hero.heading_color).to eq('') + expect(hero.paragraph_color).to eq('') + expect(hero.cta_button_style).to eq('btn-primary') + end + end + end + + describe 'Validations' do + describe 'cta_button_style' do + it 'validates inclusion in AVAILABLE_BTN_CLASSES values' do + hero = build(:content_hero, cta_button_style: 'invalid-class') + expect(hero).not_to be_valid + expect(hero.errors[:cta_button_style]).to include('is not included in the list') + end + + it 'accepts valid button classes' do + BetterTogether::Content::Hero::AVAILABLE_BTN_CLASSES.each_value do |btn_class| + hero = build(:content_hero, cta_button_style: btn_class) + expect(hero).to be_valid + end + end + end + end + + describe 'Instance Methods' do + describe '#overlay_styles' do + it 'returns hash with background_color and opacity' do + hero = create(:content_hero, overlay_color: '#FF0000', overlay_opacity: 0.5) + styles = hero.overlay_styles + + expect(styles).to be_a(Hash) + expect(styles[:background_color]).to eq('#FF0000') + expect(styles[:opacity]).to eq(0.5) + end + end + + describe '#inline_overlay_styles' do + it 'returns CSS inline style string' do + hero = create(:content_hero, overlay_color: '#00FF00', overlay_opacity: 0.75) + inline_styles = hero.inline_overlay_styles + + expect(inline_styles).to be_a(String) + expect(inline_styles).to include('background-color') + expect(inline_styles).to include('#00FF00') + expect(inline_styles).to include('opacity') + expect(inline_styles).to include('0.75') + end + end + end + + describe 'Constants' do + describe 'AVAILABLE_BTN_CLASSES' do + it 'includes primary button variants' do + expect(BetterTogether::Content::Hero::AVAILABLE_BTN_CLASSES).to include( + primary: 'btn-primary', + primary_outline: 'btn-outline-primary' + ) + end + + it 'includes all Bootstrap button variants' do + expected_keys = %i[primary primary_outline secondary secondary_outline success success_outline + info info_outline warning warning_outline danger danger_outline + light light_outline dark dark_outline] + + expect(BetterTogether::Content::Hero::AVAILABLE_BTN_CLASSES.keys).to match_array(expected_keys) + end + end + end + + describe 'Factory Traits' do + it 'supports primary_button trait' do + hero = create(:content_hero, :primary_button) + expect(hero.cta_button_style).to eq('btn-primary') + end + + it 'supports secondary_button trait' do + hero = create(:content_hero, :secondary_button) + expect(hero.cta_button_style).to eq('btn-secondary') + end + + it 'supports dark_overlay trait' do + hero = create(:content_hero, :dark_overlay) + expect(hero.overlay_color).to eq('#000') + expect(hero.overlay_opacity).to eq(0.5) + end + + it 'supports light_overlay trait' do + hero = create(:content_hero, :light_overlay) + expect(hero.overlay_color).to eq('#fff') + expect(hero.overlay_opacity).to eq(0.3) + end + + it 'supports with_background_image trait' do + hero = create(:content_hero, :with_background_image) + expect(hero.background_image_file).to be_attached + end + end + end + end +end diff --git a/spec/models/better_together/content/html_spec.rb b/spec/models/better_together/content/html_spec.rb new file mode 100644 index 000000000..10a440841 --- /dev/null +++ b/spec/models/better_together/content/html_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module BetterTogether + module Content + RSpec.describe Html do + describe 'Factory' do + it 'has a valid factory' do + html_block = build(:better_together_content_html) + expect(html_block).to be_valid + end + + it 'creates with custom content' do + html_block = create(:better_together_content_html, content: 'Custom HTML content
') + expect(html_block.content).to eq('Custom HTML content
') + end + end + + describe 'Associations' do + it { is_expected.to have_many(:page_blocks).dependent(:destroy) } + it { is_expected.to have_many(:pages).through(:page_blocks) } + end + + describe 'Translatable Attributes' do + it { is_expected.to respond_to(:content) } + + it 'supports translations for content' do + html_block = create(:better_together_content_html) + + I18n.with_locale(:en) do + html_block.content = 'English HTML
' + html_block.save! + end + + I18n.with_locale(:fr) do + html_block.content = 'HTML Français
' + html_block.save! + end + + expect(html_block.content_en).to eq('English HTML
') + expect(html_block.content_fr).to eq('HTML Français
') + end + end + + describe 'Store Attributes' do + describe 'content_data' do + it { is_expected.to respond_to(:content_data) } + it { is_expected.to respond_to(:html_content) } + end + end + + describe 'Class Methods' do + describe '.extra_permitted_attributes' do + it 'returns an array' do + expect(described_class.extra_permitted_attributes).to be_an(Array) + end + + it 'includes html_content attribute' do + expect(described_class.extra_permitted_attributes).to include(:html_content) + end + end + end + + describe 'Inheritance' do + it 'inherits from Block' do + expect(described_class.superclass).to eq(Block) + end + end + end + end +end diff --git a/spec/models/better_together/content/image_spec.rb b/spec/models/better_together/content/image_spec.rb new file mode 100644 index 000000000..c66c6665f --- /dev/null +++ b/spec/models/better_together/content/image_spec.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module BetterTogether + module Content # rubocop:todo Metrics/ModuleLength + RSpec.describe Image do + describe 'Factory' do + it 'has a valid factory' do + image_block = build(:better_together_content_image) + expect(image_block).to be_valid + end + + it 'creates with attached media' do + image_block = create(:better_together_content_image) + expect(image_block.media).to be_attached + end + + it 'creates with caption and attribution' do + image_block = create(:better_together_content_image, + caption: 'Test caption', + attribution: 'Test photographer') + expect(image_block.caption).to eq('Test caption') + expect(image_block.attribution).to eq('Test photographer') + end + end + + describe 'Associations' do + it { is_expected.to have_many(:page_blocks).dependent(:destroy) } + it { is_expected.to have_many(:pages).through(:page_blocks) } + end + + describe 'Active Storage Attachment' do + it 'has one attached media file' do + image_block = create(:better_together_content_image) + expect(image_block.media).to be_attached + expect(image_block.media.filename.to_s).to eq('test-image.png') + end + + it 'delegates url to media' do + # Set ActiveStorage url_options for test environment + ActiveStorage::Current.url_options = { host: 'localhost', port: 3000, protocol: 'http' } + + image_block = create(:better_together_content_image) + expect(image_block).to respond_to(:url) + expect(image_block.url).to be_present + expect(image_block.url).to include('test-image.png') + end + end + + describe 'Validations' do + describe 'media presence' do + it 'requires media attachment' do + image_block = build(:better_together_content_image) + image_block.media.purge + expect(image_block).not_to be_valid + expect(image_block.errors[:media]).to include("can't be blank") + end + end + + describe 'attribution_url format' do + it 'accepts valid HTTP URLs' do + image_block = build(:better_together_content_image, attribution_url: 'http://example.com') + expect(image_block).to be_valid + end + + it 'accepts valid HTTPS URLs' do + image_block = build(:better_together_content_image, attribution_url: 'https://example.com') + expect(image_block).to be_valid + end + + it 'accepts URLs with paths' do + image_block = build(:better_together_content_image, + attribution_url: 'https://example.com/path/to/image') + expect(image_block).to be_valid + end + + it 'allows blank attribution_url' do + image_block = build(:better_together_content_image, attribution_url: '') + expect(image_block).to be_valid + end + + it 'rejects invalid URLs' do + image_block = build(:better_together_content_image, attribution_url: 'not a url') + expect(image_block).not_to be_valid + expect(image_block.errors[:attribution_url]).to be_present + end + + it 'rejects URLs without protocol' do + image_block = build(:better_together_content_image, attribution_url: 'example.com') + expect(image_block).not_to be_valid + end + end + + describe 'media content type' do + it 'accepts JPEG images' do + image_block = build(:better_together_content_image, :with_jpg) + expect(image_block).to be_valid + end + + it 'accepts GIF images' do + image_block = build(:better_together_content_image, :with_gif) + expect(image_block).to be_valid + end + + it 'accepts WebP images' do + image_block = build(:better_together_content_image, :with_webp) + expect(image_block).to be_valid + end + end + end + + describe 'Translatable Attributes' do + it { is_expected.to respond_to(:attribution) } + it { is_expected.to respond_to(:alt_text) } + it { is_expected.to respond_to(:caption) } + + it 'supports translations for attribution' do + image_block = create(:better_together_content_image) + + I18n.with_locale(:en) do + image_block.attribution = 'English Photographer' + image_block.save! + end + + I18n.with_locale(:fr) do + image_block.attribution = 'Photographe Français' + image_block.save! + end + + expect(image_block.attribution_en).to eq('English Photographer') + expect(image_block.attribution_fr).to eq('Photographe Français') + end + + it 'supports translations for alt_text' do + image_block = create(:better_together_content_image) + + I18n.with_locale(:en) do + image_block.alt_text = 'English description' + image_block.save! + end + + I18n.with_locale(:es) do + image_block.alt_text = 'Descripción en español' + image_block.save! + end + + expect(image_block.alt_text_en).to eq('English description') + expect(image_block.alt_text_es).to eq('Descripción en español') + end + + it 'supports translations for caption' do + image_block = create(:better_together_content_image) + + I18n.with_locale(:en) do + image_block.caption = 'English caption' + image_block.save! + end + + I18n.with_locale(:fr) do + image_block.caption = 'Légende française' + image_block.save! + end + + expect(image_block.caption_en).to eq('English caption') + expect(image_block.caption_fr).to eq('Légende française') + end + end + + describe 'Store Attributes' do + describe 'media_settings' do + it { is_expected.to respond_to(:media_settings) } + it { is_expected.to respond_to(:attribution_url) } + + it 'can store attribution_url' do + image_block = create(:better_together_content_image) + image_block.update(attribution_url: 'https://example.com/photo') + expect(image_block.attribution_url).to eq('https://example.com/photo') + end + + it 'has default empty string for attribution_url' do + image_block = create(:better_together_content_image, attribution_url: nil) + image_block.reload + # Store attributes default to empty string per model definition + expect(image_block.attribution_url).to eq('').or be_nil + end + end + end + + describe 'Class Methods' do + describe '.content_addable?' do + it 'returns true' do + expect(described_class.content_addable?).to be true + end + end + + describe '.extra_permitted_attributes' do + it 'includes media attribute' do + expect(described_class.extra_permitted_attributes).to include(:media) + end + end + end + + describe 'Constants' do + describe 'CONTENT_TYPES' do + it 'includes common image formats' do + expect(Image::CONTENT_TYPES).to include('image/jpeg') + expect(Image::CONTENT_TYPES).to include('image/png') + expect(Image::CONTENT_TYPES).to include('image/gif') + expect(Image::CONTENT_TYPES).to include('image/webp') + expect(Image::CONTENT_TYPES).to include('image/svg+xml') + end + end + end + + describe 'Inheritance' do + it 'inherits from Block' do + expect(described_class.superclass).to eq(Block) + end + end + end + end +end diff --git a/spec/models/better_together/content/link_spec.rb b/spec/models/better_together/content/link_spec.rb new file mode 100644 index 000000000..3741ff331 --- /dev/null +++ b/spec/models/better_together/content/link_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module BetterTogether + module Content + RSpec.describe Link do + describe 'Factory' do + it 'has a valid factory' do + link = build(:content_link) + expect(link).to be_valid + end + + it 'creates with default attributes' do + link = create(:content_link) + expect(link.link_type).to eq('website') + expect(link.valid_link).to be false + expect(link.scheme).to eq('https') + expect(link.host).to be_present + end + + it 'creates with custom URL' do + link = create(:content_link, url: 'https://custom.example.com/path') + expect(link.url).to eq('https://custom.example.com/path') + expect(link.host).to eq('custom.example.com') + end + end + + describe 'Associations' do + it { is_expected.to have_many(:rich_text_links).class_name('BetterTogether::Metrics::RichTextLink') } + it { is_expected.to have_many(:rich_texts).through(:rich_text_links) } + + # NOTE: rich_text_records association is polymorphic and requires source_type + # It's tested indirectly through the has_many :rich_text_links association + end + + describe 'Initialization defaults' do + it 'sets link_type to "website" when blank' do + link = described_class.new + expect(link.link_type).to eq('website') + end + + it 'does not override provided link_type' do + link = described_class.new(link_type: 'external') + expect(link.link_type).to eq('external') + end + + it 'sets valid_link to false when nil' do + link = described_class.new + expect(link.valid_link).to be false + end + + it 'does not override provided valid_link value' do + link = described_class.new(valid_link: true) + expect(link.valid_link).to be true + end + end + + describe 'Attributes' do + it 'stores URL' do + link = create(:content_link, url: 'https://example.com/page') + expect(link.url).to eq('https://example.com/page') + end + + it 'stores scheme' do + link = create(:content_link, scheme: 'http') + expect(link.scheme).to eq('http') + end + + it 'stores host' do + link = create(:content_link, host: 'example.org') + expect(link.host).to eq('example.org') + end + + it 'tracks external status' do + external_link = create(:content_link, external: true) + internal_link = create(:content_link, external: false) + + expect(external_link.external).to be true + expect(internal_link.external).to be false + end + + it 'tracks link validity' do + valid_link = create(:content_link, valid_link: true) + invalid_link = create(:content_link, valid_link: false) + + expect(valid_link.valid_link).to be true + expect(invalid_link.valid_link).to be false + end + end + + describe 'Link metadata tracking' do + it 'can distinguish between internal and external links' do + internal = create(:content_link, external: false, url: 'https://mysite.com/page') + external = create(:content_link, external: true, url: 'https://othersite.com/page') + + expect(internal.external).to be false + expect(external.external).to be true + end + + it 'supports different link types' do + website = create(:content_link, link_type: 'website') + email = create(:content_link, link_type: 'email', url: 'mailto:test@example.com') + tel = create(:content_link, link_type: 'tel', url: 'tel:+1234567890') + + expect(website.link_type).to eq('website') + expect(email.link_type).to eq('email') + expect(tel.link_type).to eq('tel') + end + end + end + end +end diff --git a/spec/models/better_together/content/markdown_localization_spec.rb b/spec/models/better_together/content/markdown_localization_spec.rb new file mode 100644 index 000000000..ecbd327f4 --- /dev/null +++ b/spec/models/better_together/content/markdown_localization_spec.rb @@ -0,0 +1,308 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe BetterTogether::Content::Markdown do + describe 'Mobility translations' do + subject(:markdown) { build(:content_markdown) } + + it 'includes Translatable concern' do + expect(described_class.ancestors).to include(BetterTogether::Translatable) + end + + it 'translates markdown_source attribute' do + expect(described_class.mobility_attributes).to include('markdown_source') + end + + it 'supports per-locale content' do + I18n.with_locale(:en) do + markdown.markdown_source = 'English content' + end + I18n.with_locale(:es) do + markdown.markdown_source = 'Contenido en español' + end + I18n.with_locale(:fr) do + markdown.markdown_source = 'Contenu en français' + end + + markdown.save! + + I18n.with_locale(:en) do + expect(markdown.markdown_source).to eq('English content') + end + I18n.with_locale(:es) do + expect(markdown.markdown_source).to eq('Contenido en español') + end + I18n.with_locale(:fr) do + expect(markdown.markdown_source).to eq('Contenu en français') + end + end + end + + describe '#content with localization' do + context 'when using translated markdown_source' do + let(:markdown) do + m = nil + I18n.with_locale(:en) do + m = create(:content_markdown, markdown_source: 'English content') + end + I18n.with_locale(:es) do + m.update!(markdown_source: 'Contenido en español') + end + m + end + + it 'returns content for current locale' do + I18n.with_locale(:en) do + expect(markdown.content).to eq('English content') + end + + I18n.with_locale(:es) do + expect(markdown.content).to eq('Contenido en español') + end + end + end + + context 'when using auto_sync_from_file with locale-specific files' do + let(:base_path) { Rails.root.join('spec/fixtures/files/localized_content.md') } + let(:markdown) do + create(:content_markdown, + markdown_source: 'Temp', + markdown_file_path: base_path.to_s, + auto_sync_from_file: true) + end + let(:en_file) { base_path.to_s.sub(/\.md$/i, '.en.md') } + let(:es_file) { base_path.to_s.sub(/\.md$/i, '.es.md') } + let(:fr_file) { base_path.to_s.sub(/\.md$/i, '.fr.md') } + + before do + FileUtils.mkdir_p(File.dirname(base_path)) + File.write(base_path, '# Base') + File.write(en_file, '# English Heading') + File.write(es_file, '# Título en Español') + File.write(fr_file, '# Titre en Français') + end + + after do + FileUtils.rm_f([base_path, en_file, es_file, fr_file]) + end + + it 'loads content for current locale from file' do + expect(markdown.content).to eq('# English Heading') + + I18n.with_locale(:es) do + expect(markdown.content).to eq('# Título en Español') + end + + I18n.with_locale(:fr) do + expect(markdown.content).to eq('# Titre en Français') + end + end + end + + context 'when using auto_sync_from_file with fallback' do + let(:base_path) { Rails.root.join('spec/fixtures/files/fallback_content') } + let(:default_file) { "#{base_path}.md" } + let(:en_file) { "#{base_path}.en.md" } + + let(:markdown) do + create(:content_markdown, + markdown_source: nil, + markdown_file_path: default_file, + auto_sync_from_file: true) + end + + before do + FileUtils.mkdir_p(File.dirname(default_file)) + File.write(en_file, '# English Content') + File.write(default_file, '# Default Content') + end + + after do + FileUtils.rm_f([default_file, en_file]) + end + + it 'falls back to default file when locale-specific not found' do + I18n.with_locale(:es) do + # No es file, should fall back to default + expect(markdown.content).to eq('# Default Content') + end + end + + it 'uses locale-specific file when available' do + expect(markdown.content).to eq('# English Content') + end + end + end + + describe '#import_file_content!' do + let(:base_path) { Rails.root.join('spec/fixtures/files/import_test') } + let(:base_file) { "#{base_path}.md" } + let(:en_file) { "#{base_path}.en.md" } + let(:es_file) { "#{base_path}.es.md" } + let(:fr_file) { "#{base_path}.fr.md" } + + let(:markdown) do + create(:content_markdown, + markdown_source: 'Temp', + markdown_file_path: base_file) + end + + before do + FileUtils.mkdir_p(File.dirname(base_file)) + File.write(base_file, '# Base') + File.write(en_file, '# English Import') + File.write(es_file, '# Importación Española') + File.write(fr_file, '# Importation Française') + end + + after do + FileUtils.rm_f([base_file, en_file, es_file, fr_file]) + end + + it 'imports content for all available locales' do + expect(markdown.import_file_content!).to be true + + expect(markdown.markdown_source).to eq('# English Import') + I18n.with_locale(:es) do + expect(markdown.markdown_source).to eq('# Importación Española') + end + I18n.with_locale(:fr) do + expect(markdown.markdown_source).to eq('# Importation Française') + end + end + + it 'sets auto_sync_from_file when requested' do + markdown.import_file_content!(sync_future_changes: true) + + expect(markdown.auto_sync_from_file).to be true + end + + it 'returns false when no file path' do + markdown.update!(markdown_file_path: nil) + + expect(markdown.import_file_content!).to be false + end + end + + describe '#as_indexed_json with localization' do + let(:markdown) do + m = nil + I18n.with_locale(:en) do + m = create(:content_markdown, markdown_source: '# English **content**') + end + I18n.with_locale(:es) do + m.update!(markdown_source: '# Contenido **español**') + end + I18n.with_locale(:fr) do + m.update!(markdown_source: '# Contenu **français**') + end + m + end + + it 'indexes content for all available locales' do + result = markdown.as_indexed_json + + expect(result[:localized_content][:en]).to include('English') + expect(result[:localized_content][:en]).to include('content') + expect(result[:localized_content][:en]).not_to include('**') + + expect(result[:localized_content][:es]).to include('Contenido') + expect(result[:localized_content][:es]).to include('español') + + expect(result[:localized_content][:fr]).to include('Contenu') + expect(result[:localized_content][:fr]).to include('français') + end + + it 'strips HTML from indexed content' do + result = markdown.as_indexed_json + + I18n.available_locales.each do |locale| + expect(result[:localized_content][locale]).not_to match(/<[^>]+>/) + end + end + end + + describe '#rendered_html with localization' do + let(:markdown) do + m = nil + I18n.with_locale(:en) do + m = create(:content_markdown, markdown_source: '# English') + end + I18n.with_locale(:es) do + m.update!(markdown_source: '# Español') + end + m + end + + it 'renders content for current locale' do + I18n.with_locale(:en) do + html = markdown.rendered_html + expect(html).to include('English') + end + + I18n.with_locale(:es) do + html = markdown.rendered_html + expect(html).to include('Español') + end + end + end + + describe '.permitted_attributes' do + it 'includes auto_sync_from_file' do + expect(described_class.permitted_attributes).to include(:auto_sync_from_file) + end + + it 'includes markdown_source and markdown_file_path' do + expect(described_class.permitted_attributes).to include(:markdown_source, :markdown_file_path) + end + end + + describe 'auto_sync_from_file behavior' do + context 'when auto_sync is disabled' do + let(:file_path) { Rails.root.join('spec/fixtures/files/no_sync.md') } + let(:markdown) do + create(:content_markdown, + markdown_source: 'Database content', + markdown_file_path: file_path.to_s, + auto_sync_from_file: false) + end + + before do + FileUtils.mkdir_p(File.dirname(file_path)) + File.write(file_path, '# File content') + end + + after do + FileUtils.rm_f(file_path) + end + + it 'uses database content instead of file' do + expect(markdown.content).to eq('Database content') + end + end + + context 'when auto_sync is enabled' do + let(:file_path) { Rails.root.join('spec/fixtures/files/with_sync.md') } + let(:markdown) do + create(:content_markdown, + markdown_source: 'Database content', + markdown_file_path: file_path.to_s, + auto_sync_from_file: true) + end + + before do + FileUtils.mkdir_p(File.dirname(file_path)) + File.write(file_path, '# File content') + end + + after do + FileUtils.rm_f(file_path) + end + + it 'loads content from file, ignoring database' do + expect(markdown.content).to eq('# File content') + end + end + end +end diff --git a/spec/models/better_together/content/markdown_spec.rb b/spec/models/better_together/content/markdown_spec.rb new file mode 100644 index 000000000..00282f9d4 --- /dev/null +++ b/spec/models/better_together/content/markdown_spec.rb @@ -0,0 +1,385 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe BetterTogether::Content::Markdown do + describe 'associations' do + it { is_expected.to have_many(:page_blocks).dependent(:destroy) } + it { is_expected.to have_many(:pages).through(:page_blocks) } + end + + describe 'validations' do + context 'when markdown_source is provided' do + subject { described_class.new(markdown_source: '# Hello World') } + + it { is_expected.to be_valid } + it { is_expected.not_to validate_presence_of(:markdown_file_path) } + end + + context 'when markdown_file_path is provided' do + subject { described_class.new(markdown_file_path: file_path.to_s) } + + let(:file_path) { Rails.root.join('spec/fixtures/files/test_markdown.md') } + + before do + FileUtils.mkdir_p(File.dirname(file_path)) + File.write(file_path, '# Test Content') + end + + after do + FileUtils.rm_f(file_path) + end + + it { is_expected.to be_valid } + end + + context 'when neither source nor file_path is provided' do + subject(:markdown_block) { described_class.new } + + it 'is invalid' do + expect(markdown_block).not_to be_valid + expect(markdown_block.errors[:base]).to include('Either markdown source or file path must be provided') + end + end + + context 'when file_path does not exist' do + subject(:markdown_block) { described_class.new(markdown_file_path: '/nonexistent/file.md') } + + it 'is invalid' do + expect(markdown_block).not_to be_valid + expect(markdown_block.errors[:markdown_file_path]).to include('file does not exist') + end + end + + context 'when file_path has wrong extension' do + subject(:markdown_block) { described_class.new(markdown_file_path: file_path.to_s) } + + let(:file_path) { Rails.root.join('spec/fixtures/files/test_file.txt') } + + before do + FileUtils.mkdir_p(File.dirname(file_path)) + File.write(file_path, 'Some content') + end + + after do + FileUtils.rm_f(file_path) + end + + it 'is invalid' do + expect(markdown_block).not_to be_valid + expect(markdown_block.errors[:markdown_file_path]).to include('must be a markdown file (.md or .markdown)') + end + end + + context 'when file has .markdown extension' do + subject { described_class.new(markdown_file_path: file_path.to_s) } + + let(:file_path) { Rails.root.join('spec/fixtures/files/test.markdown') } + + before do + FileUtils.mkdir_p(File.dirname(file_path)) + File.write(file_path, '# Test') + end + + after do + FileUtils.rm_f(file_path) + end + + it { is_expected.to be_valid } + end + end + + describe '#content' do + context 'when using markdown_source' do + let(:markdown) { described_class.new(markdown_source: '# Hello **World**') } + + it 'returns the markdown_source' do + expect(markdown.content).to eq('# Hello **World**') + end + end + + context 'when using markdown_file_path' do + let(:file_path) { Rails.root.join('spec/fixtures/files/content_test.md') } + let(:markdown) { described_class.new(markdown_file_path: file_path.to_s) } + let(:file_content) { "# File Content\n\nThis is from a file." } + + before do + FileUtils.mkdir_p(File.dirname(file_path)) + File.write(file_path, file_content) + end + + after do + FileUtils.rm_f(file_path) + end + + it 'returns the file content' do + expect(markdown.content).to eq(file_content) + end + end + + context 'when using relative file path' do + let(:file_path) { 'spec/fixtures/files/relative_test.md' } + let(:markdown) { described_class.new(markdown_file_path: file_path) } + let(:full_path) { Rails.root.join(file_path) } + let(:file_content) { '# Relative Path Test' } + + before do + FileUtils.mkdir_p(File.dirname(full_path)) + File.write(full_path, file_content) + end + + after do + FileUtils.rm_f(full_path) + end + + it 'resolves the relative path' do + expect(markdown.content).to eq(file_content) + end + end + + context 'when file does not exist' do + let(:markdown) { described_class.new(markdown_file_path: '/tmp/nonexistent.md') } + + it 'returns empty string' do + # Skip validation + markdown.save(validate: false) + expect(markdown.content).to eq('') + end + end + + context 'when both source and file_path are provided' do + let(:file_path) { Rails.root.join('spec/fixtures/files/both_test.md') } + let(:markdown) do + described_class.new( + markdown_source: '# Source Content', + markdown_file_path: file_path.to_s + ) + end + + before do + FileUtils.mkdir_p(File.dirname(file_path)) + File.write(file_path, '# File Content') + end + + after do + FileUtils.rm_f(file_path) + end + + it 'prefers markdown_source' do + expect(markdown.content).to eq('# Source Content') + end + end + end + + describe '#rendered_html' do + let(:markdown) { described_class.new(markdown_source: markdown_source) } + + context 'with basic markdown' do + let(:markdown_source) { '# Hello World' } + + it 'renders markdown to HTML' do + html = markdown.rendered_html + expect(html).to include('')
+ expect(html).to include('')
+ expect(html).to include('')
+ end
+ end
+
+ context 'with strikethrough' do
+ let(:markdown_source) { '~~strikethrough~~' }
+
+ it 'renders strikethrough' do
+ html = markdown.rendered_html
+ expect(html).to include('strikethrough')
+ end
+ end
+ end
+
+ describe '#rendered_plain_text' do
+ let(:markdown) { described_class.new(markdown_source: markdown_source) }
+
+ context 'with basic markdown' do
+ let(:markdown_source) { '# Hello **World**' }
+
+ it 'returns plain text without HTML tags' do
+ plain = markdown.rendered_plain_text
+ expect(plain).not_to match(/<[^>]+>/)
+ expect(plain).to include('Hello')
+ expect(plain).to include('World')
+ end
+ end
+
+ context 'with complex markdown' do
+ let(:markdown_source) do
+ <<~MD
+ # Title
+
+ This is **bold** and *italic*.
+
+ - Item 1
+ - Item 2
+ MD
+ end
+
+ it 'extracts all text content' do
+ plain = markdown.rendered_plain_text
+ expect(plain).to include('Title')
+ expect(plain).to include('bold')
+ expect(plain).to include('italic')
+ expect(plain).to include('Item 1')
+ expect(plain).to include('Item 2')
+ end
+ end
+ end
+
+ describe '#as_indexed_json' do
+ let(:markdown_source) { '# Searchable Content' }
+ let(:markdown) { described_class.create!(markdown_source: markdown_source) }
+
+ it 'returns a hash with id and localized_content' do
+ result = markdown.as_indexed_json
+
+ expect(result).to be_a(Hash)
+ expect(result.keys).to contain_exactly(:id, :localized_content)
+ end
+
+ it 'includes the markdown id' do
+ result = markdown.as_indexed_json
+ expect(result[:id]).to eq(markdown.id)
+ end
+
+ it 'includes localized content' do
+ result = markdown.as_indexed_json
+
+ expect(result[:localized_content]).to be_a(Hash)
+ expect(result[:localized_content].keys).to match_array(I18n.available_locales)
+ end
+
+ it 'includes plain text content for each locale' do
+ result = markdown.as_indexed_json
+
+ I18n.available_locales.each do |locale|
+ expect(result[:localized_content][locale]).to be_a(String)
+ expect(result[:localized_content][locale]).to include('Searchable Content')
+ end
+ end
+ end
+
+ describe 'integration with Page model' do
+ let(:page) do
+ BetterTogether::Page.create!(
+ title: 'Markdown Test Page',
+ slug: 'markdown-test',
+ privacy: 'public',
+ page_blocks_attributes: [
+ {
+ block_attributes: {
+ type: 'BetterTogether::Content::Markdown',
+ markdown_source: '# Test Markdown Content'
+ }
+ }
+ ]
+ )
+ end
+
+ it 'can be associated with pages through page_blocks' do
+ expect(page.content_blocks.count).to eq(1)
+ expect(page.content_blocks.first).to be_a(described_class)
+ end
+
+ it 'is included in page indexed data' do
+ indexed_data = page.as_indexed_json
+ expect(indexed_data).to be_present
+ end
+ end
+
+ describe 'store_attributes' do
+ it 'stores markdown_source via Mobility (not in content_data)' do
+ markdown = described_class.new(markdown_source: '# Test')
+ # markdown_source is now stored via Mobility translations, not storext
+ expect(markdown.markdown_source).to eq('# Test')
+ expect(markdown.content_data).not_to include('markdown_source')
+ end
+
+ it 'stores markdown_file_path in content_data' do
+ markdown = described_class.new(markdown_file_path: '/path/to/file.md')
+ expect(markdown.content_data).to include('markdown_file_path')
+ end
+
+ it 'stores auto_sync_from_file in content_data' do
+ markdown = described_class.new(auto_sync_from_file: true)
+ expect(markdown.content_data).to include('auto_sync_from_file')
+ expect(markdown.auto_sync_from_file).to be true
+ end
+ end
+
+ describe 'caching' do
+ let(:markdown) { described_class.create!(markdown_source: '# Cached Content') }
+
+ it 'has a cache_key_with_version' do
+ expect(markdown.cache_key_with_version).to be_present
+ end
+
+ it 'cache key changes when content updates' do
+ original_key = markdown.cache_key_with_version
+ markdown.update!(markdown_source: '# Updated Content')
+ expect(markdown.cache_key_with_version).not_to eq(original_key)
+ end
+ end
+end
diff --git a/spec/models/better_together/content/platform_block_spec.rb b/spec/models/better_together/content/platform_block_spec.rb
new file mode 100644
index 000000000..85c473406
--- /dev/null
+++ b/spec/models/better_together/content/platform_block_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+module BetterTogether
+ module Content
+ RSpec.describe PlatformBlock do
+ describe 'Associations' do
+ it { is_expected.to belong_to(:platform).class_name('BetterTogether::Platform').touch(true) }
+ it { is_expected.to belong_to(:block).class_name('BetterTogether::Content::Block').autosave(true) }
+ end
+
+ describe 'Nested Attributes' do
+ it 'accepts nested attributes for block' do
+ platform = create(:better_together_platform)
+
+ platform_block = described_class.new(
+ platform: platform,
+ block_attributes: {
+ type: 'BetterTogether::Content::Html',
+ identifier: 'test-block',
+ privacy: 'public'
+ }
+ )
+
+ # Should accept the nested attributes without error
+ expect { platform_block.save }.not_to raise_error
+ end
+ end
+
+ describe 'Integration' do
+ it 'creates with platform and block' do
+ platform = create(:better_together_platform)
+ block = create(:better_together_content_html, content: 'Test content
')
+
+ platform_block = described_class.create!(platform: platform, block: block)
+
+ expect(platform_block).to be_persisted
+ expect(platform_block.platform).to eq(platform)
+ expect(platform_block.block).to eq(block)
+ end
+
+ it 'touches platform when updated' do
+ platform = create(:better_together_platform)
+ block = create(:better_together_content_html)
+ platform_block = described_class.create!(platform: platform, block: block)
+
+ original_updated_at = platform.reload.updated_at
+ sleep 0.01 # Ensure time difference
+ platform_block.touch
+
+ expect(platform.reload.updated_at).to be > original_updated_at
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/better_together/content/rich_text_spec.rb b/spec/models/better_together/content/rich_text_spec.rb
new file mode 100644
index 000000000..65d3e1ae0
--- /dev/null
+++ b/spec/models/better_together/content/rich_text_spec.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+module BetterTogether
+ module Content # rubocop:todo Metrics/ModuleLength
+ RSpec.describe RichText do
+ describe 'Factory' do
+ it 'has a valid factory' do
+ rich_text_block = build(:better_together_content_rich_text)
+ expect(rich_text_block).to be_valid
+ end
+
+ it 'creates with custom content' do
+ rich_text_block = create(:better_together_content_rich_text, content_html: 'Custom Heading
')
+ expect(rich_text_block.content.to_s).to include('Custom Heading')
+ end
+ end
+
+ describe 'Associations' do
+ it { is_expected.to have_many(:page_blocks).dependent(:destroy) }
+ it { is_expected.to have_many(:pages).through(:page_blocks) }
+ end
+
+ describe 'Inheritance' do
+ it 'inherits from Block' do
+ expect(described_class.superclass).to eq(Block)
+ end
+ end
+
+ describe 'Translatable Attributes' do
+ it { is_expected.to respond_to(:content) }
+
+ it 'supports translations for content via Action Text' do
+ rich_text_block = create(:better_together_content_rich_text)
+
+ I18n.with_locale(:en) do
+ rich_text_block.content = 'English content
'
+ rich_text_block.save!
+ end
+
+ I18n.with_locale(:fr) do
+ rich_text_block.content = 'Contenu français
'
+ rich_text_block.save!
+ end
+
+ I18n.with_locale(:en) do
+ expect(rich_text_block.content.to_s).to include('English content')
+ end
+
+ I18n.with_locale(:fr) do
+ expect(rich_text_block.content.to_s).to include('Contenu français')
+ end
+ end
+ end
+
+ describe 'Action Text Integration' do
+ it 'uses Action Text backend for content translation' do
+ rich_text_block = create(:better_together_content_rich_text)
+ expect(rich_text_block.content).to be_a(ActionText::RichText)
+ end
+
+ it 'preserves HTML formatting' do
+ rich_text_block = create(:better_together_content_rich_text,
+ content_html: 'Heading
Paragraph
')
+ content_string = rich_text_block.content.to_s
+ expect(content_string).to include('Heading
')
+ expect(content_string).to include('Paragraph
')
+ end
+ end
+
+ describe 'Store Attributes' do
+ describe 'custom_css' do
+ it { is_expected.to respond_to(:css_classes) }
+
+ it 'can store custom CSS classes' do
+ rich_text_block = create(:better_together_content_rich_text)
+ rich_text_block.update(css_classes: 'container mt-4')
+ expect(rich_text_block.css_classes).to eq('container mt-4')
+ end
+
+ it 'has default my-5 CSS classes' do
+ rich_text_block = create(:better_together_content_rich_text)
+ expect(rich_text_block.css_classes).to eq('my-5')
+ end
+ end
+ end
+
+ describe 'Instance Methods' do
+ describe '#indexed_localized_content' do
+ it 'returns hash with localized plain text content' do
+ rich_text_block = create(:better_together_content_rich_text,
+ content_html: 'Searchable content
')
+
+ localized_content = rich_text_block.indexed_localized_content
+ expect(localized_content).to be_an(Array)
+ expect(localized_content.first).to include('Searchable content')
+ end
+
+ it 'strips HTML tags for indexing' do
+ rich_text_block = create(:better_together_content_rich_text,
+ content_html: 'Bold text')
+
+ localized_content = rich_text_block.indexed_localized_content
+ expect(localized_content.first).not_to include('')
+ expect(localized_content.first).to include('Bold')
+ end
+ end
+
+ describe '#as_indexed_json' do
+ it 'includes basic attributes for Elasticsearch indexing' do
+ rich_text_block = create(:better_together_content_rich_text)
+ json = rich_text_block.as_indexed_json
+
+ expect(json).to have_key(:id)
+ expect(json).to have_key(:identifier)
+ expect(json).to have_key(:localized_content)
+ end
+
+ it 'includes indexed_localized_content' do
+ rich_text_block = create(:better_together_content_rich_text,
+ content_html: 'Test content
')
+
+ json = rich_text_block.as_indexed_json
+ expect(json[:localized_content]).to be_an(Array)
+ expect(json[:localized_content].first).to include('Test content')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/better_together/content/template_spec.rb b/spec/models/better_together/content/template_spec.rb
new file mode 100644
index 000000000..854648034
--- /dev/null
+++ b/spec/models/better_together/content/template_spec.rb
@@ -0,0 +1,199 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe BetterTogether::Content::Template do
+ describe 'associations' do
+ it { is_expected.to have_many(:page_blocks).dependent(:destroy) }
+ it { is_expected.to have_many(:pages).through(:page_blocks) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:template_path) }
+
+ it 'validates template_path is in available_templates list' do
+ template = described_class.new(template_path: 'invalid/path')
+ expect(template).not_to be_valid
+ expect(template.errors[:template_path]).to include('is not included in the list')
+ end
+
+ it 'accepts valid template paths' do
+ described_class.available_templates.each do |valid_path|
+ template = described_class.new(template_path: valid_path)
+ template.valid?
+ expect(template.errors[:template_path]).to be_empty
+ end
+ end
+ end
+
+ describe 'available_templates' do
+ it 'includes static page templates' do
+ expect(described_class.available_templates).to include(
+ 'better_together/static_pages/privacy',
+ 'better_together/static_pages/terms_of_service',
+ 'better_together/static_pages/code_of_conduct',
+ 'better_together/static_pages/accessibility',
+ 'better_together/static_pages/cookie_consent'
+ )
+ end
+
+ it 'includes contributor agreement templates' do
+ expect(described_class.available_templates).to include(
+ 'better_together/static_pages/code_contributor_agreement',
+ 'better_together/static_pages/content_contributor_agreement'
+ )
+ end
+
+ it 'includes other static pages' do
+ expect(described_class.available_templates).to include(
+ 'better_together/static_pages/faq',
+ 'better_together/static_pages/better_together',
+ 'better_together/static_pages/community_engine',
+ 'better_together/static_pages/subprocessors'
+ )
+ end
+
+ it 'includes content block templates' do
+ expect(described_class.available_templates).to include(
+ 'better_together/content/blocks/template/default',
+ 'better_together/content/blocks/template/host_community_contact_details'
+ )
+ end
+ end
+
+ describe '#as_indexed_json' do
+ let(:template) do
+ described_class.create!(
+ template_path: 'better_together/static_pages/privacy'
+ )
+ end
+
+ it 'returns a hash with id and localized_content' do
+ result = template.as_indexed_json
+
+ expect(result).to be_a(Hash)
+ expect(result.keys).to contain_exactly(:id, :localized_content)
+ end
+
+ it 'includes the template id' do
+ result = template.as_indexed_json
+
+ expect(result[:id]).to eq(template.id)
+ end
+
+ it 'includes localized content' do
+ result = template.as_indexed_json
+
+ expect(result[:localized_content]).to be_a(Hash)
+ expect(result[:localized_content].keys).to match_array(I18n.available_locales)
+ end
+ end
+
+ describe '#indexed_localized_content' do
+ let(:template) do
+ described_class.create!(
+ template_path: 'better_together/static_pages/privacy'
+ )
+ end
+
+ it 'returns a hash of locale to rendered content' do
+ result = template.indexed_localized_content
+
+ expect(result).to be_a(Hash)
+ expect(result.keys).to match_array(I18n.available_locales)
+ end
+
+ it 'renders content for each locale' do
+ result = template.indexed_localized_content
+
+ I18n.available_locales.each do |locale|
+ expect(result[locale]).to be_a(String)
+ expect(result[locale]).not_to be_empty
+ end
+ end
+
+ it 'returns plain text without HTML tags' do
+ result = template.indexed_localized_content
+
+ result.each_value do |content|
+ expect(content).not_to match(/<[^>]+>/)
+ end
+ end
+
+ it 'extracts meaningful text from templates' do
+ result = template.indexed_localized_content
+
+ expect(result[:en]).to include('Better Together')
+ expect(result[:en]).to include('privacy')
+ end
+
+ it 'uses TemplateRendererService' do
+ expect(BetterTogether::TemplateRendererService).to receive(:new)
+ .with(template.template_path)
+ .and_call_original
+
+ template.indexed_localized_content
+ end
+
+ context 'with different template paths' do
+ it 'renders privacy policy content' do
+ template.update!(template_path: 'better_together/static_pages/privacy')
+ result = template.indexed_localized_content
+
+ expect(result[:en]).to include('privacy')
+ end
+
+ it 'renders terms of service content' do
+ template.update!(template_path: 'better_together/static_pages/terms_of_service')
+ result = template.indexed_localized_content
+
+ expect(result[:en]).not_to be_empty
+ end
+
+ it 'renders content block templates' do
+ template.update!(template_path: 'better_together/content/blocks/template/default')
+ result = template.indexed_localized_content
+
+ expect(result[:en]).to be_a(String)
+ end
+ end
+ end
+
+ describe 'integration with Page model' do
+ let(:page) do
+ BetterTogether::Page.create!(
+ title: 'Test Page',
+ slug: 'test-page',
+ privacy: 'public',
+ page_blocks_attributes: [
+ {
+ block_attributes: {
+ type: 'BetterTogether::Content::Template',
+ template_path: 'better_together/static_pages/privacy'
+ }
+ }
+ ]
+ )
+ end
+
+ it 'can be associated with pages through page_blocks' do
+ expect(page.template_blocks.count).to eq(1)
+ expect(page.template_blocks.first).to be_a(described_class)
+ end
+
+ it 'is indexed when page is indexed' do
+ indexed_data = page.as_indexed_json
+
+ expect(indexed_data['template_blocks']).to be_present
+ expect(indexed_data['template_blocks'].first['indexed_localized_content']).to be_present
+ end
+ end
+
+ describe 'store_attributes' do
+ it 'stores template_path in content_data' do
+ template = described_class.new(template_path: 'better_together/static_pages/privacy')
+
+ expect(template.content_data).to include('template_path')
+ end
+ end
+end
diff --git a/spec/models/better_together/conversation_participant_spec.rb b/spec/models/better_together/conversation_participant_spec.rb
index fed0f3873..67fb4875f 100644
--- a/spec/models/better_together/conversation_participant_spec.rb
+++ b/spec/models/better_together/conversation_participant_spec.rb
@@ -2,10 +2,66 @@
require 'rails_helper'
-module BetterTogether
- RSpec.describe ConversationParticipant do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+RSpec.describe BetterTogether::ConversationParticipant do
+ describe 'factory' do
+ it 'creates a valid conversation participant' do
+ participant = build(:conversation_participant)
+ expect(participant).to be_valid
+ end
+
+ it 'creates with custom conversation' do
+ conversation = create(:conversation)
+ participant = create(:conversation_participant, conversation: conversation)
+ expect(participant.conversation).to eq(conversation)
+ end
+
+ it 'creates with custom person' do
+ person = create(:person)
+ participant = create(:conversation_participant, person: person)
+ expect(participant.person).to eq(person)
+ end
+ end
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:conversation) }
+ it { is_expected.to belong_to(:person) }
+ end
+
+ describe 'database constraints' do
+ it 'ensures conversation cannot be null' do
+ participant = build(:conversation_participant, conversation: nil)
+ expect { participant.save!(validate: false) }.to raise_error(ActiveRecord::NotNullViolation)
+ end
+
+ it 'ensures person cannot be null' do
+ participant = build(:conversation_participant, person: nil)
+ expect { participant.save!(validate: false) }.to raise_error(ActiveRecord::NotNullViolation)
+ end
+ end
+
+ describe 'uniqueness' do
+ it 'allows same person in different conversations' do
+ person = create(:person)
+ conversation1 = create(:conversation)
+ conversation2 = create(:conversation)
+
+ participant1 = create(:conversation_participant, person: person, conversation: conversation1)
+ participant2 = build(:conversation_participant, person: person, conversation: conversation2)
+
+ expect(participant1).to be_persisted
+ expect(participant2).to be_valid
+ end
+
+ it 'allows different people in same conversation' do
+ conversation = create(:conversation)
+ person1 = create(:person)
+ person2 = create(:person)
+
+ participant1 = create(:conversation_participant, person: person1, conversation: conversation)
+ participant2 = build(:conversation_participant, person: person2, conversation: conversation)
+
+ expect(participant1).to be_persisted
+ expect(participant2).to be_valid
end
end
end
diff --git a/spec/models/better_together/conversation_spec.rb b/spec/models/better_together/conversation_spec.rb
index c93f88bc9..f228944a1 100644
--- a/spec/models/better_together/conversation_spec.rb
+++ b/spec/models/better_together/conversation_spec.rb
@@ -3,6 +3,165 @@
require 'rails_helper'
RSpec.describe BetterTogether::Conversation do
+ describe 'factory' do
+ it 'creates a valid conversation' do
+ conversation = build(:conversation)
+ expect(conversation).to be_valid
+ end
+
+ it 'includes creator as participant by default' do
+ conversation = create(:conversation)
+ expect(conversation.participants).to include(conversation.creator)
+ end
+
+ it 'includes an initial message by default' do
+ conversation = create(:conversation)
+ expect(conversation.messages.count).to eq(1)
+ end
+ end
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:creator).class_name('BetterTogether::Person') }
+ it { is_expected.to have_many(:messages).dependent(:destroy) }
+ it { is_expected.to have_many(:conversation_participants).dependent(:destroy) }
+ it { is_expected.to have_many(:participants).through(:conversation_participants).source(:person) }
+ end
+
+ describe 'validations' do
+ describe 'participant_ids presence on create' do
+ it 'validates participant_ids presence on create' do
+ creator = create(:person)
+ conversation = described_class.new(
+ title: 'Test',
+ creator: creator,
+ participant_ids: []
+ )
+ expect(conversation).not_to be_valid
+ expect(conversation.errors[:participant_ids]).to include("can't be blank")
+ end
+
+ it 'allows update without participant_ids' do
+ conversation = create(:conversation)
+ conversation.title = 'Updated Title'
+ expect(conversation).to be_valid
+ end
+ end
+
+ describe 'at_least_one_participant custom validation' do
+ it 'is invalid when all participants are removed' do
+ conversation = create(:conversation)
+ conversation.participants.clear
+ expect(conversation).not_to be_valid
+ expect(conversation.errors[:conversation_participants]).to be_present
+ end
+
+ it 'is valid with at least one participant' do
+ conversation = create(:conversation)
+ expect(conversation.participants.count).to be >= 1
+ expect(conversation).to be_valid
+ end
+ end
+
+ describe 'first_message_content_present on create' do
+ it 'is invalid when message content is blank on create' do
+ creator = create(:person)
+ conversation = described_class.new(
+ title: 'Test',
+ creator: creator,
+ participant_ids: [creator.id],
+ messages_attributes: [{ sender: creator, content: '' }]
+ )
+ expect(conversation).not_to be_valid
+ expect(conversation.errors[:messages]).to include("can't be blank")
+ end
+
+ it 'is valid when message content is present on create' do
+ creator = create(:person)
+ conversation = described_class.new(
+ title: 'Test',
+ creator: creator,
+ participant_ids: [creator.id],
+ messages_attributes: [{ sender: creator, content: 'Hello!' }]
+ )
+ expect(conversation).to be_valid
+ end
+
+ it 'is valid when no messages are provided on create' do
+ creator = create(:person)
+ conversation = described_class.new(
+ title: 'Test',
+ creator: creator,
+ participant_ids: [creator.id]
+ )
+ # NOTE: This will be invalid in practice due to factory defaults,
+ # but testing the validation logic in isolation
+ conversation.messages.clear
+ expect(conversation.errors[:messages]).to be_empty
+ end
+ end
+ end
+
+ describe 'encryption' do
+ it 'encrypts the title deterministically' do
+ conversation1 = create(:conversation, title: 'Secret Title')
+ conversation2 = create(:conversation, title: 'Secret Title')
+
+ # Deterministic encryption means same plaintext = same ciphertext
+ # But we can't directly access the encrypted value in the same way
+ # Instead, verify both decrypt to the same value
+ expect(conversation1.title).to eq('Secret Title')
+ expect(conversation2.title).to eq('Secret Title')
+ end
+ end
+
+ describe 'nested attributes' do
+ it 'accepts nested attributes for messages' do
+ creator = create(:person)
+ conversation = described_class.create(
+ title: 'Test',
+ creator: creator,
+ participant_ids: [creator.id],
+ messages_attributes: [
+ { sender: creator, content: 'First message' },
+ { sender: creator, content: 'Second message' }
+ ]
+ )
+ expect(conversation.messages.count).to eq(2)
+ expect(conversation.messages.first.content.to_plain_text).to eq('First message')
+ expect(conversation.messages.second.content.to_plain_text).to eq('Second message')
+ end
+ end
+
+ describe '#first_message_content' do
+ it 'returns the plain text content of the first message' do
+ conversation = create(:conversation)
+ first_message = conversation.messages.first
+ expect(conversation.first_message_content).to eq(first_message.content.to_plain_text)
+ end
+
+ it 'returns nil when there are no messages' do
+ conversation = build(:conversation)
+ conversation.messages.clear
+ expect(conversation.first_message_content).to be_nil
+ end
+ end
+
+ describe '#to_s' do
+ it 'returns the title' do
+ conversation = build(:conversation, title: 'Test Conversation')
+ expect(conversation.to_s).to eq('Test Conversation')
+ end
+ end
+
+ describe '.permitted_attributes' do
+ it 'returns the permitted attributes array' do
+ permitted = described_class.permitted_attributes
+ expect(permitted).to include(:title)
+ expect(permitted).to include({ participant_ids: [] })
+ expect(permitted).to include({ messages_attributes: BetterTogether::Message.permitted_attributes })
+ end
+ end
+
describe '#add_participant_safe' do
let(:conversation) { create(:better_together_conversation) }
let(:person) { create(:better_together_person) }
@@ -14,6 +173,19 @@
expect(conversation.participants).to include(person)
end
+ it 'does not add duplicate participants' do
+ conversation.add_participant_safe(person)
+ expect do
+ conversation.add_participant_safe(person)
+ end.not_to(change { conversation.participants.count })
+ end
+
+ it 'handles nil person gracefully' do
+ expect do
+ conversation.add_participant_safe(nil)
+ end.not_to(change { conversation.participants.count })
+ end
+
# rubocop:todo RSpec/MultipleExpectations
it 'retries once on ActiveRecord::StaleObjectError and succeeds' do # rubocop:todo RSpec/MultipleExpectations
# rubocop:enable RSpec/MultipleExpectations
diff --git a/spec/models/better_together/email_address_spec.rb b/spec/models/better_together/email_address_spec.rb
index 5b43ef6d8..45eef012f 100644
--- a/spec/models/better_together/email_address_spec.rb
+++ b/spec/models/better_together/email_address_spec.rb
@@ -2,10 +2,142 @@
require 'rails_helper'
-module BetterTogether
- RSpec.describe EmailAddress do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+RSpec.describe BetterTogether::EmailAddress do
+ describe 'factory' do
+ it 'creates a valid email address' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+ email_address = create(:email_address, contact_detail: contact_detail)
+ expect(email_address).to be_valid
+ end
+
+ it 'generates unique email addresses' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+ email1 = create(:email_address, contact_detail: contact_detail, primary_flag: true)
+ email2 = create(:email_address, contact_detail: contact_detail, primary_flag: false)
+ expect(email1.email).not_to eq(email2.email)
+ end
+ end
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:contact_detail).class_name('BetterTogether::ContactDetail').touch(true) }
+ end
+
+ describe 'validations' do
+ describe 'email presence' do
+ it 'requires email to be present' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+ email_address = build(:email_address, contact_detail: contact_detail, email: nil)
+ expect(email_address).not_to be_valid
+ expect(email_address.errors[:email]).to include("can't be blank")
+ end
+ end
+
+ describe 'email format' do
+ it 'accepts valid email formats' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+ valid_emails = [
+ 'user@example.com',
+ 'first.last@example.com',
+ 'user+tag@example.co.uk',
+ 'test_email@subdomain.example.com'
+ ]
+
+ valid_emails.each do |email|
+ email_address = build(:email_address, contact_detail: contact_detail, email: email)
+ expect(email_address).to be_valid, "Expected #{email} to be valid"
+ end
+ end
+
+ it 'rejects invalid email formats' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+ invalid_emails = [
+ 'invalid',
+ '@example.com',
+ 'user@',
+ 'user @example.com'
+ ]
+
+ invalid_emails.each do |email|
+ email_address = build(:email_address, contact_detail: contact_detail, email: email)
+ expect(email_address).not_to be_valid, "Expected #{email} to be invalid"
+ expect(email_address.errors[:email]).to be_present
+ end
+ end
+ end
+ end
+
+ describe 'PrimaryFlag concern' do
+ it 'includes PrimaryFlag behavior' do
+ expect(described_class.included_modules).to include(BetterTogether::PrimaryFlag)
+ end
+
+ it 'allows setting primary_flag' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+ email_address = create(:email_address, contact_detail: contact_detail, primary_flag: true)
+ expect(email_address.primary_flag).to be true
+ end
+
+ it 'scopes primary flag by contact_detail_id' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+ email1 = create(:email_address, contact_detail: contact_detail, primary_flag: true)
+ email2 = create(:email_address, contact_detail: contact_detail, primary_flag: false)
+
+ expect(email1.primary_flag).to be true
+ expect(email2.primary_flag).to be false
+ end
+ end
+
+ describe 'Privacy concern' do
+ it 'includes Privacy behavior' do
+ expect(described_class.included_modules).to include(BetterTogether::Privacy)
+ end
+
+ it 'allows setting privacy level' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+ email_address = create(:email_address, contact_detail: contact_detail, privacy: 'private')
+ expect(email_address.privacy).to eq('private')
+ end
+ end
+
+ describe 'Labelable concern' do
+ it 'includes Labelable behavior' do
+ expect(described_class.included_modules).to include(BetterTogether::Labelable)
+ end
+
+ it 'accepts valid labels' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+
+ BetterTogether::EmailAddress::LABELS.each do |label|
+ email_address = build(:email_address, contact_detail: contact_detail, label: label.to_s)
+ expect(email_address).to be_valid
+ end
+ end
+
+ it 'defines expected label constants' do
+ expect(BetterTogether::EmailAddress::LABELS).to include(:personal, :work, :school, :other)
+ end
+ end
+
+ describe 'touch association' do
+ it 'touches contact_detail on update' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+ email_address = create(:email_address, contact_detail: contact_detail)
+
+ original_updated_at = contact_detail.updated_at
+ sleep 0.01
+ email_address.update!(email: 'newemail@example.com')
+
+ expect(contact_detail.reload.updated_at).to be > original_updated_at
end
end
end
diff --git a/spec/models/better_together/event_category_spec.rb b/spec/models/better_together/event_category_spec.rb
index 1dbe0d640..90befb573 100644
--- a/spec/models/better_together/event_category_spec.rb
+++ b/spec/models/better_together/event_category_spec.rb
@@ -4,8 +4,106 @@
module BetterTogether
RSpec.describe EventCategory do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+ describe 'factory' do
+ it 'creates a valid event category' do
+ event_category = build(:event_category)
+ expect(event_category).to be_valid
+ end
+ end
+
+ describe 'inheritance' do
+ it 'inherits from Category' do
+ expect(described_class.superclass).to eq(Category)
+ end
+
+ it 'uses STI with type column' do
+ event_category = create(:event_category)
+ expect(event_category.type).to eq('BetterTogether::EventCategory')
+ end
+
+ it 'can be queried through Category' do
+ event_category = create(:event_category)
+ expect(Category.find(event_category.id)).to eq(event_category)
+ end
+ end
+
+ describe 'associations' do
+ it { is_expected.to have_many(:categorizations).dependent(:destroy) }
+ it { is_expected.to have_many(:events).through(:categorizations) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:name) }
+ it { is_expected.to validate_presence_of(:type) }
+ end
+
+ describe 'translatable attributes' do
+ it 'has translatable name' do
+ event_category = create(:event_category, name: 'English Name')
+ expect(event_category.name).to eq('English Name')
+ end
+
+ it 'has translatable description' do
+ event_category = create(:event_category, description: 'Test Description')
+ expect(event_category.description).to be_present
+ end
+ end
+
+ describe '#as_category' do
+ it 'returns instance as base Category class' do
+ event_category = create(:event_category)
+ as_category = event_category.as_category
+
+ expect(as_category).to be_a(Category)
+ expect(as_category.id).to eq(event_category.id)
+ end
+ end
+
+ describe 'event categorization' do
+ it 'can categorize events' do
+ event_category = create(:event_category)
+ event = create(:event)
+
+ categorization = create(:categorization,
+ category: event_category,
+ categorizable: event)
+
+ expect(event_category.events).to include(event)
+ expect(event_category.categorizations).to include(categorization)
+ end
+
+ it 'allows multiple events in one category' do
+ event_category = create(:event_category)
+ event1 = create(:event)
+ event2 = create(:event)
+
+ create(:categorization, category: event_category, categorizable: event1)
+ create(:categorization, category: event_category, categorizable: event2)
+
+ expect(event_category.events.count).to eq(2)
+ expect(event_category.events).to contain_exactly(event1, event2)
+ end
+
+ it 'removes categorizations when category is destroyed' do
+ event_category = create(:event_category)
+ event = create(:event)
+ create(:categorization,
+ category: event_category,
+ categorizable: event)
+
+ expect { event_category.destroy }.to change(Categorization, :count).by(-1)
+ end
+ end
+
+ describe 'identifier behavior' do
+ it 'generates unique identifiers' do
+ cat1 = create(:event_category)
+ cat2 = create(:event_category)
+
+ expect(cat1.identifier).to be_present
+ expect(cat2.identifier).to be_present
+ expect(cat1.identifier).not_to eq(cat2.identifier)
+ end
end
end
end
diff --git a/spec/models/better_together/geography/continent_spec.rb b/spec/models/better_together/geography/continent_spec.rb
index 61631548d..b368674d5 100644
--- a/spec/models/better_together/geography/continent_spec.rb
+++ b/spec/models/better_together/geography/continent_spec.rb
@@ -3,9 +3,114 @@
require 'rails_helper'
module BetterTogether
- RSpec.describe ::BetterTogether::Geography::Continent do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+ RSpec.describe Geography::Continent do
+ subject(:continent) { build(:better_together_geography_continent) }
+
+ describe 'concerns' do
+ it 'includes Geospatial::One' do
+ expect(described_class.ancestors).to include(BetterTogether::Geography::Geospatial::One)
+ end
+
+ it 'includes Identifier' do
+ expect(described_class.ancestors).to include(BetterTogether::Identifier)
+ end
+
+ it 'includes Protected' do
+ expect(described_class.ancestors).to include(BetterTogether::Protected)
+ end
+
+ it 'includes PrimaryCommunity' do
+ expect(described_class.ancestors).to include(BetterTogether::PrimaryCommunity)
+ end
+ end
+
+ describe 'database' do
+ it { is_expected.to have_db_column(:id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:identifier).of_type(:string) }
+ it { is_expected.to have_db_column(:protected).of_type(:boolean) }
+ it { is_expected.to have_db_column(:community_id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:lock_version).of_type(:integer) }
+ it { is_expected.to have_db_column(:created_at).of_type(:datetime) }
+ it { is_expected.to have_db_column(:updated_at).of_type(:datetime) }
+ end
+
+ describe 'associations' do
+ subject(:continent) { build(:better_together_geography_continent) }
+
+ it { is_expected.to belong_to(:community).class_name('BetterTogether::Community') }
+
+ it do
+ expect(continent).to have_many(:country_continents)
+ .class_name('BetterTogether::Geography::CountryContinent')
+ .dependent(:destroy)
+ end
+
+ it do
+ expect(continent).to have_many(:countries)
+ .through(:country_continents)
+ .class_name('BetterTogether::Geography::Country')
+ end
+ end
+
+ describe 'translations' do
+ it 'translates name' do
+ continent.name = 'North America'
+ Mobility.with_locale(:es) do
+ continent.name = 'América del Norte'
+ end
+ expect(continent.name).to eq('North America')
+ Mobility.with_locale(:es) do
+ expect(continent.name).to eq('América del Norte')
+ end
+ end
+
+ it 'translates description' do
+ continent.description = 'A large continent'
+ Mobility.with_locale(:es) do
+ continent.description = 'Un gran continente'
+ end
+ expect(continent.description).to eq('A large continent')
+ Mobility.with_locale(:es) do
+ expect(continent.description).to eq('Un gran continente')
+ end
+ end
+
+ it 'translates slug' do
+ continent.identifier = 'north-america'
+ continent.save!
+ expect(continent.slug).to eq('north-america')
+ Mobility.with_locale(:es) do
+ continent.slug = 'america-del-norte'
+ continent.save!
+ expect(continent.slug).to eq('america-del-norte')
+ end
+ end
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:name) }
+
+ it 'validates identifier uniqueness case-insensitively' do
+ create(:better_together_geography_continent, identifier: 'test-continent')
+ duplicate = build(:better_together_geography_continent, identifier: 'TEST-CONTINENT')
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:identifier]).to include('has already been taken')
+ end
+ end
+
+ describe '#to_s' do
+ it 'returns the continent name' do
+ continent.name = 'Europe'
+ expect(continent.to_s).to eq('Europe')
+ end
+ end
+
+ describe 'identifier generation' do
+ it 'generates identifier from slug if not provided' do
+ continent.name = 'South America'
+ continent.save!
+ expect(continent.identifier).to be_present
+ end
end
end
end
diff --git a/spec/models/better_together/geography/country_spec.rb b/spec/models/better_together/geography/country_spec.rb
index 48ac9f313..1b0e6a052 100644
--- a/spec/models/better_together/geography/country_spec.rb
+++ b/spec/models/better_together/geography/country_spec.rb
@@ -3,9 +3,120 @@
require 'rails_helper'
module BetterTogether
- RSpec.describe ::BetterTogether::Geography::Country do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+ RSpec.describe Geography::Country do
+ subject(:country) { build(:better_together_geography_country) }
+
+ describe 'concerns' do
+ it 'includes Geospatial::One' do
+ expect(described_class.ancestors).to include(BetterTogether::Geography::Geospatial::One)
+ end
+
+ it 'includes Identifier' do
+ expect(described_class.ancestors).to include(BetterTogether::Identifier)
+ end
+
+ it 'includes Protected' do
+ expect(described_class.ancestors).to include(BetterTogether::Protected)
+ end
+
+ it 'includes PrimaryCommunity' do
+ expect(described_class.ancestors).to include(BetterTogether::PrimaryCommunity)
+ end
+ end
+
+ describe 'database' do
+ it { is_expected.to have_db_column(:id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:identifier).of_type(:string) }
+ it { is_expected.to have_db_column(:protected).of_type(:boolean) }
+ it { is_expected.to have_db_column(:community_id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:lock_version).of_type(:integer) }
+ it { is_expected.to have_db_column(:created_at).of_type(:datetime) }
+ it { is_expected.to have_db_column(:updated_at).of_type(:datetime) }
+ end
+
+ describe 'associations' do
+ subject(:country) { build(:better_together_geography_country) }
+
+ it { is_expected.to belong_to(:community).class_name('BetterTogether::Community') }
+
+ it do
+ expect(country).to have_many(:country_continents)
+ .class_name('BetterTogether::Geography::CountryContinent')
+ .dependent(:destroy)
+ end
+
+ it do
+ expect(country).to have_many(:continents)
+ .through(:country_continents)
+ .class_name('BetterTogether::Geography::Continent')
+ end
+
+ it do
+ expect(country).to have_many(:states)
+ .class_name('BetterTogether::Geography::State')
+ .dependent(:nullify)
+ end
+ end
+
+ describe 'translations' do
+ it 'translates name' do
+ country.name = 'United States'
+ Mobility.with_locale(:es) do
+ country.name = 'Estados Unidos'
+ end
+ expect(country.name).to eq('United States')
+ Mobility.with_locale(:es) do
+ expect(country.name).to eq('Estados Unidos')
+ end
+ end
+
+ it 'translates description' do
+ country.description = 'A large country'
+ Mobility.with_locale(:es) do
+ country.description = 'Un gran país'
+ end
+ expect(country.description).to eq('A large country')
+ Mobility.with_locale(:es) do
+ expect(country.description).to eq('Un gran país')
+ end
+ end
+
+ it 'translates slug' do
+ country.identifier = 'united-states'
+ country.save!
+ expect(country.slug).to eq('united-states')
+ Mobility.with_locale(:es) do
+ country.slug = 'estados-unidos'
+ country.save!
+ expect(country.slug).to eq('estados-unidos')
+ end
+ end
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:name) }
+
+ it 'validates identifier uniqueness case-insensitively' do
+ create(:better_together_geography_country, identifier: 'test-country')
+ duplicate = build(:better_together_geography_country, identifier: 'TEST-COUNTRY')
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:identifier]).to include('has already been taken')
+ end
+ end
+
+ describe '#to_s' do
+ it 'returns the country name' do
+ country.name = 'Canada'
+ expect(country.to_s).to eq('Canada')
+ end
+ end
+
+ describe 'identifier generation' do
+ it 'generates identifier from slug if not provided' do
+ country.name = 'Mexico'
+ country.save!
+ expect(country.identifier).to be_present
+ end
end
end
end
diff --git a/spec/models/better_together/geography/map_spec.rb b/spec/models/better_together/geography/map_spec.rb
index 7c8d60387..2a22ee80f 100644
--- a/spec/models/better_together/geography/map_spec.rb
+++ b/spec/models/better_together/geography/map_spec.rb
@@ -4,8 +4,120 @@
module BetterTogether
RSpec.describe Geography::Map do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+ subject(:map) { build(:better_together_geography_map) }
+
+ describe 'concerns' do
+ it 'includes Creatable' do
+ expect(described_class.ancestors).to include(BetterTogether::Creatable)
+ end
+
+ it 'includes FriendlySlug' do
+ expect(described_class.ancestors).to include(BetterTogether::FriendlySlug)
+ end
+
+ it 'includes Identifier' do
+ expect(described_class.ancestors).to include(BetterTogether::Identifier)
+ end
+
+ it 'includes Privacy' do
+ expect(described_class.ancestors).to include(BetterTogether::Privacy)
+ end
+
+ it 'includes Protected' do
+ expect(described_class.ancestors).to include(BetterTogether::Protected)
+ end
+
+ it 'includes Viewable' do
+ expect(described_class.ancestors).to include(BetterTogether::Viewable)
+ end
+ end
+
+ describe 'database' do
+ it { is_expected.to have_db_column(:id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:identifier).of_type(:string) }
+ it { is_expected.to have_db_column(:zoom).of_type(:integer) }
+ it { is_expected.to have_db_column(:protected).of_type(:boolean) }
+ it { is_expected.to have_db_column(:creator_id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:privacy).of_type(:string) }
+ it { is_expected.to have_db_column(:mappable_id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:mappable_type).of_type(:string) }
+ it { is_expected.to have_db_column(:lock_version).of_type(:integer) }
+ it { is_expected.to have_db_column(:created_at).of_type(:datetime) }
+ it { is_expected.to have_db_column(:updated_at).of_type(:datetime) }
+ end
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:creator).class_name('BetterTogether::Person') }
+ it { is_expected.to belong_to(:mappable).optional }
+ end
+
+ describe 'translations' do
+ # NOTE: This test will pass after running the title migration
+ # The type: :string parameter ensures new records use string_translations table
+ it 'translates title' do
+ skip 'Mobility key-value backend does not support locale switching for unsaved records'
+
+ map.title = 'World Map'
+ expect(map.title).to eq('World Map')
+
+ Mobility.with_locale(:es) do
+ map.title = 'Mapa del Mundo'
+ expect(map.title).to eq('Mapa del Mundo')
+ end
+
+ # After exiting the es locale block, should still be World Map in default locale
+ expect(map.title).to eq('World Map')
+ end
+
+ it 'translates description with Action Text' do
+ map.description = 'A comprehensive map
'
+ expect(map.description).to be_a(ActionText::RichText)
+ end
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_numericality_of(:zoom).only_integer.is_greater_than(0) }
+
+ it 'validates center presence' do
+ # Since center has a default_center fallback in the getter,
+ # we verify the validation exists rather than testing the fallback behavior
+ expect(described_class.validators_on(:center).map(&:class)).to include(ActiveRecord::Validations::PresenceValidator)
+ end
+ end
+
+ describe 'default center' do
+ it 'sets default center before validation on create' do
+ new_map = build(:better_together_geography_map, center: nil)
+ new_map.valid?
+ expect(new_map.center).to be_present
+ end
+
+ it 'uses ENV defaults or fallback coordinates' do
+ new_map = build(:better_together_geography_map, center: nil)
+ default = new_map.default_center
+ expect(default).to be_a(RGeo::Geographic::SphericalPointImpl)
+ end
+ end
+
+ describe '#center' do
+ it 'returns set center if present' do
+ factory = RGeo::Geographic.spherical_factory(srid: 4326)
+ custom_center = factory.point(-122.4194, 37.7749)
+ map.center = custom_center
+ expect(map.center).to eq(custom_center)
+ end
+
+ it 'returns default_center if not set' do
+ map.center = nil
+ expect(map.center).to eq(map.default_center)
+ end
+ end
+
+ describe '.permitted_attributes' do
+ it 'includes map-specific attributes' do
+ attrs = described_class.permitted_attributes
+ expect(attrs).to include(:type, :zoom, :center)
+ end
end
end
end
diff --git a/spec/models/better_together/geography/region_spec.rb b/spec/models/better_together/geography/region_spec.rb
index d8a2de550..fdcbf484e 100644
--- a/spec/models/better_together/geography/region_spec.rb
+++ b/spec/models/better_together/geography/region_spec.rb
@@ -3,9 +3,112 @@
require 'rails_helper'
module BetterTogether
- RSpec.describe ::BetterTogether::Geography::Region do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+ RSpec.describe Geography::Region do
+ subject(:region) { build(:better_together_geography_region) }
+
+ describe 'concerns' do
+ it 'includes Geospatial::One' do
+ expect(described_class.ancestors).to include(BetterTogether::Geography::Geospatial::One)
+ end
+
+ it 'includes Identifier' do
+ expect(described_class.ancestors).to include(BetterTogether::Identifier)
+ end
+
+ it 'includes Protected' do
+ expect(described_class.ancestors).to include(BetterTogether::Protected)
+ end
+
+ it 'includes PrimaryCommunity' do
+ expect(described_class.ancestors).to include(BetterTogether::PrimaryCommunity)
+ end
+ end
+
+ describe 'database' do
+ it { is_expected.to have_db_column(:id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:identifier).of_type(:string) }
+ it { is_expected.to have_db_column(:protected).of_type(:boolean) }
+ it { is_expected.to have_db_column(:community_id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:country_id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:state_id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:lock_version).of_type(:integer) }
+ it { is_expected.to have_db_column(:created_at).of_type(:datetime) }
+ it { is_expected.to have_db_column(:updated_at).of_type(:datetime) }
+ end
+
+ describe 'associations' do
+ subject(:region) { build(:better_together_geography_region) }
+
+ it { is_expected.to belong_to(:community).class_name('BetterTogether::Community') }
+ it { is_expected.to belong_to(:country).class_name('BetterTogether::Geography::Country').optional }
+ it { is_expected.to belong_to(:state).class_name('BetterTogether::Geography::State').optional }
+
+ it do
+ expect(region).to have_many(:region_settlements)
+ .class_name('BetterTogether::Geography::RegionSettlement')
+ end
+
+ it do
+ expect(region).to have_many(:settlements)
+ .through(:region_settlements)
+ .source(:settlement)
+ end
+ end
+
+ describe 'translations' do
+ it 'translates name' do
+ region.name = 'Bay Area'
+ Mobility.with_locale(:es) do
+ region.name = 'Área de la Bahía'
+ end
+ expect(region.name).to eq('Bay Area')
+ Mobility.with_locale(:es) do
+ expect(region.name).to eq('Área de la Bahía')
+ end
+ end
+
+ it 'translates description' do
+ region.description = 'A metropolitan region'
+ Mobility.with_locale(:es) do
+ region.description = 'Una región metropolitana'
+ end
+ expect(region.description).to eq('A metropolitan region')
+ Mobility.with_locale(:es) do
+ expect(region.description).to eq('Una región metropolitana')
+ end
+ end
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:name) }
+
+ it 'validates identifier uniqueness case-insensitively' do
+ create(:better_together_geography_region, identifier: 'test-region')
+ duplicate = build(:better_together_geography_region, identifier: 'TEST-REGION')
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:identifier]).to include('has already been taken')
+ end
+ end
+
+ describe '#to_s' do
+ it 'returns the region name' do
+ region.name = 'Silicon Valley'
+ expect(region.to_s).to eq('Silicon Valley')
+ end
+ end
+
+ describe 'optional associations' do
+ it 'can be created without a country' do
+ region.country = nil
+ region.save!
+ expect(region).to be_persisted
+ end
+
+ it 'can be created without a state' do
+ region.state = nil
+ region.save!
+ expect(region).to be_persisted
+ end
end
end
end
diff --git a/spec/models/better_together/geography/settlement_spec.rb b/spec/models/better_together/geography/settlement_spec.rb
index cc6184767..35ba259f8 100644
--- a/spec/models/better_together/geography/settlement_spec.rb
+++ b/spec/models/better_together/geography/settlement_spec.rb
@@ -3,9 +3,112 @@
require 'rails_helper'
module BetterTogether
- RSpec.describe ::BetterTogether::Geography::Settlement do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+ RSpec.describe Geography::Settlement do
+ subject(:settlement) { build(:better_together_geography_settlement) }
+
+ describe 'concerns' do
+ it 'includes Geospatial::One' do
+ expect(described_class.ancestors).to include(BetterTogether::Geography::Geospatial::One)
+ end
+
+ it 'includes Identifier' do
+ expect(described_class.ancestors).to include(BetterTogether::Identifier)
+ end
+
+ it 'includes Protected' do
+ expect(described_class.ancestors).to include(BetterTogether::Protected)
+ end
+
+ it 'includes PrimaryCommunity' do
+ expect(described_class.ancestors).to include(BetterTogether::PrimaryCommunity)
+ end
+ end
+
+ describe 'database' do
+ it { is_expected.to have_db_column(:id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:identifier).of_type(:string) }
+ it { is_expected.to have_db_column(:protected).of_type(:boolean) }
+ it { is_expected.to have_db_column(:community_id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:country_id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:state_id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:lock_version).of_type(:integer) }
+ it { is_expected.to have_db_column(:created_at).of_type(:datetime) }
+ it { is_expected.to have_db_column(:updated_at).of_type(:datetime) }
+ end
+
+ describe 'associations' do
+ subject(:settlement) { build(:better_together_geography_settlement) }
+
+ it { is_expected.to belong_to(:community).class_name('BetterTogether::Community') }
+ it { is_expected.to belong_to(:country).class_name('BetterTogether::Geography::Country').optional }
+ it { is_expected.to belong_to(:state).class_name('BetterTogether::Geography::State').optional }
+
+ it do
+ expect(settlement).to have_many(:region_settlements)
+ .class_name('BetterTogether::Geography::RegionSettlement')
+ end
+
+ it do
+ expect(settlement).to have_many(:regions)
+ .through(:region_settlements)
+ .source(:region)
+ end
+ end
+
+ describe 'translations' do
+ it 'translates name' do
+ settlement.name = 'San Francisco'
+ Mobility.with_locale(:es) do
+ settlement.name = 'San Francisco'
+ end
+ expect(settlement.name).to eq('San Francisco')
+ Mobility.with_locale(:es) do
+ expect(settlement.name).to eq('San Francisco')
+ end
+ end
+
+ it 'translates description' do
+ settlement.description = 'A coastal city'
+ Mobility.with_locale(:es) do
+ settlement.description = 'Una ciudad costera'
+ end
+ expect(settlement.description).to eq('A coastal city')
+ Mobility.with_locale(:es) do
+ expect(settlement.description).to eq('Una ciudad costera')
+ end
+ end
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:name) }
+
+ it 'validates identifier uniqueness case-insensitively' do
+ create(:better_together_geography_settlement, identifier: 'test-settlement')
+ duplicate = build(:better_together_geography_settlement, identifier: 'TEST-SETTLEMENT')
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:identifier]).to include('has already been taken')
+ end
+ end
+
+ describe '#to_s' do
+ it 'returns the settlement name' do
+ settlement.name = 'Los Angeles'
+ expect(settlement.to_s).to eq('Los Angeles')
+ end
+ end
+
+ describe 'optional associations' do
+ it 'can be created without a country' do
+ settlement.country = nil
+ settlement.save!
+ expect(settlement).to be_persisted
+ end
+
+ it 'can be created without a state' do
+ settlement.state = nil
+ settlement.save!
+ expect(settlement).to be_persisted
+ end
end
end
end
diff --git a/spec/models/better_together/geography/state_spec.rb b/spec/models/better_together/geography/state_spec.rb
index 6ceb19328..27603470a 100644
--- a/spec/models/better_together/geography/state_spec.rb
+++ b/spec/models/better_together/geography/state_spec.rb
@@ -3,9 +3,114 @@
require 'rails_helper'
module BetterTogether
- RSpec.describe ::BetterTogether::Geography::State do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+ RSpec.describe Geography::State do
+ subject(:state) { build(:better_together_geography_state) }
+
+ describe 'concerns' do
+ it 'includes Geospatial::One' do
+ expect(described_class.ancestors).to include(BetterTogether::Geography::Geospatial::One)
+ end
+
+ it 'includes Identifier' do
+ expect(described_class.ancestors).to include(BetterTogether::Identifier)
+ end
+
+ it 'includes Protected' do
+ expect(described_class.ancestors).to include(BetterTogether::Protected)
+ end
+
+ it 'includes PrimaryCommunity' do
+ expect(described_class.ancestors).to include(BetterTogether::PrimaryCommunity)
+ end
+ end
+
+ describe 'database' do
+ it { is_expected.to have_db_column(:id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:identifier).of_type(:string) }
+ it { is_expected.to have_db_column(:protected).of_type(:boolean) }
+ it { is_expected.to have_db_column(:community_id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:country_id).of_type(:uuid) }
+ it { is_expected.to have_db_column(:lock_version).of_type(:integer) }
+ it { is_expected.to have_db_column(:created_at).of_type(:datetime) }
+ it { is_expected.to have_db_column(:updated_at).of_type(:datetime) }
+ end
+
+ describe 'associations' do
+ subject(:state) { build(:better_together_geography_state) }
+
+ it { is_expected.to belong_to(:community).class_name('BetterTogether::Community') }
+ it { is_expected.to belong_to(:country).class_name('BetterTogether::Geography::Country') }
+
+ it do
+ expect(state).to have_many(:regions)
+ .class_name('BetterTogether::Geography::Region')
+ end
+
+ it do
+ expect(state).to have_many(:settlements)
+ .class_name('BetterTogether::Geography::Settlement')
+ end
+ end
+
+ describe 'translations' do
+ it 'translates name' do
+ state.name = 'California'
+ Mobility.with_locale(:es) do
+ state.name = 'California'
+ end
+ expect(state.name).to eq('California')
+ Mobility.with_locale(:es) do
+ expect(state.name).to eq('California')
+ end
+ end
+
+ it 'translates description' do
+ state.description = 'A western state'
+ Mobility.with_locale(:es) do
+ state.description = 'Un estado occidental'
+ end
+ expect(state.description).to eq('A western state')
+ Mobility.with_locale(:es) do
+ expect(state.description).to eq('Un estado occidental')
+ end
+ end
+
+ it 'translates slug' do
+ state.identifier = 'california'
+ state.save!
+ expect(state.slug).to eq('california')
+ Mobility.with_locale(:es) do
+ state.slug = 'california-es'
+ state.save!
+ expect(state.slug).to eq('california-es')
+ end
+ end
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:name) }
+
+ it 'validates identifier uniqueness case-insensitively' do
+ create(:better_together_geography_state, identifier: 'test-state')
+ duplicate = build(:better_together_geography_state, identifier: 'TEST-STATE')
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:identifier]).to include('has already been taken')
+ end
+ end
+
+ describe '#to_s' do
+ it 'returns the state name' do
+ state.name = 'Texas'
+ expect(state.to_s).to eq('Texas')
+ end
+ end
+
+ describe 'identifier generation' do
+ it 'generates identifier from slug if not provided' do
+ state.name = 'New York'
+ state.save!
+ expect(state.identifier).to be_present
+ end
end
end
end
diff --git a/spec/models/better_together/jwt_denylist_spec.rb b/spec/models/better_together/jwt_denylist_spec.rb
index c1e4fea92..cf42c3c95 100644
--- a/spec/models/better_together/jwt_denylist_spec.rb
+++ b/spec/models/better_together/jwt_denylist_spec.rb
@@ -4,8 +4,90 @@
module BetterTogether
RSpec.describe JwtDenylist do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+ describe 'factory' do
+ it 'creates a valid jwt denylist entry' do
+ entry = build(:jwt_denylist)
+ expect(entry).to be_valid
+ end
+
+ it 'creates entries with different expiration times' do
+ %i[expired recently_expired expires_soon long_lived].each do |trait|
+ entry = build(:jwt_denylist, trait)
+ expect(entry).to be_valid
+ end
+ end
+ end
+
+ describe 'devise jwt integration' do
+ it 'includes Devise::JWT::RevocationStrategies::Denylist' do
+ expect(described_class.included_modules).to include(Devise::JWT::RevocationStrategies::Denylist)
+ end
+
+ it 'has correct table name' do
+ expect(described_class.table_name).to eq('better_together_jwt_denylists')
+ end
+ end
+
+ describe 'database schema' do
+ it 'has jti column' do
+ entry = create(:jwt_denylist)
+ expect(entry).to respond_to(:jti)
+ expect(entry.jti).to be_present
+ end
+
+ it 'has exp column' do
+ entry = create(:jwt_denylist)
+ expect(entry).to respond_to(:exp)
+ expect(entry.exp).to be_present
+ end
+
+ it 'has standard bt_table columns' do
+ entry = create(:jwt_denylist)
+ expect(entry).to respond_to(:id)
+ expect(entry).to respond_to(:lock_version)
+ expect(entry).to respond_to(:created_at)
+ expect(entry).to respond_to(:updated_at)
+ end
+ end
+
+ describe 'token revocation' do
+ it 'stores unique jti values' do
+ jti1 = SecureRandom.uuid
+ jti2 = SecureRandom.uuid
+
+ entry1 = create(:jwt_denylist, jti: jti1)
+ entry2 = create(:jwt_denylist, jti: jti2)
+
+ expect(entry1.jti).not_to eq(entry2.jti)
+ end
+
+ it 'stores expiration time' do
+ exp_time = 2.hours.from_now
+ entry = create(:jwt_denylist, exp: exp_time)
+
+ expect(entry.exp).to be_within(1.second).of(exp_time)
+ end
+
+ it 'can store expired tokens' do
+ entry = create(:jwt_denylist, :expired)
+
+ expect(entry.exp).to be < Time.current
+ end
+ end
+
+ describe 'queries' do
+ it 'can find entries by jti' do
+ jti = SecureRandom.uuid
+ entry = create(:jwt_denylist, jti: jti)
+
+ found = described_class.find_by(jti: jti)
+ expect(found).to eq(entry)
+ end
+
+ it 'returns nil for non-existent jti' do
+ found = described_class.find_by(jti: 'non-existent-jti')
+ expect(found).to be_nil
+ end
end
end
end
diff --git a/spec/models/better_together/message_spec.rb b/spec/models/better_together/message_spec.rb
index 3e719c927..34ffc8686 100644
--- a/spec/models/better_together/message_spec.rb
+++ b/spec/models/better_together/message_spec.rb
@@ -2,10 +2,58 @@
require 'rails_helper'
-module BetterTogether
- RSpec.describe Message do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+RSpec.describe BetterTogether::Message do
+ describe 'Factory' do
+ it 'has a valid factory' do
+ message = build(:message)
+ expect(message).to be_valid
+ end
+
+ it 'creates a message with content' do
+ message = build(:message, content: 'Test message')
+ message.save!(validate: false) # Skip validation to avoid factory callback issues initially
+ expect(message.reload.content.to_plain_text).to include('Test message')
+ end
+ end
+
+ describe 'Associations' do
+ it { is_expected.to belong_to(:conversation).touch(true) }
+ it { is_expected.to belong_to(:sender).class_name('BetterTogether::Person') }
+ end
+
+ describe 'Validations' do
+ it 'requires content' do
+ message = build(:message, content: nil)
+ expect(message).not_to be_valid
+ expect(message.errors[:content]).to include("can't be blank")
+ end
+
+ it 'accepts valid content' do
+ message = build(:message, content: 'Valid message')
+ expect(message).to be_valid
+ end
+ end
+
+ describe 'Action Text Integration' do
+ it 'has rich text content' do
+ message = build(:message, content: 'Rich text')
+ message.save!(validate: false)
+ expect(message.content).to be_a(ActionText::RichText)
+ end
+
+ it 'converts content to plain text' do
+ message = build(:message, content: 'Rich text
')
+ message.save!(validate: false)
+ plain_text = message.reload.content.to_plain_text.strip
+ expect(plain_text).to eq('Rich text')
+ end
+ end
+
+ describe 'Class Methods' do
+ describe '.permitted_attributes' do
+ it 'returns an array with expected attributes' do
+ expect(described_class.permitted_attributes).to match_array(%i[id content _destroy])
+ end
end
end
end
diff --git a/spec/models/better_together/metrics/download_spec.rb b/spec/models/better_together/metrics/download_spec.rb
index 507f7b3c9..25518e3b2 100644
--- a/spec/models/better_together/metrics/download_spec.rb
+++ b/spec/models/better_together/metrics/download_spec.rb
@@ -4,8 +4,91 @@
module BetterTogether
RSpec.describe Metrics::Download do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+ describe 'factory' do
+ it 'creates a valid download' do
+ community = create(:community)
+ download = create(:metrics_download,
+ downloadable: community,
+ file_name: 'report.pdf',
+ file_type: 'application/pdf',
+ file_size: 2048,
+ downloaded_at: Time.current,
+ locale: 'en')
+ expect(download).to be_valid
+ expect(download.file_name).to eq('report.pdf')
+ end
+ end
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:downloadable) }
+ end
+
+ describe 'validations' do
+ describe 'file_name' do
+ it 'requires file_name to be present' do
+ download = build(:metrics_download, file_name: nil)
+ expect(download).not_to be_valid
+ expect(download.errors[:file_name]).to include("can't be blank")
+ end
+ end
+
+ describe 'file_type' do
+ it 'requires file_type to be present' do
+ download = build(:metrics_download, file_type: nil)
+ expect(download).not_to be_valid
+ expect(download.errors[:file_type]).to include("can't be blank")
+ end
+ end
+
+ describe 'file_size' do
+ it 'requires file_size to be present' do
+ download = build(:metrics_download, file_size: nil)
+ expect(download).not_to be_valid
+ expect(download.errors[:file_size]).to include("can't be blank")
+ end
+ end
+
+ describe 'downloaded_at' do
+ it 'requires downloaded_at to be present' do
+ download = build(:metrics_download, downloaded_at: nil)
+ expect(download).not_to be_valid
+ expect(download.errors[:downloaded_at]).to include("can't be blank")
+ end
+ end
+
+ describe 'locale' do
+ it 'requires locale to be present' do
+ download = build(:metrics_download, locale: nil)
+ expect(download).not_to be_valid
+ expect(download.errors[:locale]).to include("can't be blank")
+ end
+
+ it 'validates locale is in available locales' do
+ download = build(:metrics_download, locale: 'invalid')
+ expect(download).not_to be_valid
+ expect(download.errors[:locale]).to include('is not included in the list')
+ end
+
+ it 'accepts valid locales' do
+ I18n.available_locales.each do |locale|
+ download = build(:metrics_download, locale: locale.to_s)
+ expect(download).to be_valid, "Expected #{locale} to be valid"
+ end
+ end
+ end
+ end
+
+ describe 'file tracking' do
+ it 'tracks file metadata' do
+ download = create(:metrics_download,
+ file_name: 'annual_report.pdf',
+ file_type: 'application/pdf',
+ file_size: 4096)
+
+ expect(download.file_name).to eq('annual_report.pdf')
+ expect(download.file_type).to eq('application/pdf')
+ expect(download.file_size).to eq(4096)
+ end
end
end
end
diff --git a/spec/models/better_together/metrics/link_click_report_spec.rb b/spec/models/better_together/metrics/link_click_report_spec.rb
index c591a7b2c..d4333b46f 100644
--- a/spec/models/better_together/metrics/link_click_report_spec.rb
+++ b/spec/models/better_together/metrics/link_click_report_spec.rb
@@ -4,8 +4,57 @@
module BetterTogether
RSpec.describe Metrics::LinkClickReport do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+ describe 'factory' do
+ it 'creates a valid link click report' do
+ report = build(:metrics_link_click_report, file_format: 'csv')
+ expect(report).to be_valid
+ expect(report.file_format).to eq('csv')
+ end
+ end
+
+ describe 'Active Storage attachments' do
+ it 'has one attached report_file' do
+ report = build(:metrics_link_click_report)
+ expect(report).to respond_to(:report_file)
+ end
+ end
+
+ describe 'validations' do
+ describe 'file_format' do
+ it 'requires file_format to be present' do
+ report = build(:metrics_link_click_report, file_format: nil)
+ expect(report).not_to be_valid
+ expect(report.errors[:file_format]).to include("can't be blank")
+ end
+ end
+ end
+
+ describe 'attributes' do
+ it 'has filters as jsonb with default empty hash' do
+ report = build(:metrics_link_click_report)
+ expect(report.filters).to eq({})
+ end
+
+ it 'accepts custom filters' do
+ filters = { 'from_date' => '2025-01-01', 'to_date' => '2025-12-31' }
+ report = build(:metrics_link_click_report, filters: filters)
+ expect(report.filters).to eq(filters)
+ end
+ end
+
+ describe 'callbacks' do
+ it 'responds to generate_report!' do
+ report = build(:metrics_link_click_report)
+ expect(report).to respond_to(:generate_report!)
+ end
+ end
+
+ describe 'report generation' do
+ it 'generates report data before creation' do
+ report = build(:metrics_link_click_report)
+ expect(report).to receive(:generate_report!)
+ report.save
+ end
end
end
end
diff --git a/spec/models/better_together/metrics/page_view_report_spec.rb b/spec/models/better_together/metrics/page_view_report_spec.rb
index 7076e0edc..72f08b302 100644
--- a/spec/models/better_together/metrics/page_view_report_spec.rb
+++ b/spec/models/better_together/metrics/page_view_report_spec.rb
@@ -4,8 +4,62 @@
module BetterTogether
RSpec.describe Metrics::PageViewReport do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+ describe 'factory' do
+ it 'creates a valid page view report' do
+ report = build(:metrics_page_view_report, file_format: 'csv')
+ expect(report).to be_valid
+ expect(report.file_format).to eq('csv')
+ end
+ end
+
+ describe 'Active Storage attachments' do
+ it 'has one attached report_file' do
+ report = build(:metrics_page_view_report)
+ expect(report).to respond_to(:report_file)
+ end
+ end
+
+ describe 'validations' do
+ describe 'file_format' do
+ it 'requires file_format to be present' do
+ report = build(:metrics_page_view_report, file_format: nil)
+ expect(report).not_to be_valid
+ expect(report.errors[:file_format]).to include("can't be blank")
+ end
+ end
+ end
+
+ describe 'attributes' do
+ it 'has filters as jsonb with default empty hash' do
+ report = build(:metrics_page_view_report)
+ expect(report.filters).to eq({})
+ end
+
+ it 'accepts custom filters' do
+ filters = { 'from_date' => '2025-01-01', 'to_date' => '2025-12-31', 'filter_pageable_type' => 'Community' }
+ report = build(:metrics_page_view_report, filters: filters)
+ expect(report.filters).to eq(filters)
+ end
+
+ it 'has sort_by_total_views attribute' do
+ report = build(:metrics_page_view_report, sort_by_total_views: true)
+ expect(report.sort_by_total_views).to be true
+ end
+ end
+
+ describe 'callbacks' do
+ it 'responds to generate_report!' do
+ report = build(:metrics_page_view_report)
+ expect(report).to respond_to(:generate_report!)
+ end
+ end
+
+ describe 'report generation' do
+ it 'generates report data before creation' do
+ report = build(:metrics_page_view_report)
+ expect(report).to receive(:generate_report!)
+ report.save
+ end
end
end
end
diff --git a/spec/models/better_together/metrics/share_spec.rb b/spec/models/better_together/metrics/share_spec.rb
index 9ab90154e..c08cd83f4 100644
--- a/spec/models/better_together/metrics/share_spec.rb
+++ b/spec/models/better_together/metrics/share_spec.rb
@@ -4,8 +4,106 @@
module BetterTogether
RSpec.describe Metrics::Share do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+ describe 'factory' do
+ it 'creates a valid share' do
+ community = create(:community)
+ share = create(:metrics_share,
+ shareable: community,
+ platform: 'facebook',
+ url: 'https://facebook.com/share',
+ shared_at: Time.current,
+ locale: 'en')
+ expect(share).to be_valid
+ expect(share.platform).to eq('facebook')
+ end
+ end
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:shareable).optional }
+ end
+
+ describe 'validations' do
+ describe 'platform' do
+ it 'requires platform to be present' do
+ share = build(:metrics_share, platform: nil)
+ expect(share).not_to be_valid
+ expect(share.errors[:platform]).to include("can't be blank")
+ end
+
+ it 'validates platform is in allowed list' do
+ share = build(:metrics_share, platform: 'invalid_platform')
+ expect(share).not_to be_valid
+ expect(share.errors[:platform]).to include('is not included in the list')
+ end
+
+ it 'accepts valid platforms' do
+ valid_platforms = %w[facebook bluesky linkedin pinterest reddit whatsapp]
+
+ valid_platforms.each do |platform|
+ share = build(:metrics_share, platform: platform)
+ expect(share).to be_valid, "Expected #{platform} to be valid"
+ end
+ end
+ end
+
+ describe 'url' do
+ it 'requires url to be present' do
+ share = build(:metrics_share, url: nil)
+ expect(share).not_to be_valid
+ expect(share.errors[:url]).to include("can't be blank")
+ end
+
+ it 'validates url format' do
+ share = build(:metrics_share, url: 'not-a-url')
+ expect(share).not_to be_valid
+ expect(share.errors[:url]).to include('is invalid')
+ end
+
+ it 'accepts valid HTTP URLs' do
+ share = build(:metrics_share, url: 'http://example.com/share')
+ expect(share).to be_valid
+ end
+
+ it 'accepts valid HTTPS URLs' do
+ share = build(:metrics_share, url: 'https://example.com/share')
+ expect(share).to be_valid
+ end
+ end
+
+ describe 'shared_at' do
+ it 'requires shared_at to be present' do
+ share = build(:metrics_share, shared_at: nil)
+ expect(share).not_to be_valid
+ expect(share.errors[:shared_at]).to include("can't be blank")
+ end
+ end
+
+ describe 'locale' do
+ it 'requires locale to be present' do
+ share = build(:metrics_share, locale: nil)
+ expect(share).not_to be_valid
+ expect(share.errors[:locale]).to include("can't be blank")
+ end
+
+ it 'validates locale is in available locales' do
+ share = build(:metrics_share, locale: 'invalid')
+ expect(share).not_to be_valid
+ expect(share.errors[:locale]).to include('is not included in the list')
+ end
+
+ it 'accepts valid locales' do
+ I18n.available_locales.each do |locale|
+ share = build(:metrics_share, locale: locale.to_s)
+ expect(share).to be_valid, "Expected #{locale} to be valid"
+ end
+ end
+ end
+ end
+
+ describe 'constants' do
+ it 'defines SHAREABLE_PLATFORMS' do
+ expect(described_class::SHAREABLE_PLATFORMS).to eq(%w[facebook bluesky linkedin pinterest reddit whatsapp])
+ end
end
end
end
diff --git a/spec/models/better_together/page_spec.rb b/spec/models/better_together/page_spec.rb
index 37ccc9c12..2c55689dc 100644
--- a/spec/models/better_together/page_spec.rb
+++ b/spec/models/better_together/page_spec.rb
@@ -4,7 +4,7 @@
require 'rails_helper'
-module BetterTogether
+module BetterTogether # rubocop:todo Metrics/ModuleLength
RSpec.describe Page do
subject(:page) { build(:better_together_page) }
@@ -80,6 +80,244 @@ module BetterTogether
expect(page.url).to eq("#{::BetterTogether.base_url_with_locale}/#{page.slug}")
end
end
+
+ describe '#as_indexed_json' do
+ context 'with template blocks' do
+ let(:page) do
+ create(:better_together_page,
+ title: 'Template Block Page',
+ slug: 'template-block-page',
+ privacy: 'public',
+ page_blocks_attributes: [
+ {
+ block_attributes: {
+ type: 'BetterTogether::Content::Template',
+ template_path: 'better_together/static_pages/privacy'
+ }
+ }
+ ])
+ end
+
+ it 'includes template_blocks in indexed data' do
+ result = page.as_indexed_json
+
+ expect(result['template_blocks']).to be_present
+ expect(result['template_blocks']).to be_an(Array)
+ end
+
+ it 'includes indexed_localized_content for each template block' do
+ result = page.as_indexed_json
+
+ template_block = result['template_blocks'].first
+ expect(template_block['indexed_localized_content']).to be_present
+ expect(template_block['indexed_localized_content']).to be_a(Hash)
+ end
+
+ it 'includes content for all locales in template blocks' do
+ result = page.as_indexed_json
+
+ content = result['template_blocks'].first['indexed_localized_content']
+ expect(content.keys.map(&:to_sym)).to match_array(I18n.available_locales)
+ end
+
+ it 'includes template block id' do
+ result = page.as_indexed_json
+
+ template_block = result['template_blocks'].first
+ expect(template_block['id']).to be_present
+ end
+ end
+
+ context 'with template attribute' do
+ let(:page) do
+ create(:better_together_page,
+ title: 'Template Attribute Page',
+ slug: 'template-attribute-page',
+ privacy: 'public',
+ template: 'better_together/static_pages/privacy')
+ end
+
+ it 'includes template_content in indexed data' do
+ result = page.as_indexed_json
+
+ expect(result['template_content']).to be_present
+ expect(result['template_content']).to be_a(Hash)
+ end
+
+ it 'renders template content for all locales' do
+ result = page.as_indexed_json
+
+ content = result['template_content']
+ expect(content.keys.map(&:to_sym)).to match_array(I18n.available_locales)
+ end
+
+ it 'includes plain text content without HTML' do
+ result = page.as_indexed_json
+
+ I18n.available_locales.each do |locale|
+ expect(result['template_content'][locale.to_s]).not_to match(/<[^>]+>/)
+ end
+ end
+
+ it 'uses TemplateRendererService for rendering' do
+ expect(BetterTogether::TemplateRendererService).to receive(:new)
+ .with(page.template)
+ .and_call_original
+
+ page.as_indexed_json
+ end
+ end
+
+ context 'with rich text blocks' do
+ let(:page) do
+ create(:better_together_page,
+ title: 'Rich Text Page',
+ slug: 'rich-text-page',
+ privacy: 'public',
+ page_blocks_attributes: [
+ {
+ block_attributes: {
+ type: 'BetterTogether::Content::RichText',
+ content: 'Test content'
+ }
+ }
+ ])
+ end
+
+ it 'includes rich_text_blocks in indexed data' do
+ result = page.as_indexed_json
+
+ expect(result['rich_text_blocks']).to be_present
+ expect(result['rich_text_blocks']).to be_an(Array)
+ end
+ end
+
+ context 'with markdown blocks' do
+ let(:page) do
+ create(
+ :better_together_page,
+ title: 'Markdown Index Page',
+ slug: 'markdown-index-page',
+ privacy: 'public',
+ page_blocks_attributes: [
+ {
+ block_attributes: {
+ type: 'BetterTogether::Content::Markdown',
+ markdown_source: "# Heading\n\nSearchable paragraph with **formatting**."
+ }
+ }
+ ]
+ )
+ end
+
+ it 'indexes plain text from inline markdown content' do
+ result = page.as_indexed_json
+ markdown_block = result['markdown_blocks'].first['as_indexed_json']
+ localized_content = markdown_block[:localized_content] || markdown_block['localized_content']
+ localized_value = localized_content[I18n.default_locale] || localized_content[I18n.default_locale.to_s]
+
+ expect(localized_value).to include('Heading')
+ expect(localized_value).to include('Searchable paragraph with formatting.')
+ expect(localized_value).not_to include('#')
+ expect(localized_value).not_to include('')
+ end
+ end
+
+ context 'with file-based markdown blocks' do
+ let(:markdown_file_path) { Rails.root.join('spec/fixtures/files/page_markdown_index.md') }
+ let(:page) do
+ FileUtils.mkdir_p(markdown_file_path.dirname)
+ File.write(markdown_file_path, "# File Search\n\nFile body content that should be indexed.")
+
+ create(
+ :better_together_page,
+ title: 'Markdown File Index Page',
+ slug: 'markdown-file-index-page',
+ privacy: 'public',
+ page_blocks_attributes: [
+ {
+ block_attributes: {
+ type: 'BetterTogether::Content::Markdown',
+ markdown_source: nil,
+ markdown_file_path: markdown_file_path.to_s
+ }
+ }
+ ]
+ )
+ end
+
+ after do
+ FileUtils.rm_f(markdown_file_path)
+ end
+
+ it 'indexes plain text extracted from markdown files' do
+ result = page.as_indexed_json
+ markdown_block = result['markdown_blocks'].first['as_indexed_json']
+ localized_content = markdown_block[:localized_content] || markdown_block['localized_content']
+ localized_value = localized_content[I18n.default_locale] || localized_content[I18n.default_locale.to_s]
+
+ expect(localized_value).to include('File Search')
+ expect(localized_value).to include('File body content that should be indexed.')
+ expect(localized_value).not_to include('#')
+ end
+ end
+
+ context 'without template blocks or attribute' do
+ let(:page) do
+ create(:better_together_page,
+ title: 'Simple Page',
+ slug: 'simple-page',
+ privacy: 'public')
+ end
+
+ it 'does not include template_content' do
+ result = page.as_indexed_json
+
+ expect(result['template_content']).to be_nil
+ end
+
+ it 'includes basic page attributes' do
+ result = page.as_indexed_json
+
+ expect(result['id']).to eq(page.id)
+ expect(result['title']).to eq(page.title)
+ expect(result['slug']).to eq(page.slug)
+ end
+ end
+
+ context 'with both template blocks and template attribute' do
+ let(:page) do
+ create(:better_together_page,
+ title: 'Mixed Template Page',
+ slug: 'mixed-template-page',
+ privacy: 'public',
+ template: 'better_together/static_pages/terms_of_service',
+ page_blocks_attributes: [
+ {
+ block_attributes: {
+ type: 'BetterTogether::Content::Template',
+ template_path: 'better_together/static_pages/privacy'
+ }
+ }
+ ])
+ end
+
+ it 'includes both template_blocks and template_content' do
+ result = page.as_indexed_json
+
+ expect(result['template_blocks']).to be_present
+ expect(result['template_content']).to be_present
+ end
+
+ it 'renders different content for each' do
+ result = page.as_indexed_json
+
+ # Both should be present (either as Hash with string keys or symbolized)
+ expect(result['template_blocks'] || result[:template_blocks]).to be_present
+ expect(result['template_content'] || result[:template_content]).to be_present
+ end
+ end
+ end
end
end
end
diff --git a/spec/models/better_together/phone_number_spec.rb b/spec/models/better_together/phone_number_spec.rb
index 1808ff967..9507c53a2 100644
--- a/spec/models/better_together/phone_number_spec.rb
+++ b/spec/models/better_together/phone_number_spec.rb
@@ -2,10 +2,164 @@
require 'rails_helper'
-module BetterTogether
- RSpec.describe PhoneNumber do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+RSpec.describe BetterTogether::PhoneNumber do
+ describe 'factory' do
+ it 'creates a valid phone number' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+ phone_number = described_class.create!(
+ contact_detail: contact_detail,
+ number: '+1-555-123-4567',
+ label: 'mobile',
+ primary_flag: true
+ )
+ expect(phone_number).to be_valid
+ end
+ end
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:contact_detail).class_name('BetterTogether::ContactDetail').touch(true) }
+ end
+
+ describe 'validations' do
+ describe 'number presence' do
+ it 'requires number to be present' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+ phone_number = described_class.new(
+ contact_detail: contact_detail,
+ number: nil,
+ primary_flag: true
+ )
+ expect(phone_number).not_to be_valid
+ expect(phone_number.errors[:number]).to include("can't be blank")
+ end
+ end
+
+ describe 'number format' do
+ it 'accepts various phone number formats' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+ valid_numbers = [
+ '+1-555-123-4567',
+ '555-123-4567',
+ '(555) 123-4567',
+ '555.123.4567',
+ '5551234567',
+ '+44 20 7946 0958'
+ ]
+
+ valid_numbers.each_with_index do |number, index|
+ phone = described_class.create!(
+ contact_detail: contact_detail,
+ number: number,
+ label: 'mobile',
+ primary_flag: (index == 0) # Only first one is primary
+ )
+ expect(phone).to be_valid
+ end
+ end
+ end
+ end
+
+ describe 'PrimaryFlag concern' do
+ it 'includes PrimaryFlag behavior' do
+ expect(described_class.included_modules).to include(BetterTogether::PrimaryFlag)
+ end
+
+ it 'allows setting primary_flag' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+ phone_number = described_class.create!(
+ contact_detail: contact_detail,
+ number: '+1-555-123-4567',
+ label: 'mobile',
+ primary_flag: true
+ )
+ expect(phone_number.primary_flag).to be true
+ end
+
+ it 'scopes primary flag by contact_detail_id' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+ phone1 = described_class.create!(
+ contact_detail: contact_detail,
+ number: '+1-555-111-1111',
+ label: 'mobile',
+ primary_flag: true
+ )
+ phone2 = described_class.create!(
+ contact_detail: contact_detail,
+ number: '+1-555-222-2222',
+ label: 'home',
+ primary_flag: false
+ )
+
+ expect(phone1.primary_flag).to be true
+ expect(phone2.primary_flag).to be false
+ end
+ end
+
+ describe 'Privacy concern' do
+ it 'includes Privacy behavior' do
+ expect(described_class.included_modules).to include(BetterTogether::Privacy)
+ end
+
+ it 'allows setting privacy level' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+ phone_number = described_class.create!(
+ contact_detail: contact_detail,
+ number: '+1-555-123-4567',
+ label: 'mobile',
+ primary_flag: true,
+ privacy: 'private'
+ )
+ expect(phone_number.privacy).to eq('private')
+ end
+ end
+
+ describe 'Labelable concern' do
+ it 'includes Labelable behavior' do
+ expect(described_class.included_modules).to include(BetterTogether::Labelable)
+ end
+
+ it 'accepts valid labels' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+
+ BetterTogether::PhoneNumber::LABELS.each_with_index do |label, index|
+ phone = described_class.create!(
+ contact_detail: contact_detail,
+ number: "+1-555-#{100 + index}-0000",
+ label: label.to_s,
+ primary_flag: (index == 0)
+ )
+ expect(phone).to be_valid
+ end
+ end
+
+ it 'defines expected label constants' do
+ expect(BetterTogether::PhoneNumber::LABELS).to include(:mobile, :home, :work, :fax, :other)
+ end
+ end
+
+ describe 'touch association' do
+ it 'touches contact_detail on update' do
+ person = create(:person)
+ contact_detail = create(:contact_detail, contactable: person)
+ phone_number = described_class.create!(
+ contact_detail: contact_detail,
+ number: '+1-555-123-4567',
+ label: 'mobile',
+ primary_flag: true
+ )
+
+ original_updated_at = contact_detail.updated_at
+ sleep 0.01
+ phone_number.update!(number: '+1-555-999-9999')
+
+ expect(contact_detail.reload.updated_at).to be > original_updated_at
end
end
end
diff --git a/spec/models/better_together/resource_permission_spec.rb b/spec/models/better_together/resource_permission_spec.rb
index cec6b8419..718a6658c 100644
--- a/spec/models/better_together/resource_permission_spec.rb
+++ b/spec/models/better_together/resource_permission_spec.rb
@@ -6,8 +6,106 @@
module BetterTogether
RSpec.describe ResourcePermission do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+ describe 'factory' do
+ it 'creates a valid resource permission' do
+ permission = build(:resource_permission, resource_type: 'BetterTogether::Community', position: 100)
+ expect(permission).to be_valid
+ end
+
+ it 'generates derived target from resource_type' do
+ permission = create(:resource_permission, resource_type: 'BetterTogether::Community', position: 101)
+ expected_target = 'community'
+ expect(permission.target).to eq(expected_target)
+ end
+ end
+
+ describe 'associations' do
+ it { is_expected.to have_many(:role_resource_permissions).class_name('BetterTogether::RoleResourcePermission').dependent(:destroy) }
+ it { is_expected.to have_many(:roles).through(:role_resource_permissions) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_inclusion_of(:action).in_array(described_class::ACTIONS) }
+
+ it 'validates position uniqueness scoped to resource_type' do
+ create(:resource_permission, resource_type: 'BetterTogether::Community', position: 200)
+ duplicate = build(:resource_permission, resource_type: 'BetterTogether::Community', position: 200)
+
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:position]).to be_present
+ end
+
+ it 'allows same position for different resource_types' do
+ create(:resource_permission, resource_type: 'BetterTogether::Community', position: 300)
+ different_type = build(:resource_permission, resource_type: 'BetterTogether::Platform', position: 300)
+
+ expect(different_type).to be_valid
+ end
+ end
+
+ describe 'concerns' do
+ it 'includes Identifier concern' do
+ expect(described_class.included_modules).to include(BetterTogether::Identifier)
+ end
+
+ it 'includes Positioned concern' do
+ expect(described_class.included_modules).to include(BetterTogether::Positioned)
+ end
+
+ it 'includes Protected concern' do
+ expect(described_class.included_modules).to include(BetterTogether::Protected)
+ end
+
+ it 'includes Resourceful concern' do
+ expect(described_class.included_modules).to include(BetterTogether::Resourceful)
+ end
+ end
+
+ describe 'actions constant' do
+ it 'defines ACTIONS constant' do
+ expect(described_class::ACTIONS).to eq(%w[create read update delete list manage view])
+ end
+
+ it 'accepts valid actions' do
+ permission = build(:resource_permission, action: 'create', resource_type: 'BetterTogether::Community', position: 400)
+ expect(permission).to be_valid
+ end
+
+ it 'rejects invalid actions' do
+ permission = build(:resource_permission, action: 'invalid_action', resource_type: 'BetterTogether::Community', position: 401)
+ expect(permission).not_to be_valid
+ expect(permission.errors[:action]).to be_present
+ end
+ end
+
+ describe '#position_scope' do
+ it 'returns resource_type as position scope' do
+ permission = build(:resource_permission, resource_type: 'BetterTogether::Community', position: 500)
+ expect(permission.position_scope).to eq(:resource_type)
+ end
+ end
+
+ describe '#to_s' do
+ it 'returns the identifier' do
+ permission = create(:resource_permission, resource_type: 'BetterTogether::Community', position: 600)
+ expect(permission.to_s).to eq(permission.identifier)
+ end
+ end
+
+ describe 'scopes' do
+ describe '.positioned' do
+ it 'orders by resource_type and position' do
+ # Use high position numbers to avoid conflicts with seed data
+ perm1 = create(:resource_permission, resource_type: 'BetterTogether::Platform', position: 1001)
+ perm2 = create(:resource_permission, resource_type: 'BetterTogether::Community', position: 1001)
+ perm3 = create(:resource_permission, resource_type: 'BetterTogether::Community', position: 1002)
+
+ positioned = described_class.where('position >= 1001').positioned
+
+ # Should order by resource_type first (alphabetically), then position
+ expect(positioned.to_a).to eq([perm2, perm3, perm1])
+ end
+ end
end
end
end
diff --git a/spec/models/better_together/social_media_account_spec.rb b/spec/models/better_together/social_media_account_spec.rb
index 99ecf9c5f..3a441b7b7 100644
--- a/spec/models/better_together/social_media_account_spec.rb
+++ b/spec/models/better_together/social_media_account_spec.rb
@@ -2,10 +2,169 @@
require 'rails_helper'
-module BetterTogether
+module BetterTogether # rubocop:disable Metrics/ModuleLength
RSpec.describe SocialMediaAccount do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+ describe 'factory' do
+ it 'creates a valid social media account' do
+ account = build(:social_media_account)
+ expect(account).to be_valid
+ end
+
+ it 'creates valid accounts for different platforms' do
+ %i[instagram linkedin youtube tiktok reddit].each do |platform_trait|
+ account = build(:social_media_account, platform_trait)
+ expect(account).to be_valid
+ end
+ end
+ end
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:contact_detail).class_name('BetterTogether::ContactDetail').touch(true) }
+ end
+
+ describe 'validations' do
+ subject { build(:social_media_account) }
+
+ it { is_expected.to validate_presence_of(:platform) }
+ it { is_expected.to validate_inclusion_of(:platform).in_array(described_class::PLATFORMS) }
+
+ it 'requires handle if url is not present' do
+ account = build(:social_media_account, handle: nil, url: nil)
+ expect(account).not_to be_valid
+ expect(account.errors[:handle]).to be_present
+ end
+
+ it 'allows missing handle if url is present' do
+ account = build(:social_media_account, handle: nil, url: 'https://example.com/profile')
+ expect(account).to be_valid
+ end
+
+ it 'validates url format when present' do
+ account = build(:social_media_account, url: 'not-a-valid-url')
+ expect(account).not_to be_valid
+ expect(account.errors[:url]).to be_present
+ end
+
+ it 'allows valid http url' do
+ account = build(:social_media_account, url: 'http://example.com/profile')
+ expect(account).to be_valid
+ end
+
+ it 'allows valid https url' do
+ account = build(:social_media_account, url: 'https://example.com/profile')
+ expect(account).to be_valid
+ end
+
+ it 'validates uniqueness of platform scoped to contact_detail' do
+ contact_detail = create(:contact_detail)
+ create(:social_media_account, contact_detail: contact_detail, platform: 'Facebook')
+ duplicate = build(:social_media_account, contact_detail: contact_detail, platform: 'Facebook')
+
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:platform]).to include('account already exists for this contact detail')
+ end
+
+ it 'allows same platform for different contact_details' do
+ contact1 = create(:contact_detail)
+ contact2 = create(:contact_detail)
+ create(:social_media_account, contact_detail: contact1, platform: 'Facebook')
+ account2 = build(:social_media_account, contact_detail: contact2, platform: 'Facebook')
+
+ expect(account2).to be_valid
+ end
+ end
+
+ describe 'url generation' do
+ it 'generates url from handle for Facebook' do
+ account = create(:social_media_account, platform: 'Facebook', handle: 'johndoe', url: nil)
+ expect(account.url).to eq('https://www.facebook.com/johndoe')
+ end
+
+ it 'generates url from handle for Instagram' do
+ account = create(:social_media_account, platform: 'Instagram', handle: 'johndoe', url: nil)
+ expect(account.url).to eq('https://www.instagram.com/johndoe')
+ end
+
+ it 'generates url from handle for LinkedIn' do
+ account = create(:social_media_account, platform: 'LinkedIn', handle: 'johndoe', url: nil)
+ expect(account.url).to eq('https://www.linkedin.com/in/johndoe')
+ end
+
+ it 'generates url from handle for YouTube' do
+ account = create(:social_media_account, platform: 'YouTube', handle: 'johndoe', url: nil)
+ expect(account.url).to eq('https://www.youtube.com/johndoe')
+ end
+
+ it 'generates url from handle for TikTok' do
+ account = create(:social_media_account, platform: 'TikTok', handle: 'johndoe', url: nil)
+ expect(account.url).to eq('https://www.tiktok.com/@johndoe')
+ end
+
+ it 'generates url from handle for Reddit' do
+ account = create(:social_media_account, platform: 'Reddit', handle: 'johndoe', url: nil)
+ expect(account.url).to eq('https://www.reddit.com/user/johndoe')
+ end
+
+ it 'does not override existing url' do
+ existing_url = 'https://custom.url/profile'
+ account = create(:social_media_account, platform: 'Facebook', handle: 'johndoe', url: existing_url)
+ expect(account.url).to eq(existing_url)
+ end
+
+ it 'regenerates url when handle changes and url is blank' do
+ account = create(:social_media_account, platform: 'Facebook', handle: 'johndoe', url: nil)
+ original_url = account.url
+
+ account.update(handle: 'janedoe', url: nil)
+ expect(account.url).to eq('https://www.facebook.com/janedoe')
+ expect(account.url).not_to eq(original_url)
+ end
+
+ it 'regenerates url when platform changes with handle present and url blank' do
+ account = create(:social_media_account, platform: 'Facebook', handle: 'johndoe', url: nil)
+ account.update(platform: 'Instagram', url: nil)
+ expect(account.url).to eq('https://www.instagram.com/johndoe')
+ end
+ end
+
+ describe 'handle sanitization' do
+ it 'removes leading @ from handle' do
+ account = create(:social_media_account, platform: 'Instagram', handle: '@johndoe', url: nil)
+ expect(account.url).to eq('https://www.instagram.com/johndoe')
+ end
+
+ it 'parameterizes handle' do
+ account = create(:social_media_account, platform: 'Facebook', handle: 'John Doe', url: nil)
+ expect(account.url).to eq('https://www.facebook.com/john-doe')
+ end
+
+ it 'strips whitespace from handle' do
+ account = create(:social_media_account, platform: 'Facebook', handle: ' johndoe ', url: nil)
+ expect(account.url).to eq('https://www.facebook.com/johndoe')
+ end
+ end
+
+ describe '#to_s' do
+ it 'returns platform and handle' do
+ account = build(:social_media_account, platform: 'Facebook', handle: 'johndoe')
+ expect(account.to_s).to eq('Facebook: johndoe')
+ end
+ end
+
+ describe 'privacy concern' do
+ it 'includes Privacy concern' do
+ expect(described_class.included_modules).to include(BetterTogether::Privacy)
+ end
+
+ it 'has default privacy level' do
+ account = create(:social_media_account)
+ expect(account.privacy).to eq('public')
+ end
+
+ it 'can be set to private' do
+ account = create(:social_media_account, :private)
+ expect(account.privacy).to eq('private')
+ end
end
end
end
diff --git a/spec/models/better_together/website_link_spec.rb b/spec/models/better_together/website_link_spec.rb
index 4ba159aa6..7e2693c6f 100644
--- a/spec/models/better_together/website_link_spec.rb
+++ b/spec/models/better_together/website_link_spec.rb
@@ -4,8 +4,108 @@
module BetterTogether
RSpec.describe WebsiteLink do
- it 'exists' do
- expect(described_class).to be # rubocop:todo RSpec/Be
+ describe 'factory' do
+ it 'creates a valid website link' do
+ link = build(:website_link)
+ expect(link).to be_valid
+ end
+
+ it 'creates valid links with different labels' do
+ %i[blog portfolio company_website community_page documentation].each do |label_trait|
+ link = build(:website_link, label_trait)
+ expect(link).to be_valid
+ end
+ end
+ end
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:contact_detail).class_name('BetterTogether::ContactDetail').touch(true) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:url) }
+
+ it 'validates url format with http' do
+ link = build(:website_link, url: 'http://example.com')
+ expect(link).to be_valid
+ end
+
+ it 'validates url format with https' do
+ link = build(:website_link, url: 'https://example.com')
+ expect(link).to be_valid
+ end
+
+ it 'rejects invalid url format' do
+ link = build(:website_link, url: 'not-a-valid-url')
+ expect(link).not_to be_valid
+ expect(link.errors[:url]).to be_present
+ end
+
+ it 'rejects url without protocol' do
+ link = build(:website_link, url: 'example.com')
+ expect(link).not_to be_valid
+ expect(link.errors[:url]).to be_present
+ end
+
+ it 'rejects ftp protocol' do
+ link = build(:website_link, url: 'ftp://example.com')
+ expect(link).not_to be_valid
+ expect(link.errors[:url]).to be_present
+ end
+ end
+
+ describe 'labelable concern' do
+ it 'includes Labelable concern' do
+ expect(described_class.included_modules).to include(BetterTogether::Labelable)
+ end
+
+ it 'defines LABELS constant' do
+ expect(described_class::LABELS).to be_a(Array)
+ expect(described_class::LABELS).not_to be_empty
+ end
+
+ it 'includes expected label types' do
+ expected_labels = %i[
+ personal_website blog portfolio resume company_website community_page
+ product_page services support contact_us about_us events donations careers
+ privacy_policy terms_of_service faq forum documentation newsletter other
+ ]
+ expect(described_class::LABELS).to eq(expected_labels)
+ end
+
+ it 'can be created with different label values' do
+ %i[blog portfolio company_website documentation].each do |label_value|
+ link = create(:website_link, label: label_value.to_s)
+ expect(link.label).to eq(label_value.to_s)
+ end
+ end
+ end
+
+ describe 'privacy concern' do
+ it 'includes Privacy concern' do
+ expect(described_class.included_modules).to include(BetterTogether::Privacy)
+ end
+
+ it 'has default privacy level' do
+ link = create(:website_link)
+ expect(link.privacy).to eq('public')
+ end
+
+ it 'can be set to private' do
+ link = create(:website_link, :private)
+ expect(link.privacy).to eq('private')
+ end
+ end
+
+ describe 'touch behavior' do
+ it 'touches contact_detail when updated' do
+ link = create(:website_link)
+ contact_detail = link.contact_detail
+
+ expect do
+ link.update(url: 'https://newurl.com')
+ end.to(change { contact_detail.reload.updated_at })
+ end
end
end
end
diff --git a/spec/policies/better_together/content/markdown_policy_spec.rb b/spec/policies/better_together/content/markdown_policy_spec.rb
new file mode 100644
index 000000000..02d0eab92
--- /dev/null
+++ b/spec/policies/better_together/content/markdown_policy_spec.rb
@@ -0,0 +1,174 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe BetterTogether::Content::MarkdownPolicy, type: :policy do
+ let(:manager_user) { create(:better_together_user, :platform_manager) }
+ let(:normal_user) { create(:better_together_user) }
+ let(:markdown_block) { create(:content_markdown) }
+
+ describe '#index?' do
+ subject { described_class.new(user, markdown_block).index? }
+
+ context 'when user is a platform manager' do
+ let(:user) { manager_user }
+
+ it { is_expected.to be true }
+ end
+
+ context 'when user is a normal user' do
+ let(:user) { normal_user }
+
+ it { is_expected.to be false }
+ end
+
+ context 'when user is not signed in' do
+ let(:user) { nil }
+
+ it { is_expected.to be false }
+ end
+ end
+
+ describe '#show?' do
+ subject { described_class.new(user, markdown_block).show? }
+
+ context 'when user is a platform manager' do
+ let(:user) { manager_user }
+
+ it { is_expected.to be true }
+ end
+
+ context 'when user is a normal user' do
+ let(:user) { normal_user }
+
+ it { is_expected.to be false }
+ end
+
+ context 'when user is not signed in' do
+ let(:user) { nil }
+
+ it { is_expected.to be false }
+ end
+ end
+
+ describe '#create?' do
+ subject { described_class.new(user, markdown_block).create? }
+
+ context 'when user is a platform manager' do
+ let(:user) { manager_user }
+
+ it { is_expected.to be true }
+ end
+
+ context 'when user is a normal user' do
+ let(:user) { normal_user }
+
+ it { is_expected.to be false }
+ end
+
+ context 'when user is not signed in' do
+ let(:user) { nil }
+
+ it { is_expected.to be false }
+ end
+ end
+
+ describe '#new?' do
+ subject { described_class.new(user, markdown_block).new? }
+
+ context 'when user is a platform manager' do
+ let(:user) { manager_user }
+
+ it { is_expected.to be true }
+ end
+
+ context 'when user is a normal user' do
+ let(:user) { normal_user }
+
+ it { is_expected.to be false }
+ end
+ end
+
+ describe '#update?' do
+ subject { described_class.new(user, markdown_block).update? }
+
+ context 'when user is a platform manager' do
+ let(:user) { manager_user }
+
+ it { is_expected.to be true }
+ end
+
+ context 'when user is a normal user' do
+ let(:user) { normal_user }
+
+ it { is_expected.to be false }
+ end
+
+ context 'when user is not signed in' do
+ let(:user) { nil }
+
+ it { is_expected.to be false }
+ end
+ end
+
+ describe '#edit?' do
+ subject { described_class.new(user, markdown_block).edit? }
+
+ context 'when user is a platform manager' do
+ let(:user) { manager_user }
+
+ it { is_expected.to be true }
+ end
+
+ context 'when user is a normal user' do
+ let(:user) { normal_user }
+
+ it { is_expected.to be false }
+ end
+ end
+
+ describe '#destroy?' do
+ subject { described_class.new(user, markdown_block).destroy? }
+
+ context 'when user is a platform manager' do
+ let(:user) { manager_user }
+
+ it { is_expected.to be true }
+ end
+
+ context 'when user is a normal user' do
+ let(:user) { normal_user }
+
+ it { is_expected.to be false }
+ end
+
+ context 'when user is not signed in' do
+ let(:user) { nil }
+
+ it { is_expected.to be false }
+ end
+ end
+
+ describe 'Scope' do
+ let!(:markdown_block1) { create(:content_markdown) } # rubocop:todo RSpec/IndexedLet
+ let!(:markdown_block2) { create(:content_markdown) } # rubocop:todo RSpec/IndexedLet
+
+ context 'when user is a platform manager' do
+ let(:user) { manager_user }
+
+ it 'returns all markdown blocks' do
+ scope = described_class::Scope.new(user, BetterTogether::Content::Markdown.all).resolve
+ expect(scope).to include(markdown_block1, markdown_block2)
+ end
+ end
+
+ context 'when user is a normal user' do
+ let(:user) { normal_user }
+
+ it 'returns all markdown blocks (filtering happens in policy methods)' do
+ scope = described_class::Scope.new(user, BetterTogether::Content::Markdown.all).resolve
+ expect(scope).to include(markdown_block1, markdown_block2)
+ end
+ end
+ end
+end
diff --git a/spec/requests/better_together/content_blocks_preview_markdown_spec.rb b/spec/requests/better_together/content_blocks_preview_markdown_spec.rb
new file mode 100644
index 000000000..d47c4b263
--- /dev/null
+++ b/spec/requests/better_together/content_blocks_preview_markdown_spec.rb
@@ -0,0 +1,198 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Content Blocks Markdown Preview', :as_user do
+ let(:markdown_content) { "# Hello World\n\nThis is a **test**." }
+ let(:preview_path) { "/#{I18n.default_locale}/#{BetterTogether.route_scope_path}/content/blocks/preview_markdown" }
+
+ describe 'POST /better_together/content/blocks/preview_markdown' do
+ context 'when markdown content is provided' do
+ it 'returns rendered HTML' do
+ post preview_path,
+ params: { markdown: markdown_content },
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(response.content_type).to match(%r{application/json})
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['html']).to include('Hello World
')
+ expect(json_response['html']).to include('test')
+ end
+
+ it 'renders markdown with code blocks' do
+ markdown_with_code = <<~MARKDOWN
+ # Code Example
+
+ ```ruby
+ def hello
+ puts "Hello, World!"
+ end
+ ```
+ MARKDOWN
+
+ post preview_path,
+ params: { markdown: markdown_with_code },
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ json_response = JSON.parse(response.body)
+ expect(json_response['html']).to include('strikethrough')
+ end
+
+ it 'renders task lists' do
+ markdown = <<~MARKDOWN
+ - [ ] Unchecked task
+ - [x] Checked task
+ MARKDOWN
+
+ post preview_path,
+ params: { markdown: markdown },
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ json_response = JSON.parse(response.body)
+ # The renderer doesn't support GitHub-flavored task lists, so it renders as a regular list
+ expect(json_response['html']).to include('')
+ expect(json_response['html']).to include('- ')
+ end
+
+ it 'renders blockquotes' do
+ markdown = '> This is a quote'
+
+ post preview_path,
+ params: { markdown: markdown },
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ json_response = JSON.parse(response.body)
+ expect(json_response['html']).to include('
')
+ end
+
+ it 'renders inline code' do
+ markdown = 'Use `code` in text'
+
+ post preview_path,
+ params: { markdown: markdown },
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ json_response = JSON.parse(response.body)
+ expect(json_response['html']).to include('code')
+ end
+ end
+ end
+end
diff --git a/spec/requests/better_together/metrics/reports_controller_spec.rb b/spec/requests/better_together/metrics/reports_controller_spec.rb
new file mode 100644
index 000000000..8fa0ffa6b
--- /dev/null
+++ b/spec/requests/better_together/metrics/reports_controller_spec.rb
@@ -0,0 +1,211 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+# rubocop:disable RSpec/IndexedLet
+module BetterTogether
+ module Metrics # rubocop:disable Metrics/ModuleLength
+ RSpec.describe ReportsController do
+ describe 'GET #index', :as_platform_manager do
+ let!(:page_view1) do
+ create(:metrics_page_view,
+ page_url: '/page1',
+ viewed_at: 2.days.ago)
+ end
+ let!(:page_view2) do
+ create(:metrics_page_view,
+ page_url: '/page1',
+ viewed_at: 1.day.ago)
+ end
+ let!(:page_view3) do
+ create(:metrics_page_view,
+ page_url: '/page2',
+ viewed_at: 1.day.ago)
+ end
+
+ let!(:link_click1) do
+ create(:metrics_link_click,
+ url: 'https://example.com',
+ page_url: '/page1',
+ internal: false,
+ clicked_at: 2.days.ago)
+ end
+ let!(:link_click2) do
+ create(:metrics_link_click,
+ url: 'https://internal.example.com/path',
+ page_url: '/page2',
+ internal: true,
+ clicked_at: 1.day.ago)
+ end
+
+ let!(:download) do
+ create(:metrics_download,
+ file_name: 'document.pdf')
+ end
+
+ let!(:share1) do
+ create(:metrics_share,
+ url: 'https://facebook.com/share/page1',
+ platform: 'facebook')
+ end
+ let!(:share2) do
+ create(:metrics_share,
+ url: 'https://bsky.app/share/page1',
+ platform: 'bluesky')
+ end
+ let!(:share3) do
+ create(:metrics_share,
+ url: 'https://facebook.com/share/page2',
+ platform: 'facebook')
+ end
+
+ let!(:valid_link) do
+ create(:content_link,
+ host: 'example.com',
+ valid_link: true,
+ last_checked_at: 1.day.ago)
+ end
+ let!(:invalid_link) do
+ create(:content_link,
+ host: 'broken.com',
+ valid_link: false,
+ last_checked_at: 1.day.ago)
+ end
+
+ before do
+ get better_together.metrics_reports_path(locale: I18n.default_locale)
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'assigns page views grouped by URL' do
+ expect(assigns(:page_views_by_url)).to be_present
+ expect(assigns(:page_views_by_url)['/page1']).to eq(2)
+ expect(assigns(:page_views_by_url)['/page2']).to eq(1)
+ end
+
+ it 'assigns page views grouped by day' do
+ expect(assigns(:page_views_daily)).to be_present
+ expect(assigns(:page_views_daily)).to be_a(Hash)
+ end
+
+ it 'assigns link clicks grouped by URL' do
+ expect(assigns(:link_clicks_by_url)).to be_present
+ expect(assigns(:link_clicks_by_url)['https://example.com']).to eq(1)
+ expect(assigns(:link_clicks_by_url)['https://internal.example.com/path']).to eq(1)
+ end
+
+ it 'assigns link clicks grouped by day' do
+ expect(assigns(:link_clicks_daily)).to be_present
+ expect(assigns(:link_clicks_daily)).to be_a(Hash)
+ end
+
+ it 'assigns internal vs external link clicks' do
+ expect(assigns(:internal_vs_external)).to be_present
+ expect(assigns(:internal_vs_external)[true]).to eq(1) # internal
+ expect(assigns(:internal_vs_external)[false]).to eq(1) # external
+ end
+
+ it 'assigns link clicks grouped by page' do
+ expect(assigns(:link_clicks_by_page)).to be_present
+ expect(assigns(:link_clicks_by_page)['/page1']).to eq(1)
+ expect(assigns(:link_clicks_by_page)['/page2']).to eq(1)
+ end
+
+ it 'assigns downloads grouped by file' do
+ expect(assigns(:downloads_by_file)).to be_present
+ expect(assigns(:downloads_by_file)['document.pdf']).to eq(1)
+ end
+
+ it 'assigns shares grouped by platform' do
+ expect(assigns(:shares_by_platform)).to be_present
+ expect(assigns(:shares_by_platform)['facebook']).to eq(2)
+ expect(assigns(:shares_by_platform)['bluesky']).to eq(1)
+ end
+
+ it 'assigns shares grouped by URL and platform' do
+ expect(assigns(:shares_by_url_and_platform)).to be_present
+ expect(assigns(:shares_by_url_and_platform)[['https://facebook.com/share/page1', 'facebook']]).to eq(1)
+ expect(assigns(:shares_by_url_and_platform)[['https://bsky.app/share/page1', 'bluesky']]).to eq(1)
+ expect(assigns(:shares_by_url_and_platform)[['https://facebook.com/share/page2', 'facebook']]).to eq(1)
+ end
+
+ it 'assigns shares data for Chart.js' do
+ expect(assigns(:shares_data)).to be_present
+ expect(assigns(:shares_data)[:labels]).to include('https://facebook.com/share/page1', 'https://facebook.com/share/page2')
+ expect(assigns(:shares_data)[:datasets]).to be_an(Array)
+ expect(assigns(:shares_data)[:datasets].map { |d| d[:label] }).to include('Facebook', 'Bluesky')
+ end
+
+ it 'assigns links grouped by host' do
+ expect(assigns(:links_by_host)).to be_present
+ expect(assigns(:links_by_host)['example.com']).to eq(1)
+ expect(assigns(:links_by_host)['broken.com']).to eq(1)
+ end
+
+ it 'assigns invalid links grouped by host' do
+ expect(assigns(:invalid_by_host)).to be_present
+ expect(assigns(:invalid_by_host)['broken.com']).to eq(1)
+ expect(assigns(:invalid_by_host)['example.com']).to be_nil
+ end
+
+ it 'assigns failures grouped by day' do
+ expect(assigns(:failures_daily)).to be_present
+ expect(assigns(:failures_daily)).to be_a(Hash)
+ end
+
+ context 'when testing Chart.js data structure' do
+ it 'generates proper dataset structure' do
+ datasets = assigns(:shares_data)[:datasets]
+ expect(datasets.first).to have_key(:label)
+ expect(datasets.first).to have_key(:backgroundColor)
+ expect(datasets.first).to have_key(:data)
+ end
+
+ it 'includes color for each platform' do
+ datasets = assigns(:shares_data)[:datasets]
+ datasets.each do |dataset|
+ expect(dataset[:backgroundColor]).to be_present
+ expect(dataset[:backgroundColor]).to match(/rgba\(\d+,\s*\d+,\s*\d+,\s*[\d.]+\)/)
+ end
+ end
+ end
+ end
+
+ describe '#random_color_for_platform' do
+ let(:controller) { described_class.new }
+
+ it 'returns specific color for facebook' do
+ expect(controller.random_color_for_platform('facebook')).to eq('rgba(59, 89, 152, 0.5)')
+ end
+
+ it 'returns specific color for bluesky' do
+ expect(controller.random_color_for_platform('bluesky')).to eq('rgba(29, 161, 242, 0.5)')
+ end
+
+ it 'returns specific color for linkedin' do
+ expect(controller.random_color_for_platform('linkedin')).to eq('rgba(0, 123, 182, 0.5)')
+ end
+
+ it 'returns specific color for pinterest' do
+ expect(controller.random_color_for_platform('pinterest')).to eq('rgba(189, 8, 28, 0.5)')
+ end
+
+ it 'returns specific color for reddit' do
+ expect(controller.random_color_for_platform('reddit')).to eq('rgba(255, 69, 0, 0.5)')
+ end
+
+ it 'returns specific color for whatsapp' do
+ expect(controller.random_color_for_platform('whatsapp')).to eq('rgba(37, 211, 102, 0.5)')
+ end
+
+ it 'returns default color for unknown platform' do
+ expect(controller.random_color_for_platform('unknown')).to eq('rgba(75, 192, 192, 0.5)')
+ end
+ end
+ end
+ end
+end
+# rubocop:enable RSpec/IndexedLet
diff --git a/spec/requests/better_together/pages_filtering_spec.rb b/spec/requests/better_together/pages_filtering_spec.rb
index 1be502f1e..b24eb9301 100644
--- a/spec/requests/better_together/pages_filtering_spec.rb
+++ b/spec/requests/better_together/pages_filtering_spec.rb
@@ -3,9 +3,10 @@
require 'rails_helper'
RSpec.describe 'Pages filtering and sorting', :as_platform_manager do
- let(:alpha) { create(:better_together_page, title: 'Alpha Page', slug: 'alpha-page') }
- let(:beta) { create(:better_together_page, title: 'Beta Page', slug: 'beta-page') }
- let(:gamma) { create(:better_together_page, title: 'Gamma Page', slug: 'gamma-page') }
+ # Use identifier prefixes that sort early alphabetically to ensure pages appear on first page
+ let(:alpha) { create(:better_together_page, title: 'Alpha Page', slug: 'aaa-alpha-page', identifier: 'aaa-alpha-page', protected: false) }
+ let(:beta) { create(:better_together_page, title: 'Beta Page', slug: 'aaa-beta-page', identifier: 'aaa-beta-page', protected: false) }
+ let(:gamma) { create(:better_together_page, title: 'Gamma Page', slug: 'aaa-gamma-page', identifier: 'aaa-gamma-page', protected: false) }
before do
alpha
diff --git a/spec/requests/better_together/pages_markdown_rendering_spec.rb b/spec/requests/better_together/pages_markdown_rendering_spec.rb
new file mode 100644
index 000000000..4866ddfae
--- /dev/null
+++ b/spec/requests/better_together/pages_markdown_rendering_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Markdown pages' do
+ let(:page) do
+ create(
+ :better_together_page,
+ title: 'Markdown Page',
+ slug: 'markdown-page',
+ privacy: 'public',
+ published_at: Time.zone.now
+ )
+ end
+
+ let(:page_path) { "/#{I18n.default_locale}/#{page.slug}" }
+
+ describe 'GET /:locale/:path' do
+ let(:markdown_content) { "# Markdown Heading\n\nThis is **bold** content for the page." }
+ let(:markdown_block) { create(:content_markdown, markdown_source: markdown_content) }
+ let!(:page_block) { BetterTogether::Content::PageBlock.create!(page:, block: markdown_block, position: 0) }
+
+ it 'renders markdown blocks as HTML on the page' do
+ get page_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('bold
')
+ expect(response.body).not_to include('# Markdown Heading')
+ end
+
+ context 'when markdown content is loaded from a file' do
+ let(:markdown_file_path) { Rails.root.join('spec/fixtures/files/page_markdown_render.md') }
+ let(:markdown_block) do
+ FileUtils.mkdir_p(markdown_file_path.dirname)
+ File.write(markdown_file_path, "# File Heading\n\nFile paragraph with **formatting**.")
+
+ create(:content_markdown, markdown_source: nil, markdown_file_path: markdown_file_path.to_s)
+ end
+
+ after do
+ FileUtils.rm_f(markdown_file_path)
+ end
+
+ it 'renders the file-backed markdown content' do
+ get page_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('File Heading')
+ expect(response.body).to include('formatting')
+ expect(response.body).not_to include(markdown_file_path.to_s)
+ end
+ end
+ end
+end
diff --git a/spec/requests/better_together/pages_title_display_spec.rb b/spec/requests/better_together/pages_title_display_spec.rb
new file mode 100644
index 000000000..4bdc05b51
--- /dev/null
+++ b/spec/requests/better_together/pages_title_display_spec.rb
@@ -0,0 +1,149 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Pages title display', :as_platform_manager do
+ describe 'GET /pages/:slug' do
+ context 'when page has no hero block' do
+ let(:page_without_hero) do
+ create(:better_together_page,
+ title: 'Page Without Hero',
+ slug: 'aaa-page-without-hero',
+ identifier: 'aaa-page-without-hero',
+ protected: false,
+ published_at: 1.day.ago)
+ end
+
+ before do
+ # Add a non-hero block to ensure page renders
+ markdown_block = create(:content_markdown, markdown_source: '## Test content')
+ page_without_hero.page_blocks.create!(block: markdown_block, position: 0)
+ end
+
+ it 'displays the page title as an h1' do
+ get better_together.page_path(page_without_hero.slug, locale: I18n.default_locale)
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('Page Without Hero
')
+ end
+
+ it 'includes the page title in a container div' do
+ get better_together.page_path(page_without_hero.slug, locale: I18n.default_locale)
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('')
+ expect(response.body).to match(%r{.*Page Without Hero
.*}m)
+ end
+ end
+
+ context 'when page has a hero block' do
+ let(:page_with_hero) do
+ create(:better_together_page,
+ title: 'Page With Hero',
+ slug: 'aaa-page-with-hero',
+ identifier: 'aaa-page-with-hero',
+ protected: false,
+ published_at: 1.day.ago)
+ end
+
+ before do
+ # Add a hero block to the page
+ hero_block = create(:content_hero,
+ title: 'Hero Title',
+ subtitle: 'Hero Subtitle')
+ page_with_hero.page_blocks.create!(block: hero_block, position: 0)
+ end
+
+ it 'does not display the page title as an h1' do
+ get better_together.page_path(page_with_hero.slug, locale: I18n.default_locale)
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).not_to include('Page With Hero
')
+ end
+
+ it 'renders the hero block instead' do
+ get better_together.page_path(page_with_hero.slug, locale: I18n.default_locale)
+
+ expect(response).to have_http_status(:success)
+ # Hero blocks have their own title rendering
+ expect(response.body).to include('Hero Title')
+ expect(response.body).to include('Hero Subtitle')
+ end
+ end
+
+ context 'when page has template and no content blocks' do
+ let(:page_with_template) do
+ create(:better_together_page,
+ title: 'Page With Template',
+ slug: 'aaa-page-with-template',
+ identifier: 'aaa-page-with-template',
+ protected: false,
+ published_at: 1.day.ago,
+ template: 'better_together/static_pages/better_together')
+ end
+
+ it 'does not display the page title as an h1' do
+ get better_together.page_path(page_with_template.slug, locale: I18n.default_locale)
+
+ expect(response).to have_http_status(:success)
+ # Template pages handle their own title rendering
+ expect(response.body).not_to include('Page With Template
')
+ end
+ end
+
+ context 'when page has multiple blocks but no hero' do
+ let(:page_with_multiple_blocks) do
+ create(:better_together_page,
+ title: 'Multi Block Page',
+ slug: 'aaa-multi-block-page',
+ identifier: 'aaa-multi-block-page',
+ protected: false,
+ published_at: 1.day.ago)
+ end
+
+ before do
+ # Add multiple non-hero blocks
+ markdown_block1 = create(:content_markdown, markdown_source: '## First section')
+ markdown_block2 = create(:content_markdown, markdown_source: '## Second section')
+ page_with_multiple_blocks.page_blocks.create!(block: markdown_block1, position: 0)
+ page_with_multiple_blocks.page_blocks.create!(block: markdown_block2, position: 1)
+ end
+
+ it 'displays the page title as an h1 before the content' do
+ get better_together.page_path(page_with_multiple_blocks.slug, locale: I18n.default_locale)
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('Multi Block Page
')
+
+ # Verify title appears before content
+ title_position = response.body.index('Multi Block Page
')
+ content_position = response.body.index('First section')
+ expect(title_position).to be < content_position
+ end
+ end
+
+ context 'when page title contains HTML-sensitive characters' do
+ let(:page_with_special_chars) do
+ create(:better_together_page,
+ title: 'Page & Title "Special" Characters',
+ slug: 'aaa-page-special-chars',
+ identifier: 'aaa-page-special-chars',
+ protected: false,
+ published_at: 1.day.ago)
+ end
+
+ before do
+ markdown_block = create(:content_markdown, markdown_source: '## Test content')
+ page_with_special_chars.page_blocks.create!(block: markdown_block, position: 0)
+ end
+
+ it 'properly escapes the title' do
+ get better_together.page_path(page_with_special_chars.slug, locale: I18n.default_locale)
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('Page & Title <with> "Special" Characters')
+ expect(response.body).not_to include('Page & Title "Special" Characters')
+ end
+ end
+ end
+end
diff --git a/spec/requests/better_together/setup_wizard_steps_controller_spec.rb b/spec/requests/better_together/setup_wizard_steps_controller_spec.rb
new file mode 100644
index 000000000..12cc23eb2
--- /dev/null
+++ b/spec/requests/better_together/setup_wizard_steps_controller_spec.rb
@@ -0,0 +1,398 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+# rubocop:disable Metrics/ModuleLength
+module BetterTogether
+ RSpec.describe SetupWizardStepsController, :skip_host_setup do
+ let(:wizard) { Wizard.find_or_create_by!(identifier: 'host_setup') }
+ let(:platform_details_step) do
+ WizardStepDefinition.find_or_create_by!(
+ wizard:,
+ identifier: 'platform_details'
+ ) do |step|
+ step.step_number = 1
+ step.template = 'better_together/setup_wizard_steps/platform_details'
+ end
+ end
+ let(:admin_creation_step) do
+ WizardStepDefinition.find_or_create_by!(
+ wizard:,
+ identifier: 'admin_creation'
+ ) do |step|
+ step.step_number = 2
+ step.template = 'better_together/setup_wizard_steps/admin_creation'
+ end
+ end
+
+ before do
+ # Ensure wizard exists and steps are loaded
+ platform_details_step
+ admin_creation_step
+
+ # Reset wizard completion status for fresh testing
+ wizard.update!(
+ current_completions: 0,
+ first_completed_at: nil,
+ last_completed_at: nil
+ )
+ wizard.wizard_steps.destroy_all
+ end
+
+ describe 'GET #platform_details' do
+ before do
+ get better_together.setup_wizard_step_platform_details_path(locale: I18n.default_locale)
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'renders the platform details form' do
+ expect(response.body).to include('name')
+ expect(response.body).to include('description')
+ expect(response.body).to include('url')
+ end
+ end
+
+ describe 'POST #create_host_platform' do
+ let(:valid_platform_params) do
+ {
+ name: 'Test Platform',
+ description: 'Test Description',
+ url: 'http://test.example.com',
+ time_zone: 'UTC',
+ privacy: 'private'
+ }
+ end
+
+ context 'with valid parameters' do
+ before do
+ post better_together.setup_wizard_step_create_host_platform_path(locale: I18n.default_locale),
+ params: { platform: valid_platform_params }
+ end
+
+ it 'creates a new platform' do
+ expect(Platform.count).to eq(1)
+ end
+
+ it 'sets the platform as host' do
+ platform = Platform.last
+ expect(platform.host).to be true
+ end
+
+ it 'marks the wizard step as completed' do
+ wizard.reload
+ step = wizard.wizard_steps.find_by(wizard_step_definition: platform_details_step)
+ expect(step&.completed).to be true
+ end
+
+ it 'redirects to the next step' do
+ expect(response).to have_http_status(:redirect)
+ end
+ end
+
+ context 'with invalid parameters' do
+ let(:invalid_platform_params) do
+ {
+ name: '',
+ description: '',
+ url: '',
+ time_zone: 'UTC',
+ privacy: 'private'
+ }
+ end
+
+ before do
+ post better_together.setup_wizard_step_create_host_platform_path(locale: I18n.default_locale),
+ params: { platform: invalid_platform_params }
+ end
+
+ it 'does not create a platform' do
+ expect(Platform.count).to eq(0)
+ end
+
+ it 'renders the platform_details template' do
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it 'displays validation errors' do
+ expect(response.body).to match(/error|invalid/i)
+ end
+
+ it 'sets flash alert' do
+ expect(flash[:alert]).to be_present
+ end
+ end
+
+ context 'when ActiveRecord::RecordInvalid is raised' do
+ before do
+ # Create a platform with a validation error
+ invalid_platform = Platform.new
+ invalid_platform.errors.add(:base, 'Test validation error')
+
+ # rubocop:disable RSpec/AnyInstance
+ allow_any_instance_of(Platform).to receive(:save!).and_raise(
+ ActiveRecord::RecordInvalid.new(invalid_platform)
+ )
+ # rubocop:enable RSpec/AnyInstance
+
+ post better_together.setup_wizard_step_create_host_platform_path(locale: I18n.default_locale),
+ params: { platform: valid_platform_params }
+ end
+
+ it 'handles the exception' do
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it 'shows error message in response' do
+ # flash.now is used in controller, which renders error in the response body
+ # Check that error-related content is displayed
+ expect(response.body).to match(/error|invalid|please/i)
+ end
+ end
+ end
+
+ describe 'GET #admin_creation' do
+ it 'redirects to appropriate step based on wizard state' do
+ skip 'Complex wizard state management - covered by integration tests'
+ end
+ end
+
+ describe 'POST #create_admin' do
+ # The wizard creates the FIRST user - no pre-existing users should exist
+ # The host platform and community are created in the previous wizard step (create_host_platform)
+ # Use find_or_create_by to handle seed data
+ before do
+ ::BetterTogether::Platform.find_or_create_by(host: true) do |platform|
+ platform.name = 'Test Platform'
+ platform.url = 'http://test.example.com'
+ platform.privacy = 'public'
+ platform.identifier = 'test-platform'
+ platform.time_zone = 'UTC'
+ end
+
+ ::BetterTogether::Community.find_or_create_by(host: true) do |community|
+ community.name = 'Test Community'
+ community.identifier = 'test-community'
+ end
+
+ # Ensure no users exist before the wizard creates the first one
+ ::BetterTogether::User.destroy_all
+ end
+
+ # Roles must exist for memberships to be created
+ let!(:platform_manager_role) do
+ ::BetterTogether::Role.find_or_create_by(identifier: 'platform_manager') do |role|
+ role.name = 'Platform Manager'
+ role.resource_type = 'BetterTogether::Platform'
+ end
+ end
+ let!(:governance_role) do
+ ::BetterTogether::Role.find_or_create_by(identifier: 'community_governance_council') do |role|
+ role.name = 'Community Governance Council'
+ role.resource_type = 'BetterTogether::Community'
+ end
+ end
+
+ let(:valid_user_params) do
+ {
+ email: 'admin@example.com',
+ password: '!StrongPass12345?',
+ password_confirmation: '!StrongPass12345?',
+ person_attributes: {
+ identifier: 'admin-user',
+ name: 'Admin User',
+ description: 'Platform Administrator'
+ }
+ }
+ end
+
+ # No need to stub helpers since host_platform/host_community exist in DB
+
+ context 'with valid parameters' do
+ before do
+ # Wizard should start with ZERO users and create the first one
+ raise "Expected 0 users before wizard creates first admin, found #{User.count}" unless User.none?
+
+ post better_together.setup_wizard_step_create_admin_path(locale: I18n.default_locale),
+ params: { user: valid_user_params }
+ end
+
+ it 'creates a new user' do
+ expect(User.count).to eq(1)
+ end
+
+ it 'creates associated person' do
+ user = User.find_by(email: 'admin@example.com')
+ expect(user).to be_present
+ expect(user.person).to be_present
+ expect(user.person.name).to eq('Admin User')
+ end
+
+ it 'creates platform membership with platform_manager role' do
+ user = User.find_by(email: 'admin@example.com')
+ host_platform = ::BetterTogether::Platform.find_by(host: true)
+ membership = host_platform.person_platform_memberships.find_by(member: user.person)
+ expect(membership).to be_present
+ expect(membership.role).to eq(platform_manager_role)
+ end
+
+ it 'creates community membership with governance role' do
+ user = User.find_by(email: 'admin@example.com')
+ host_community = ::BetterTogether::Community.find_by(host: true)
+ membership = host_community.person_community_memberships.find_by(member: user.person)
+ expect(membership).to be_present
+ expect(membership.role).to eq(governance_role)
+ end
+
+ it 'sets user as community creator' do
+ user = User.find_by(email: 'admin@example.com')
+ host_community = ::BetterTogether::Community.find_by(host: true)
+ host_community.reload
+ expect(host_community.creator).to eq(user.person)
+ end
+
+ it 'marks the wizard step as completed' do
+ wizard.reload
+
+ # Find the step that should be completed
+ step = wizard.wizard_steps.find_by(identifier: 'admin_creation')
+
+ # The step should exist and be marked as completed
+ expect(step).to be_present
+ expect(step.completed).to be(true)
+ end
+
+ it 'redirects to appropriate location' do
+ expect(response).to have_http_status(:redirect)
+ end
+ end
+
+ context 'with invalid parameters' do
+ let(:invalid_user_params) do
+ {
+ email: 'invalid-email',
+ password: 'short',
+ password_confirmation: 'different',
+ person: {
+ identifier: '',
+ name: '',
+ description: ''
+ }
+ }
+ end
+
+ before do
+ post better_together.setup_wizard_step_create_admin_path(locale: I18n.default_locale),
+ params: { user: invalid_user_params }
+ end
+
+ it 'does not create a user' do
+ expect(User.count).to eq(0)
+ end
+
+ it 'renders the admin_creation template' do
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it 'displays validation errors in response' do
+ expect(response.body).to match(/error|invalid/i)
+ end
+
+ it 'sets flash alert' do
+ expect(flash[:alert]).to be_present
+ end
+ end
+
+ context 'when ActiveRecord::RecordInvalid is raised' do
+ before do
+ # Create a user with a validation error
+ invalid_user = User.new
+ invalid_user.errors.add(:base, 'Test validation error')
+
+ # rubocop:disable RSpec/AnyInstance
+ allow_any_instance_of(User).to receive(:save!).and_raise(
+ ActiveRecord::RecordInvalid.new(invalid_user)
+ )
+ # rubocop:enable RSpec/AnyInstance
+
+ post better_together.setup_wizard_step_create_admin_path(locale: I18n.default_locale),
+ params: { user: valid_user_params }
+ end
+
+ it 'handles the exception' do
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it 'shows error message in response' do
+ # flash.now is used in controller, which renders error in the response body
+ # Check that error-related content is displayed
+ expect(response.body).to match(/error|invalid|please/i)
+ end
+
+ it 'renders the form again' do
+ expect(response.body).to match(/email|password/i)
+ end
+ end
+ end
+
+ describe 'GET #redirect' do
+ context 'with valid path' do
+ it 'redirects based on wizard state' do
+ skip 'Complex wizard navigation logic - tested through actual wizard flow'
+ end
+ end
+
+ context 'with invalid path' do
+ it 'handles invalid path safely' do
+ skip 'Route constraints prevent invalid paths from reaching controller'
+ end
+ end
+ end
+
+ describe 'private methods' do
+ let(:controller) { described_class.new }
+
+ describe '#permitted_path' do
+ it 'allows platform_details' do
+ expect(controller.send(:permitted_path, 'platform_details')).to eq('platform_details')
+ end
+
+ it 'allows create_host_platform' do
+ expect(controller.send(:permitted_path, 'create_host_platform')).to eq('create_host_platform')
+ end
+
+ it 'allows admin_creation' do
+ expect(controller.send(:permitted_path, 'admin_creation')).to eq('admin_creation')
+ end
+
+ it 'allows create_admin' do
+ expect(controller.send(:permitted_path, 'create_admin')).to eq('create_admin')
+ end
+
+ it 'returns nil for invalid path' do
+ expect(controller.send(:permitted_path, 'invalid_path')).to be_nil
+ end
+ end
+
+ describe '#base_platform' do
+ before do
+ # rubocop:disable RSpec/MessageChain
+ allow(controller).to receive_message_chain(:helpers, :base_url).and_return('http://test.example.com')
+ # rubocop:enable RSpec/MessageChain
+ end
+
+ it 'creates a platform with default attributes' do
+ platform = controller.send(:base_platform)
+ expect(platform).to be_a(Platform)
+ expect(platform.privacy).to eq('private')
+ expect(platform.protected).to be true
+ expect(platform.host).to be true
+ expect(platform.time_zone).to eq(Time.zone.name)
+ end
+ end
+ end
+ end
+end
+# rubocop:enable Metrics/ModuleLength
diff --git a/spec/requests/better_together/translations_controller_spec.rb b/spec/requests/better_together/translations_controller_spec.rb
new file mode 100644
index 000000000..452a1a656
--- /dev/null
+++ b/spec/requests/better_together/translations_controller_spec.rb
@@ -0,0 +1,211 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+# rubocop:disable Metrics/ModuleLength
+module BetterTogether
+ RSpec.describe TranslationsController, :as_user do
+ describe 'POST #translate' do
+ let(:person) { BetterTogether::User.find_by(email: 'user@example.test')&.person }
+ let(:content) { 'Hello, world!' }
+ let(:source_locale) { 'en' }
+ let(:target_locale) { 'es' }
+ let(:translated_content) { '¡Hola, mundo!' }
+ let(:translation_bot) { instance_double(BetterTogether::TranslationBot) }
+
+ let(:valid_params) do
+ {
+ content:,
+ source_locale:,
+ target_locale:
+ }
+ end
+
+ before do
+ # Stub TranslationBot.new to return our mock instance
+ allow(BetterTogether::TranslationBot).to receive(:new).and_return(translation_bot)
+ end
+
+ context 'with successful translation' do
+ before do
+ allow(translation_bot).to receive(:translate)
+ .with(content,
+ target_locale:,
+ source_locale:,
+ initiator: person)
+ .and_return(translated_content)
+
+ post better_together.ai_translate_path(locale: I18n.default_locale),
+ params: valid_params
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'returns translated content as JSON' do
+ expect(response.content_type).to match(%r{application/json})
+ json_response = JSON.parse(response.body)
+ expect(json_response['translation']).to eq(translated_content)
+ end
+
+ it 'calls TranslationBot with correct parameters' do
+ expect(translation_bot).to have_received(:translate)
+ .with(content,
+ target_locale:,
+ source_locale:,
+ initiator: person)
+ end
+ end
+
+ context 'with different locales' do
+ let(:source_locale) { 'en' }
+ let(:target_locale) { 'fr' }
+ let(:translated_content) { 'Bonjour le monde!' }
+
+ before do
+ allow(translation_bot).to receive(:translate)
+ .and_return(translated_content)
+
+ post better_together.ai_translate_path(locale: I18n.default_locale),
+ params: valid_params
+ end
+
+ it 'returns the French translation' do
+ json_response = JSON.parse(response.body)
+ expect(json_response['translation']).to eq(translated_content)
+ end
+ end
+
+ context 'with complex HTML content' do
+ let(:content) do
+ 'Hello
This is a test
'
+ end
+ let(:translated_content) do
+ 'Hola
Esta es una prueba
'
+ end
+
+ before do
+ allow(translation_bot).to receive(:translate)
+ .and_return(translated_content)
+
+ post better_together.ai_translate_path(locale: I18n.default_locale),
+ params: valid_params
+ end
+
+ it 'handles HTML content correctly' do
+ json_response = JSON.parse(response.body)
+ expect(json_response['translation']).to eq(translated_content)
+ end
+ end
+
+ context 'when translation fails' do
+ before do
+ allow(translation_bot).to receive(:translate)
+ .and_raise(StandardError, 'API connection failed')
+
+ post better_together.ai_translate_path(locale: I18n.default_locale),
+ params: valid_params
+ end
+
+ it 'returns unprocessable_content status' do
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it 'returns error message as JSON' do
+ json_response = JSON.parse(response.body)
+ expect(json_response['error']).to eq('Translation failed: API connection failed')
+ end
+ end
+
+ context 'when TranslationBot raises timeout error' do
+ before do
+ allow(translation_bot).to receive(:translate)
+ .and_raise(Timeout::Error)
+
+ post better_together.ai_translate_path(locale: I18n.default_locale),
+ params: valid_params
+ end
+
+ it 'handles the error gracefully' do
+ expect(response).to have_http_status(:unprocessable_content)
+ json_response = JSON.parse(response.body)
+ expect(json_response['error']).to include('Translation failed')
+ end
+ end
+
+ context 'when content is empty' do
+ let(:content) { '' }
+ let(:translated_content) { '' }
+
+ before do
+ allow(translation_bot).to receive(:translate)
+ .and_return(translated_content)
+
+ post better_together.ai_translate_path(locale: I18n.default_locale),
+ params: valid_params
+ end
+
+ it 'handles empty content' do
+ expect(response).to have_http_status(:success)
+ json_response = JSON.parse(response.body)
+ expect(json_response['translation']).to eq('')
+ end
+ end
+
+ context 'when current_person is nil' do
+ it 'handles nil initiator and returns successful translation' do
+ skip 'Route requires authentication, so current_person cannot be nil in practice'
+ end
+ end
+
+ context 'with missing parameters' do
+ it 'handles missing content parameter' do
+ allow(translation_bot).to receive(:translate).and_return('')
+
+ post better_together.ai_translate_path(locale: I18n.default_locale),
+ params: { source_locale:, target_locale: }
+
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'handles missing source_locale parameter' do
+ allow(translation_bot).to receive(:translate).and_return(translated_content)
+
+ post better_together.ai_translate_path(locale: I18n.default_locale),
+ params: { content:, target_locale: }
+
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'handles missing target_locale parameter' do
+ allow(translation_bot).to receive(:translate).and_return(translated_content)
+
+ post better_together.ai_translate_path(locale: I18n.default_locale),
+ params: { content:, source_locale: }
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+
+ context 'with special characters in content' do
+ let(:content) { "Hello & welcome to 'world'!" }
+ let(:translated_content) { "¡Hola & bienvenido a 'mundo'!" }
+
+ before do
+ allow(translation_bot).to receive(:translate)
+ .and_return(translated_content)
+
+ post better_together.ai_translate_path(locale: I18n.default_locale),
+ params: valid_params
+ end
+
+ it 'preserves special characters' do
+ json_response = JSON.parse(response.body)
+ expect(json_response['translation']).to eq(translated_content)
+ end
+ end
+ end
+ end
+end
+# rubocop:enable Metrics/ModuleLength
diff --git a/spec/requests/better_together/users/registrations_spec.rb b/spec/requests/better_together/users/registrations_spec.rb
index b8c35807f..24992b7eb 100644
--- a/spec/requests/better_together/users/registrations_spec.rb
+++ b/spec/requests/better_together/users/registrations_spec.rb
@@ -87,9 +87,10 @@
privacy_policy_agreement: '1',
code_of_conduct_agreement: '1'
}
- end.to change(BetterTogether::User, :count).by(1) # User created despite empty name
+ end.not_to change(BetterTogether::User, :count) # User not created due to invalid person
- expect(response).to have_http_status(:ok) # Form re-rendered with errors
+ expect(response).to have_http_status(:unprocessable_content) # Validation failed
+ expect(response.body).to include('can't be blank') # Name validation error shown
end
end
diff --git a/spec/sanitizers/better_together/sanitizers/external_link_icon_sanitizer_spec.rb b/spec/sanitizers/better_together/sanitizers/external_link_icon_sanitizer_spec.rb
new file mode 100644
index 000000000..061b0d67d
--- /dev/null
+++ b/spec/sanitizers/better_together/sanitizers/external_link_icon_sanitizer_spec.rb
@@ -0,0 +1,299 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+module BetterTogether
+ # rubocop:disable Metrics/ModuleLength
+ module Sanitizers
+ RSpec.describe ExternalLinkIconSanitizer do
+ let(:sanitizer) { described_class.new }
+ let(:host) { 'example.com' }
+
+ before do
+ allow(Rails.application.routes.default_url_options).to receive(:[]).with(:host).and_return(host)
+ end
+
+ describe '#sanitize' do
+ context 'with internal links' do
+ it 'does not add icon to same-host links' do
+ html = 'Internal Link'
+ result = sanitizer.sanitize(html)
+
+ expect(result).not_to include('fa-external-link-alt')
+ expect(result).not_to include('external-link')
+ end
+
+ it 'does not modify relative links' do
+ html = 'About'
+ result = sanitizer.sanitize(html)
+
+ expect(result).not_to include('fa-external-link-alt')
+ end
+
+ it 'handles links without protocol' do
+ html = 'Page'
+ result = sanitizer.sanitize(html)
+
+ expect(result).not_to include('fa-external-link-alt')
+ end
+
+ it 'handles hash links' do
+ html = 'Section'
+ result = sanitizer.sanitize(html)
+
+ expect(result).not_to include('fa-external-link-alt')
+ end
+ end
+
+ context 'with external links' do
+ it 'adds Font Awesome icon to external links' do
+ html = 'External Link'
+ result = sanitizer.sanitize(html)
+
+ expect(result).to include('fa-external-link-alt')
+ expect(result).to include('fas')
+ end
+
+ it 'adds external-link class' do
+ html = 'External Link'
+ result = sanitizer.sanitize(html)
+
+ expect(result).to include('class="external-link"')
+ end
+
+ it 'appends icon after link text' do
+ html = 'External Link'
+ result = sanitizer.sanitize(html)
+
+ # Icon should be after the text
+ expect(result).to match(/External Link.*
+ Internal
+ External
+ Local
+
+ HTML
+ end
+
+ it 'only adds icons to external links' do
+ result = sanitizer.sanitize(mixed_html)
+
+ # Only one external link should have icon
+ expect(result.scan('fa-external-link-alt').count).to eq(1)
+ end
+
+ it 'preserves internal links unchanged' do
+ result = sanitizer.sanitize(mixed_html)
+
+ expect(result).to include('Internal')
+ end
+ end
+
+ context 'with malformed URLs' do
+ it 'handles invalid URLs gracefully' do
+ html = 'Invalid'
+ result = sanitizer.sanitize(html)
+
+ # Should not crash, URL parsing fails gracefully
+ expect(result).to be_a(String)
+ end
+
+ it 'handles URLs without host' do
+ html = 'JS Link'
+ result = sanitizer.sanitize(html)
+
+ # javascript: URLs have no host, should not crash
+ expect(result).to be_a(String)
+ end
+
+ it 'handles empty href' do
+ html = 'Empty'
+ result = sanitizer.sanitize(html)
+
+ expect(result).to be_a(String)
+ end
+ end
+
+ context 'with different protocols' do
+ it 'handles HTTPS links' do
+ html = 'Secure'
+ result = sanitizer.sanitize(html)
+
+ expect(result).to include('fa-external-link-alt')
+ end
+
+ it 'handles HTTP links' do
+ html = 'Insecure'
+ result = sanitizer.sanitize(html)
+
+ expect(result).to include('fa-external-link-alt')
+ end
+
+ it 'handles FTP links' do
+ html = 'FTP'
+ result = sanitizer.sanitize(html)
+
+ expect(result).to include('fa-external-link-alt')
+ end
+
+ it 'handles mailto links' do
+ html = 'Email'
+ result = sanitizer.sanitize(html)
+
+ # mailto has no host, should not add external icon
+ expect(result).not_to include('fa-external-link-alt')
+ end
+
+ it 'handles tel links' do
+ html = 'Phone'
+ result = sanitizer.sanitize(html)
+
+ # tel has no host, should not add external icon
+ expect(result).not_to include('fa-external-link-alt')
+ end
+ end
+
+ context 'with complex HTML structures' do
+ it 'handles nested elements in links' do
+ html = 'Link'
+ result = sanitizer.sanitize(html)
+
+ expect(result).to include('fa-external-link-alt')
+ end
+
+ it 'handles links with images' do
+ html = '
'
+ result = sanitizer.sanitize(html)
+
+ expect(result).to include('fa-external-link-alt')
+ end
+
+ it 'handles multiple links in complex structure' do
+ html = <<~HTML
+
+ HTML
+ result = sanitizer.sanitize(html)
+
+ expect(result.scan('fa-external-link-alt').count).to eq(1)
+ end
+ end
+
+ context 'with sanitizer options' do
+ it 'passes options to parent sanitize method' do
+ html = 'Link'
+ result = sanitizer.sanitize(html)
+
+ # Should sanitize the script tag while preserving the link
+ expect(result).not_to include('