diff --git a/Gemfile.lock b/Gemfile.lock index 4406432..3138f66 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -120,7 +120,7 @@ GEM marcel (1.1.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.26.0) + minitest (5.27.0) net-imap (0.5.12) date net-protocol @@ -244,7 +244,7 @@ GEM concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) - unicode-emoji (4.1.0) + unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) websocket-driver (0.8.0) diff --git a/README.md b/README.md index 0ebf1f8..f0b9c46 100644 --- a/README.md +++ b/README.md @@ -439,6 +439,74 @@ end **Note:** The `"pages"` post_type name is reserved for this feature. If you try to create a post_type named "pages", Bunko will raise an error. +### Avo Admin Panel Integration + +Bunko provides a generator for creating an [Avo](https://avohq.io) admin panel with a clean editor layout. + +**Prerequisites:** + +1. Install Avo gem: +```ruby +# Gemfile +gem "avo" +``` + +2. Install and configure Avo: +```bash +bundle install +rails generate avo:install +``` + +**Generate Bunko Avo Resource:** + +```bash +rails bunko:avo:install +``` + +This creates: +- `app/avo/resources/post.rb` - Post resource with main content area and metadata sidebar +- `app/avo/filters/post_type_filter.rb` - Filter posts by type +- `app/avo/actions/publish_post.rb` - Quick publish action +- `app/avo/actions/unpublish_post.rb` - Quick unpublish action + +**Editor Options:** + +Bunko defaults to Avo's markdown field (powered by [Marksmith](https://github.com/avo-hq/marksmith)), but supports multiple editors: + +```bash +# Default: Markdown field (GitHub-style editor with Marksmith) +rails bunko:avo:install + +# Rhino (TipTap-based WYSIWYG editor) +EDITOR=rhino rails bunko:avo:install + +# TipTap (WYSIWYG editor) +EDITOR=tiptap rails bunko:avo:install + +# Trix (Rails default rich text editor) +EDITOR=trix rails bunko:avo:install + +# Plain textarea (simple text input) +EDITOR=textarea rails bunko:avo:install +``` + +**Required gems for rich editors:** + +- **Markdown**: Add `gem "marksmith"` and `gem "commonmarker"` to your Gemfile +- **Rhino**: Add `gem "avo-rhino_field"` to your Gemfile +- **TipTap**: Add `gem "avo-tiptap_field"` to your Gemfile (if available) +- **Trix**: Built into Rails (no additional gems needed) + +See the [Avo fields documentation](https://docs.avohq.io) for detailed setup instructions. + +**Layout:** + +The generated resource features: +- **Main content area:** Title and content editor +- **Sidebar:** Status, publishing options, post type, slug, SEO fields, content stats, and timestamps + +Visit `http://localhost:3000/avo` to access your admin panel. + ### Configuration ```ruby diff --git a/lib/tasks/bunko/avo.rake b/lib/tasks/bunko/avo.rake new file mode 100644 index 0000000..4578ecd --- /dev/null +++ b/lib/tasks/bunko/avo.rake @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require "fileutils" +require_relative "helpers" + +namespace :bunko do + namespace :avo do + include Bunko::RakeHelpers + + desc "Generate Avo resource for Bunko posts" + task install: :environment do + puts "Generating Avo resource for Bunko..." + puts "" + + # Check if Avo is installed + begin + require "avo" + rescue LoadError + puts "⚠️ Avo gem not found!" + puts " Add 'gem \"avo\"' to your Gemfile and run 'bundle install'" + puts " Then run 'rails generate avo:install' before running this task" + exit 1 + end + + # Check if Avo has been initialized + avo_initializer = Rails.root.join("config/initializers/avo.rb") + unless File.exist?(avo_initializer) + puts "⚠️ Avo not initialized!" + puts " Run 'rails generate avo:install' first" + exit 1 + end + + # Get configured post types for dynamic filters + post_types = Bunko.configuration.post_types.map { |pt| pt[:name] } + + # Detect editor preference (Avo-specific editors) + editor_type = ENV.fetch("EDITOR", "markdown") # Options: markdown (marksmith), rhino, tiptap, trix, textarea + + # Generate Avo resource, filters, and actions + generate_avo_post_resource(post_types, editor_type) + generate_avo_post_type_filter(post_types) if post_types.any? + generate_avo_actions + + puts "=" * 79 + puts "Avo resource generated!" + puts "" + puts "Next steps:" + puts " 1. Visit http://localhost:3000/avo to access your admin panel" + puts " 2. Customize app/avo/resources/post.rb as needed" + puts "" + puts "Editor type: #{editor_type}" + puts " To change, run: EDITOR=rhino rails bunko:avo:install" + puts " Options: markdown (default, uses Marksmith), rhino, tiptap, trix, textarea" + puts "=" * 79 + end + + private + + def generate_avo_post_resource(post_types, editor_type) + resources_dir = Rails.root.join("app/avo/resources") + resource_file = resources_dir.join("post.rb") + + if File.exist?(resource_file) + puts " - app/avo/resources/post.rb already exists (skipped)" + puts " To regenerate, delete the file and run again" + return false + end + + FileUtils.mkdir_p(resources_dir) + + resource_content = render_template( + "avo/resources/post.rb.tt", + post_types: post_types, + editor_type: editor_type + ) + + File.write(resource_file, resource_content) + + puts " ✓ Created app/avo/resources/post.rb" + puts " - Main content area with #{editor_type} editor" + puts " - Metadata sidebar with publishing, SEO, and stats" + puts " - Post type filters: #{post_types.join(", ")}" if post_types.any? + true + end + + def generate_avo_post_type_filter(post_types) + filters_dir = Rails.root.join("app/avo/filters") + filter_file = filters_dir.join("post_type_filter.rb") + + if File.exist?(filter_file) + puts " - app/avo/filters/post_type_filter.rb already exists (skipped)" + return false + end + + FileUtils.mkdir_p(filters_dir) + + filter_content = render_template( + "avo/filters/post_type_filter.rb.tt", + post_types: post_types + ) + + File.write(filter_file, filter_content) + + puts " ✓ Created app/avo/filters/post_type_filter.rb" + true + end + + def generate_avo_actions + actions_dir = Rails.root.join("app/avo/actions") + FileUtils.mkdir_p(actions_dir) + + # Generate PublishPost action + publish_action = actions_dir.join("publish_post.rb") + if File.exist?(publish_action) + puts " - app/avo/actions/publish_post.rb already exists (skipped)" + else + publish_content = render_template("avo/actions/publish_post.rb.tt", {}) + File.write(publish_action, publish_content) + puts " ✓ Created app/avo/actions/publish_post.rb" + end + + # Generate UnpublishPost action + unpublish_action = actions_dir.join("unpublish_post.rb") + if File.exist?(unpublish_action) + puts " - app/avo/actions/unpublish_post.rb already exists (skipped)" + else + unpublish_content = render_template("avo/actions/unpublish_post.rb.tt", {}) + File.write(unpublish_action, unpublish_content) + puts " ✓ Created app/avo/actions/unpublish_post.rb" + end + end + end +end diff --git a/lib/tasks/bunko/install.rake b/lib/tasks/bunko/install.rake index cd5d8f2..4a9e8ea 100644 --- a/lib/tasks/bunko/install.rake +++ b/lib/tasks/bunko/install.rake @@ -17,9 +17,11 @@ namespace :bunko do # Step 1: Create migrations puts "Creating migrations..." - create_post_types_migration(skip_seo: skip_seo, json_content: json_content) - sleep 1 # Ensure different timestamps - create_posts_migration(skip_seo: skip_seo, json_content: json_content) + base_time = Time.now.utc + timestamp1 = base_time.strftime("%Y%m%d%H%M%S") + timestamp2 = (base_time + 1).strftime("%Y%m%d%H%M%S") + create_post_types_migration(timestamp: timestamp1, skip_seo: skip_seo, json_content: json_content) + create_posts_migration(timestamp: timestamp2, skip_seo: skip_seo, json_content: json_content) puts "" # Step 2: Create models @@ -38,8 +40,7 @@ namespace :bunko do # Helper methods - def create_post_types_migration(skip_seo:, json_content:) - timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S") + def create_post_types_migration(timestamp:, skip_seo:, json_content:) migration_file = Rails.root.join("db/migrate/#{timestamp}_create_post_types.rb") if Dir.glob(Rails.root.join("db/migrate/*_create_post_types.rb")).any? @@ -57,8 +58,7 @@ namespace :bunko do true end - def create_posts_migration(skip_seo:, json_content:) - timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S") + def create_posts_migration(timestamp:, skip_seo:, json_content:) migration_file = Rails.root.join("db/migrate/#{timestamp}_create_posts.rb") if Dir.glob(Rails.root.join("db/migrate/*_create_posts.rb")).any? diff --git a/lib/tasks/bunko/sample_data.rake b/lib/tasks/bunko/sample_data.rake index 0952e98..7556094 100644 --- a/lib/tasks/bunko/sample_data.rake +++ b/lib/tasks/bunko/sample_data.rake @@ -202,12 +202,12 @@ namespace :bunko do # Add routes for the created pages pages_to_create.each do |page_def| - # Home gets special treatment with path: "/" + # Home gets special treatment - add as root route if page_def[:slug] == "home" - if add_bunko_page_route(page_def[:slug], path: "/") - puts " ✓ Added route: bunko_page :home, path: \"/\"" + if add_root_route(page_def[:slug]) + puts " ✓ Added route: root \"pages#show\", defaults: { page: \"home\" }" else - puts " - Route for :home already exists (skipped)" + puts " - Root route already exists (skipped)" end elsif add_bunko_page_route(page_def[:slug]) puts " ✓ Added route: bunko_page :#{page_def[:slug].tr("-", "_")}" @@ -240,6 +240,30 @@ namespace :bunko do Rails.application.routes.named_routes[:root].present? end + def add_root_route(page_slug) + routes_file = Rails.root.join("config/routes.rb") + routes_content = File.read(routes_file) + + # Check if root route already exists + if routes_content.match?(/^\s*root\s/) + return false + end + + # Find the Rails.application.routes.draw block start + if routes_content.match?(/Rails\.application\.routes\.draw do/) + # Insert root route right after the 'do' line + updated_content = routes_content.sub( + /(Rails\.application\.routes\.draw do\n)/, + "\\1 root \"pages#show\", defaults: {page: \"#{page_slug}\"}\n\n" + ) + else + return false + end + + File.write(routes_file, updated_content) + true + end + def add_bunko_page_route(slug, path: nil) routes_file = Rails.root.join("config/routes.rb") routes_content = File.read(routes_file) diff --git a/lib/tasks/templates/avo/actions/publish_post.rb.tt b/lib/tasks/templates/avo/actions/publish_post.rb.tt new file mode 100644 index 0000000..92bd4f9 --- /dev/null +++ b/lib/tasks/templates/avo/actions/publish_post.rb.tt @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Avo::Actions::PublishPost < Avo::BaseAction + self.name = "Publish" + self.visible = -> { record.status != "published" } + self.message = "Are you sure you want to publish this post?" + self.confirm_button_label = "Publish" + + def handle(**args) + args[:records].each do |post| + # Updates status to published + # Note: published_at is auto-set by Bunko callback if blank + # If published_at is already set (e.g. scheduled post), it will be preserved + post.update(status: "published") + end + + succeed "Post(s) published successfully!" + end +end diff --git a/lib/tasks/templates/avo/actions/unpublish_post.rb.tt b/lib/tasks/templates/avo/actions/unpublish_post.rb.tt new file mode 100644 index 0000000..5f9a61c --- /dev/null +++ b/lib/tasks/templates/avo/actions/unpublish_post.rb.tt @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Avo::Actions::UnpublishPost < Avo::BaseAction + self.name = "Unpublish" + self.visible = -> { record.status == "published" } + self.message = "Are you sure you want to unpublish this post?" + self.confirm_button_label = "Unpublish" + + def handle(**args) + args[:records].each do |post| + # Updates status to draft + # Note: published_at is preserved for re-publishing with original date + # To reset published_at, clear it manually before re-publishing + post.update(status: "draft") + end + + succeed "Post(s) unpublished successfully!" + end +end diff --git a/lib/tasks/templates/avo/filters/post_type_filter.rb.tt b/lib/tasks/templates/avo/filters/post_type_filter.rb.tt new file mode 100644 index 0000000..c3009d6 --- /dev/null +++ b/lib/tasks/templates/avo/filters/post_type_filter.rb.tt @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Avo::Filters::PostTypeFilter < Avo::Filters::SelectFilter + self.name = "Post Type" + self.visible = -> { true } + + def apply(request, query, value) + return query if value.blank? + + post_type = PostType.find_by(name: value) + return query if post_type.nil? + + query.by_post_type(post_type) + end + + def options + { +<% post_types.each_with_index do |pt, index| -%> + "<%= pt.titleize %>" => "<%= pt %>"<%= index < post_types.length - 1 ? "," : "" %> +<% end -%> + } + end +end diff --git a/lib/tasks/templates/avo/resources/post.rb.tt b/lib/tasks/templates/avo/resources/post.rb.tt new file mode 100644 index 0000000..9a4a13c --- /dev/null +++ b/lib/tasks/templates/avo/resources/post.rb.tt @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class Avo::Resources::Post < Avo::BaseResource + self.title = :title + self.includes = [:post_type] + self.search = { + query: -> { query.ransack(id_eq: params[:q], title_cont: params[:q], slug_cont: params[:q], m: "or").result(distinct: false) } + } + + def fields + # Main content area - Title and Content + main_panel do + field :title, as: :text, required: true, placeholder: "Add title" + + # Content editor - configurable type + <% if editor_type == "markdown" %> + field :content, as: :markdown + <% elsif editor_type == "rhino" %> + field :content, as: :rhino + <% elsif editor_type == "tiptap" %> + field :content, as: :tiptap + <% elsif editor_type == "trix" %> + field :content, as: :trix + <% else %> + field :content, as: :textarea, rows: 20 + <% end %> + end + + # Sidebar - All metadata + sidebar do + # Status badge (show only) + field :status, as: :badge, only_on: :show do + success :published + info :draft + warning :scheduled + end + + # Publishing + field :status, as: :select, enum: ::Bunko.configuration.valid_statuses, required: true, hide_on: :show + field :published_at, as: :date_time, picker_format: "Y-m-d H:i:S", format: "yyyy-LL-dd TT", help: "Auto-set when status changes to published" + + # Post Type + field :post_type, as: :belongs_to, searchable: true, placeholder: "Select post type" + + # Slug + field :slug, as: :text, help: "Auto-generated from title if left blank" + + # SEO Fields (conditional) + field :meta_title, as: :text, help: "Defaults to title if blank" + field :meta_description, as: :textarea, rows: 3, help: "Recommended: 150-160 characters" + + # Content Stats (show only) + field :word_count, as: :number, only_on: :show, help: "Auto-calculated" + field :reading_time, as: :text, only_on: :show do + record.reading_time_text if record.word_count.present? + end + + # Timestamps + field :created_at, as: :date_time, only_on: :show, format: "relative" + field :updated_at, as: :date_time, only_on: :show, format: "relative" + end + end + + # Filters for post types + <% if post_types.any? %> + def filters + filter Avo::Filters::PostTypeFilter + end + <% end %> + + # Actions + def actions + action Avo::Actions::PublishPost + action Avo::Actions::UnpublishPost + end +end diff --git a/lib/tasks/templates/controllers/pages_controller.rb.tt b/lib/tasks/templates/controllers/pages_controller.rb.tt index 7b93cab..70806bc 100644 --- a/lib/tasks/templates/controllers/pages_controller.rb.tt +++ b/lib/tasks/templates/controllers/pages_controller.rb.tt @@ -2,10 +2,11 @@ class PagesController < ApplicationController def show - # Extract page slug from request path (not user-controllable params) + # Extract page slug from route defaults (set by bunko_page routing helper) + # Falls back to extracting from request path for custom routes # This prevents path traversal attacks via query string manipulation # e.g., GET /about?page=../../admin/users - page_slug = request.path.split('/').reject(&:empty?).last + page_slug = params[:page] || request.path.split('/').reject(&:empty?).last @post = Post.published.find_by( post_type: PostType.find_by(name: "pages"), diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb index db6f8e4..79b34b5 100644 --- a/test/controllers/pages_controller_test.rb +++ b/test/controllers/pages_controller_test.rb @@ -42,6 +42,15 @@ class PagesControllerTest < ActionDispatch::IntegrationTest status: "published", published_at: 1.day.ago ) + + @home_page = Post.create!( + title: "Home", + slug: "home", + content: "Welcome to our site", + post_type: @pages_type, + status: "published", + published_at: 1.day.ago + ) end test "bunko_page finds page with hyphenated slug" do @@ -149,4 +158,22 @@ class PagesControllerTest < ActionDispatch::IntegrationTest assert_match "Our privacy policy", response.body assert_no_match "Our terms", response.body end + + # Custom path tests + test "bunko_page with custom path loads correct page" do + # Route defined as: bunko_page :home, path: "/home" + # Should find Post with slug "home" using params[:page] default + get "/home" + assert_response :success + assert_match "Welcome to our site", response.body + end + + test "custom path route ignores malicious query string params" do + # Attack: Try to override the home page via query string + get "/home?page=about-us" + assert_response :success + # Should still load the home page (params[:page] from route defaults takes precedence) + assert_match "Welcome to our site", response.body + assert_no_match "About our company", response.body + end end diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb index 8f31e4d..297ba25 100644 --- a/test/dummy/config/routes.rb +++ b/test/dummy/config/routes.rb @@ -4,6 +4,9 @@ # Root route root "blog#index" + # Home page - for testing bunko_page with path: "/" + bunko_page :home, path: "/home" + # Blog routes (PostType) bunko_collection :blog diff --git a/test/tasks/bunko_avo_task_test.rb b/test/tasks/bunko_avo_task_test.rb new file mode 100644 index 0000000..cb8f3b7 --- /dev/null +++ b/test/tasks/bunko_avo_task_test.rb @@ -0,0 +1,350 @@ +# frozen_string_literal: true + +require_relative "../test_helper" +require "rake" +require "fileutils" + +# Stub Avo module so tests can run without Avo gem installed +module Avo + class BaseResource; end + + class BaseAction; end + + module Filters + class SelectFilter; end + end + + module Actions; end + + module Resources; end +end + +# Prepend a module to Kernel to stub require "avo" +module AvoRequireStub + def require(name) + return true if name == "avo" + super + end +end +Kernel.prepend(AvoRequireStub) + +class BunkoAvoTaskTest < Minitest::Test + def setup + # Reset Bunko configuration before each test + Bunko.reset_configuration! + + # Clean database before each test + Post.delete_all + PostType.delete_all + + # Set up a temporary directory for test files + @destination = File.expand_path("../../tmp/avo_rake_test", __dir__) + FileUtils.rm_rf(@destination) + FileUtils.mkdir_p(@destination) + FileUtils.mkdir_p(File.join(@destination, "config/initializers")) + FileUtils.mkdir_p(File.join(@destination, "app/avo")) + + # Create Avo initializer (simulates Avo being installed) + File.write(File.join(@destination, "config/initializers/avo.rb"), <<~RUBY) + Avo.configure do |config| + config.root_path = "/avo" + end + RUBY + + # Mock Rails.root to point to our temp directory + @original_root = Rails.root + destination = @destination + Rails.singleton_class.class_eval do + define_method(:root) { Pathname.new(destination) } + end + + # Configure Bunko with test post types + Bunko.configure do |config| + config.post_type "blog" + config.post_type "docs" + end + + # Load Rails rake tasks and our bunko tasks + Dummy::Application.load_tasks if Rake::Task.tasks.empty? + + # Always force reload of bunko:avo:install task to pick up code changes + if Rake::Task.task_defined?("bunko:avo:install") + Rake::Task["bunko:avo:install"].clear + end + load File.expand_path("../../lib/tasks/bunko/avo.rake", __dir__) + end + + def teardown + # Restore Rails.root + original_root = @original_root + Rails.singleton_class.class_eval do + define_method(:root) { original_root } + end + + # Clean up temp directory + FileUtils.rm_rf(@destination) if File.exist?(@destination) + + # Clean up database + Post.delete_all + PostType.delete_all + + # Reset configuration + Bunko.configuration.post_types = [] + Bunko.configuration.collections = [] + end + + def test_avo_install_creates_post_resource + run_rake_task("bunko:avo:install") + + resource_file = File.join(@destination, "app/avo/resources/post.rb") + assert File.exist?(resource_file), "Post resource should be created" + + content = File.read(resource_file) + assert_match(/class Avo::Resources::Post < Avo::BaseResource/, content) + assert_match(/field :title/, content) + assert_match(/field :content/, content) + assert_match(/field :status/, content) + assert_match(/field :published_at/, content) + end + + def test_avo_install_creates_post_type_filter + run_rake_task("bunko:avo:install") + + filter_file = File.join(@destination, "app/avo/filters/post_type_filter.rb") + assert File.exist?(filter_file), "Post type filter should be created" + + content = File.read(filter_file) + assert_match(/class Avo::Filters::PostTypeFilter < Avo::Filters::SelectFilter/, content) + assert_match(/def apply\(request, query, value\)/, content) + assert_match(/post_type = PostType\.find_by\(name: value\)/, content) + assert_match(/query\.by_post_type\(post_type\)/, content) + end + + def test_avo_install_creates_publish_action + run_rake_task("bunko:avo:install") + + action_file = File.join(@destination, "app/avo/actions/publish_post.rb") + assert File.exist?(action_file), "Publish action should be created" + + content = File.read(action_file) + assert_match(/class Avo::Actions::PublishPost < Avo::BaseAction/, content) + assert_match(/post\.update\(status: "published"\)/, content) + assert_match(/published_at is auto-set by Bunko callback/, content) + end + + def test_avo_install_creates_unpublish_action + run_rake_task("bunko:avo:install") + + action_file = File.join(@destination, "app/avo/actions/unpublish_post.rb") + assert File.exist?(action_file), "Unpublish action should be created" + + content = File.read(action_file) + assert_match(/class Avo::Actions::UnpublishPost < Avo::BaseAction/, content) + assert_match(/post\.update\(status: "draft"\)/, content) + assert_match(/published_at is preserved/, content) + end + + def test_avo_install_with_markdown_editor + with_env("EDITOR" => "markdown") do + run_rake_task("bunko:avo:install") + end + + resource_file = File.join(@destination, "app/avo/resources/post.rb") + content = File.read(resource_file) + assert_match(/field :content, as: :markdown/, content) + end + + def test_avo_install_with_rhino_editor + with_env("EDITOR" => "rhino") do + run_rake_task("bunko:avo:install") + end + + resource_file = File.join(@destination, "app/avo/resources/post.rb") + content = File.read(resource_file) + assert_match(/field :content, as: :rhino/, content) + end + + def test_avo_install_with_tiptap_editor + with_env("EDITOR" => "tiptap") do + run_rake_task("bunko:avo:install") + end + + resource_file = File.join(@destination, "app/avo/resources/post.rb") + content = File.read(resource_file) + assert_match(/field :content, as: :tiptap/, content) + end + + def test_avo_install_with_trix_editor + with_env("EDITOR" => "trix") do + run_rake_task("bunko:avo:install") + end + + resource_file = File.join(@destination, "app/avo/resources/post.rb") + content = File.read(resource_file) + assert_match(/field :content, as: :trix/, content) + end + + def test_avo_install_with_textarea_editor + with_env("EDITOR" => "textarea") do + run_rake_task("bunko:avo:install") + end + + resource_file = File.join(@destination, "app/avo/resources/post.rb") + content = File.read(resource_file) + assert_match(/field :content, as: :textarea/, content) + end + + def test_avo_install_includes_configured_post_types_in_filter + run_rake_task("bunko:avo:install") + + filter_file = File.join(@destination, "app/avo/filters/post_type_filter.rb") + content = File.read(filter_file) + assert_match(/"Blog" => "blog"/, content) + assert_match(/"Docs" => "docs"/, content) + end + + def test_avo_install_idempotency_resource + # First run - creates files + run_rake_task("bunko:avo:install") + resource_file = File.join(@destination, "app/avo/resources/post.rb") + original_content = File.read(resource_file) + + # Modify the file + File.write(resource_file, "# Custom changes\n" + original_content) + + # Second run - should skip + output = capture_io { run_rake_task("bunko:avo:install") } + + # Verify file was not overwritten + modified_content = File.read(resource_file) + assert_match(/# Custom changes/, modified_content) + assert_match(/already exists \(skipped\)/, output.join) + end + + def test_avo_install_idempotency_filter + # First run + run_rake_task("bunko:avo:install") + filter_file = File.join(@destination, "app/avo/filters/post_type_filter.rb") + original_content = File.read(filter_file) + + # Modify the file + File.write(filter_file, "# Custom filter\n" + original_content) + + # Second run - should skip + output = capture_io { run_rake_task("bunko:avo:install") } + + modified_content = File.read(filter_file) + assert_match(/# Custom filter/, modified_content) + assert_match(/already exists \(skipped\)/, output.join) + end + + def test_avo_install_idempotency_actions + # First run + run_rake_task("bunko:avo:install") + publish_file = File.join(@destination, "app/avo/actions/publish_post.rb") + unpublish_file = File.join(@destination, "app/avo/actions/unpublish_post.rb") + + publish_content = File.read(publish_file) + unpublish_content = File.read(unpublish_file) + + # Modify the files + File.write(publish_file, "# Custom publish\n" + publish_content) + File.write(unpublish_file, "# Custom unpublish\n" + unpublish_content) + + # Second run - should skip + output = capture_io { run_rake_task("bunko:avo:install") } + + assert_match(/# Custom publish/, File.read(publish_file)) + assert_match(/# Custom unpublish/, File.read(unpublish_file)) + assert_match(/already exists \(skipped\)/, output.join) + end + + def test_avo_install_fails_without_avo_initializer + # Remove Avo initializer (simulates Avo gem installed but not initialized) + FileUtils.rm(File.join(@destination, "config/initializers/avo.rb")) + + error = assert_raises(SystemExit) do + capture_io { run_rake_task("bunko:avo:install") } + end + + assert_equal 1, error.status + end + + def test_post_type_filter_handles_blank_value + run_rake_task("bunko:avo:install") + + filter_file = File.join(@destination, "app/avo/filters/post_type_filter.rb") + content = File.read(filter_file) + + # Verify early return for blank value + assert_match(/return query if value\.blank\?/, content) + end + + def test_post_type_filter_handles_nonexistent_post_type + run_rake_task("bunko:avo:install") + + filter_file = File.join(@destination, "app/avo/filters/post_type_filter.rb") + content = File.read(filter_file) + + # Verify early return for nil post_type + assert_match(/return query if post_type\.nil\?/, content) + end + + def test_avo_install_resource_uses_record_not_post + run_rake_task("bunko:avo:install") + + resource_file = File.join(@destination, "app/avo/resources/post.rb") + content = File.read(resource_file) + + # Verify it uses 'record' for reading_time_text + assert_match(/record\.reading_time_text if record\.word_count\.present\?/, content) + refute_match(/post\.reading_time_text/, content) + end + + def test_avo_install_includes_filters_in_resource + run_rake_task("bunko:avo:install") + + resource_file = File.join(@destination, "app/avo/resources/post.rb") + content = File.read(resource_file) + + assert_match(/def filters/, content) + assert_match(/filter Avo::Filters::PostTypeFilter/, content) + end + + def test_avo_install_includes_actions_in_resource + run_rake_task("bunko:avo:install") + + resource_file = File.join(@destination, "app/avo/resources/post.rb") + content = File.read(resource_file) + + assert_match(/def actions/, content) + assert_match(/action Avo::Actions::PublishPost/, content) + assert_match(/action Avo::Actions::UnpublishPost/, content) + end + + private + + def run_rake_task(task_name) + task = Rake::Task[task_name] + task.reenable # Allow the task to be run multiple times in tests + task.invoke + end + + def with_env(env_vars) + original_values = {} + env_vars.each do |key, value| + original_values[key] = ENV[key] + ENV[key] = value + end + + yield + ensure + original_values.each do |key, value| + if value.nil? + ENV.delete(key) + else + ENV[key] = value + end + end + end +end diff --git a/test/tasks/bunko_sample_data_task_test.rb b/test/tasks/bunko_sample_data_task_test.rb index b28cc81..927c229 100644 --- a/test/tasks/bunko_sample_data_task_test.rb +++ b/test/tasks/bunko_sample_data_task_test.rb @@ -298,6 +298,56 @@ def test_sample_data_default_format_is_html ENV.delete("COUNT") end + def test_sample_data_adds_root_route_for_home_page + # Set up a temporary routes file + @routes_destination = File.expand_path("../../tmp/sample_data_routes_test", __dir__) + FileUtils.rm_rf(@routes_destination) + FileUtils.mkdir_p(File.join(@routes_destination, "config")) + + routes_file = File.join(@routes_destination, "config/routes.rb") + File.write(routes_file, <<~RUBY) + Rails.application.routes.draw do + end + RUBY + + # Create pages PostType + pages_type = PostType.create!(name: "pages", title: "Pages") + + # Configure Bunko to allow static pages + Bunko.configuration.allow_static_pages = true + + ENV["COUNT"] = "1" + + # Mock Rails.root to point to our temp directory + original_root = Rails.root + destination = @routes_destination + Rails.singleton_class.class_eval do + define_method(:root) { Pathname.new(destination) } + end + + # Run the task + run_rake_task("bunko:sample_data") + + # Verify root route was added + routes_content = File.read(routes_file) + assert_match(/root "pages#show", defaults: \{page: "home"\}/, routes_content) + + # Verify home page was created + home_page = Post.find_by(post_type: pages_type, slug: "home") + refute_nil home_page + assert_equal "Home", home_page.title + ensure + ENV.delete("COUNT") + + # Restore Rails.root + Rails.singleton_class.class_eval do + define_method(:root) { original_root } + end + + # Clean up + FileUtils.rm_rf(@routes_destination) if @routes_destination && File.exist?(@routes_destination) + end + private def run_rake_task(task_name, *args)