diff --git a/app/controllers/alchemy/pages_controller.rb b/app/controllers/alchemy/pages_controller.rb index 7b3f1fd8ee..a01fb8abd7 100644 --- a/app/controllers/alchemy/pages_controller.rb +++ b/app/controllers/alchemy/pages_controller.rb @@ -126,10 +126,11 @@ def load_index_page def load_page page_not_found! unless Current.language - @page ||= Current.language.pages.contentpages.find_by( - urlname: params[:urlname], - language_code: params[:locale] || Current.language.code - ) + result = PageFinder.new(params[:urlname]).call + if result + @page ||= result.page + params.merge!(result.extracted_params) + end Current.page = @page end diff --git a/app/models/alchemy/page.rb b/app/models/alchemy/page.rb index 28c399b121..e653eebed2 100644 --- a/app/models/alchemy/page.rb +++ b/app/models/alchemy/page.rb @@ -305,8 +305,8 @@ def find_elements(options = {}) # = The url_path for this page # # @see Alchemy::Page::UrlPath#call - def url_path(optional_params = {}) - self.class.url_path_class.new(self, optional_params).call + def url_path(optional_params = {}, wildcard_params: {}) + self.class.url_path_class.new(self, optional_params, wildcard_params: wildcard_params).call end # The page's view partial is dependent from its page layout diff --git a/app/models/alchemy/page/page_naming.rb b/app/models/alchemy/page/page_naming.rb index 0b2d9bc823..2f5baaf6a3 100644 --- a/app/models/alchemy/page/page_naming.rb +++ b/app/models/alchemy/page/page_naming.rb @@ -8,6 +8,8 @@ module PageNaming RESERVED_URLNAMES = %w[admin messages new] + delegate :wildcard_url, to: :definition + included do before_validation :set_urlname, if: :renamed?, @@ -18,6 +20,7 @@ module PageNaming validates :urlname, uniqueness: {scope: [:language_id, :layoutpage], if: -> { urlname.present? }, case_sensitive: false}, exclusion: {in: RESERVED_URLNAMES} + validate :unique_wildcard_param_keys, if: :has_wildcard_url? after_update :update_descendants_urlnames, if: :saved_change_to_urlname? @@ -42,13 +45,32 @@ def update_urlname! end end - # Returns always the last part of a urlname path + # Returns wildcard url param or the last part of an urlname path def slug urlname.to_s.split("/").last end + def has_wildcard_url? + wildcard_url.present? + end + private + def unique_wildcard_param_keys + conflicting = PageDefinition.all.find do |other| + other.name != page_layout && other.wildcard_url == wildcard_url + end + + if conflicting + errors.add( + :page_layout, + :conflicting_wildcard_param_key, + param: wildcard_url, + conflicting_layout: conflicting.name + ) + end + end + def update_descendants_urlnames reload descendants.each(&:update_urlname!) @@ -67,13 +89,14 @@ def set_urlname end # Returns the full nested urlname. - # + # Uses the wildcard_url from the page definition if present, + # otherwise converts the slug or name to a url-friendly string. def nested_url_name - converted_url_name = convert_to_urlname(slug.blank? ? name : slug) + url_part = wildcard_url.presence || convert_to_urlname(slug.blank? ? name : slug) if parent&.language_root? - converted_url_name + url_part else - [parent&.urlname, converted_url_name].compact.join("/") + [parent&.urlname, url_part].compact.join("/") end end end diff --git a/app/models/alchemy/page/url_path.rb b/app/models/alchemy/page/url_path.rb index dc0ce4a886..cf0fa3001b 100644 --- a/app/models/alchemy/page/url_path.rb +++ b/app/models/alchemy/page/url_path.rb @@ -20,11 +20,12 @@ class Page # link_to page.url # class UrlPath - def initialize(page, optional_params = {}) + def initialize(page, optional_params = {}, wildcard_params: {}) @page = page @language = @page.language @site = @language.site @optional_params = optional_params + @wildcard_params = wildcard_params end def call @@ -64,7 +65,11 @@ def language_path end def page_path - "#{root_path}#{@page.urlname}" + urlname = @page.urlname + @wildcard_params.each do |key, val| + urlname = urlname.gsub(":#{key}", val.to_s) + end + "#{root_path}#{urlname}" end def root_path diff --git a/app/models/alchemy/page_definition.rb b/app/models/alchemy/page_definition.rb index b979bbe059..ed8aa5d60c 100644 --- a/app/models/alchemy/page_definition.rb +++ b/app/models/alchemy/page_definition.rb @@ -20,6 +20,7 @@ class PageDefinition attribute :hide, :boolean, default: false attribute :editable_by attribute :hint + attribute :wildcard_url, Alchemy::WildcardUrlType.new # Needs to be down here in order to have the attribute reader # available after the attribute is defined. diff --git a/app/services/alchemy/page_finder.rb b/app/services/alchemy/page_finder.rb new file mode 100644 index 0000000000..8f91340ce5 --- /dev/null +++ b/app/services/alchemy/page_finder.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Alchemy + class PageFinder + attr_reader :urlname + + Result = Data.define(:page, :extracted_params) + + def initialize(urlname) + @urlname = urlname + end + + # @return [PageFinder::Result, nil] + def call + return if urlname.blank? + + find_by_urlname || find_by_wildcard_url + end + + private + + # Finds a page by exact urlname match within the current language. + def find_by_urlname + page = Current.language.pages.contentpages.find_by(urlname: urlname) + Result.new(page: page, extracted_params: ActionController::Parameters.new.permit!) if page + end + + # Finds a page whose urlname pattern matches the given URL. + # Loads all pages whose urlname contains a `:param` segment in a + # single SQL query, then matches each in Ruby. + # + # A urlname may contain more than one `:param` segment when a wildcard + # page is nested under another wildcard page. + def find_by_wildcard_url + return unless any_wildcard_definitions? + + url_depth = urlname.count("/") + + # find pages ordered by tree position so we can return the first match + wildcard_pages = Current.language.pages.contentpages + .where("urlname LIKE ? OR urlname LIKE ?", ":%", "%/:%") + .order(:lft) + + wildcard_pages.each do |wildcard_page| + next if wildcard_page.urlname.count("/") != url_depth + + matched_params = match_url_pattern(wildcard_page) + next unless matched_params + + # return the first match + return Result.new( + page: wildcard_page, + extracted_params: ActionController::Parameters.new(matched_params).permit! + ) + end + + nil + end + + # Matches the urlname against a page's urlname pattern. + # Static segments must match literally; each `:param` segment is captured + # as a single URL segment. + # + # @param wildcard_page [Alchemy::Page] a page whose urlname contains one or more `:param` segments + # @return [Hash, nil] matched params or nil + def match_url_pattern(wildcard_page) + regex_parts = wildcard_page.urlname.split("/").map do |segment| + next Regexp.escape(segment) unless segment.start_with?(":") + + "(?<#{segment[1..]}>[^/]+)" + end + + match = Regexp.new("\\A#{regex_parts.join("/")}\\z").match(urlname) + return unless match + + match.named_captures.transform_keys(&:to_sym) + end + + # @return [Boolean] whether any page definition declares a wildcard_url + def any_wildcard_definitions? + PageDefinition.all.any? { |d| d.wildcard_url.present? } + end + end +end diff --git a/app/types/alchemy/wildcard_url_type.rb b/app/types/alchemy/wildcard_url_type.rb new file mode 100644 index 0000000000..51d946dd3a --- /dev/null +++ b/app/types/alchemy/wildcard_url_type.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Alchemy + class WildcardUrlType < ActiveModel::Type::Value + def cast(value) + case value + when nil then nil + when Symbol, String then normalize(value) + else value + end + end + + def assert_valid_value(value) + case value + when nil + nil + when Symbol, String + assert_valid_param!(normalize(value)) + else + raise ArgumentError, "#{value.inspect} is not a valid wildcard_url. Must be a Symbol or String." + end + end + + private + + # Normalizes a wildcard_url input to a String. Symbols are turned into a + # dynamic segment with a leading colon (`:slug` => `":slug"`). + # + # @param value [String, Symbol] + # @return [String] + def normalize(value) + value.is_a?(Symbol) ? ":#{value}" : value.to_s + end + + def assert_valid_param!(value) + if value.include?("/") + raise ArgumentError, + "wildcard_url #{value.inspect}: cannot contain \"/\". " \ + "Must be a single URL segment." + end + + unless value.match?(/:\w+/) + raise ArgumentError, + "wildcard_url #{value.inspect}: must contain a dynamic segment, e.g. \":id\"." + end + end + end +end diff --git a/app/views/alchemy/admin/pages/_form.html.erb b/app/views/alchemy/admin/pages/_form.html.erb index ea8740d557..9b9ec101ae 100644 --- a/app/views/alchemy/admin/pages/_form.html.erb +++ b/app/views/alchemy/admin/pages/_form.html.erb @@ -18,7 +18,10 @@ <%= f.input :name, autofocus: true %> - <%= f.input :urlname, as: 'string', input_html: {value: @page.slug}, label: Alchemy::Page.human_attribute_name(:slug) %> + <%= f.input :urlname, as: 'string', input_html: { + value: @page.slug, + disabled: @page.has_wildcard_url? + }, label: Alchemy::Page.human_attribute_name(:slug) %> <%= f.fields_for :draft_version, @page.draft_version do |v| %> diff --git a/config/locales/alchemy.en.yml b/config/locales/alchemy.en.yml index cfadd57c76..cfaaabde7a 100644 --- a/config/locales/alchemy.en.yml +++ b/config/locales/alchemy.en.yml @@ -834,6 +834,8 @@ en: base: restrict_dependent_destroy: has_many: "There are still %{record} attached to this page. Please remove them first." + page_layout: + conflicting_wildcard_param_key: "has a conflicting wildcard param \"%{param}\" already used by the \"%{conflicting_layout}\" page layout" descendants: still_attached_to_nodes: "The following descendant pages are still attached to menu nodes: %{page_names}. Please remove them first." alchemy/element: diff --git a/spec/controllers/alchemy/pages_controller_spec.rb b/spec/controllers/alchemy/pages_controller_spec.rb index cdc00b12f2..98242e4e13 100644 --- a/spec/controllers/alchemy/pages_controller_spec.rb +++ b/spec/controllers/alchemy/pages_controller_spec.rb @@ -257,5 +257,122 @@ module Alchemy end end end + + describe "Wildcard URL matching" do + before do + PageDefinition.reset! + end + + context "with a child page that has wildcard_url (replaces slug)" do + let!(:products_page) do + create( + :alchemy_page, + :public, + name: "Products", + page_layout: "standard", + parent: default_language_root, + language: default_language + ) + end + + let!(:product_detail_page) do + create( + :alchemy_page, + :public, + name: "Product Details", + page_layout: "page_with_wildcard_url", + parent: products_page, + language: default_language + ) + end + + it "matches a dynamic path and sets params" do + get :show, params: {urlname: "products/42"} + expect(assigns(:page)).to eq(product_detail_page) + expect(controller.params[:slug]).to eq("42") + end + end + + context "exact page match takes priority over pattern" do + let!(:products_page) do + create( + :alchemy_page, + :public, + name: "Products", + page_layout: "standard", + parent: default_language_root, + language: default_language + ) + end + + let!(:product_detail_page) do + create( + :alchemy_page, + :public, + name: "Product Details", + page_layout: "page_with_wildcard_url", + parent: products_page, + language: default_language + ) + end + + let!(:child_page) do + create( + :alchemy_page, + :public, + name: "Featured", + page_layout: "standard", + parent: product_detail_page, + language: default_language + ) + end + + it "loads the exact page match, not the pattern match" do + get :show, params: {urlname: child_page.urlname} + expect(assigns(:page)).to eq(child_page) + end + end + + context "with hierarchical patterns (grandchild under pattern page)" do + let!(:products_page) do + create( + :alchemy_page, + :public, + name: "Products", + page_layout: "standard", + parent: default_language_root, + language: default_language + ) + end + + let!(:product_detail_page) do + create( + :alchemy_page, + :public, + name: "Product Details", + page_layout: "page_with_wildcard_url", + parent: products_page, + language: default_language + ) + end + + let!(:comments_page) do + create( + :alchemy_page, + :public, + name: "Comments", + page_layout: "standard", + parent: product_detail_page, + language: default_language + ) + end + + it "matches a grandchild page with parent's pattern segment" do + get :show, params: {urlname: "products/42/comments"} + expect(assigns(:page)).to eq(comments_page) + expect(controller.params[:slug]).to eq("42") + end + end + end end end diff --git a/spec/dummy/config/alchemy/page_layouts.yml b/spec/dummy/config/alchemy/page_layouts.yml index 51742ea56e..5ba9df7e3d 100644 --- a/spec/dummy/config/alchemy/page_layouts.yml +++ b/spec/dummy/config/alchemy/page_layouts.yml @@ -59,5 +59,9 @@ - menu layoutpage: true +- name: page_with_wildcard_url + wildcard_url: :slug + elements: [article] + - name: <%= 'erb_' + 'layout' %> unique: true diff --git a/spec/libraries/alchemy/configurations/format_matchers_spec.rb b/spec/libraries/alchemy/configurations/format_matchers_spec.rb new file mode 100644 index 0000000000..a65d0aa25d --- /dev/null +++ b/spec/libraries/alchemy/configurations/format_matchers_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Configurations::FormatMatchers do + subject(:format_matchers) { described_class.new } + + describe "#email" do + it "matches valid emails" do + expect("user@example.com").to match(format_matchers.email) + end + + it "does not match invalid emails" do + expect("not-an-email").not_to match(format_matchers.email) + expect("user@").not_to match(format_matchers.email) + expect("@example.com").not_to match(format_matchers.email) + end + end + + describe "#url" do + it "matches valid URLs" do + expect("example.com").to match(format_matchers.url) + expect("sub.example.com").to match(format_matchers.url) + expect("example.com:8080").to match(format_matchers.url) + expect("example.com/path").to match(format_matchers.url) + end + + it "does not match invalid URLs" do + expect("not a url").not_to match(format_matchers.url) + end + end + + describe "#link_url" do + it "matches tel: links" do + expect("tel:+1234567890").to match(format_matchers.link_url) + end + + it "matches mailto: links" do + expect("mailto:user@example.com").to match(format_matchers.link_url) + end + + it "matches absolute paths" do + expect("/some/path").to match(format_matchers.link_url) + end + + it "matches protocol URLs" do + expect("https://example.com").to match(format_matchers.link_url) + expect("http://example.com").to match(format_matchers.link_url) + end + + it "does not match relative paths" do + expect("relative/path").not_to match(format_matchers.link_url) + end + end +end diff --git a/spec/libraries/alchemy/tasks/usage_spec.rb b/spec/libraries/alchemy/tasks/usage_spec.rb index c8e525228e..0193b3f503 100644 --- a/spec/libraries/alchemy/tasks/usage_spec.rb +++ b/spec/libraries/alchemy/tasks/usage_spec.rb @@ -60,6 +60,7 @@ {"page_layout" => "everything", "count" => 0}, {"page_layout" => "footer", "count" => 0}, {"page_layout" => "news", "count" => 0}, + {"page_layout" => "page_with_wildcard_url", "count" => 0}, {"page_layout" => "readonly", "count" => 0}, {"page_layout" => "search", "count" => 0} ] diff --git a/spec/models/alchemy/page/url_path_spec.rb b/spec/models/alchemy/page/url_path_spec.rb index 9a2e789c93..20bcecb1d2 100644 --- a/spec/models/alchemy/page/url_path_spec.rb +++ b/spec/models/alchemy/page/url_path_spec.rb @@ -76,6 +76,39 @@ end end + context "with wildcard params" do + let(:parent) { create(:alchemy_page, name: "Products") } + let(:page) { create(:alchemy_page, parent: parent, name: "Product Detail", page_layout: "page_with_wildcard_url") } + + subject(:url) { described_class.new(page, wildcard_params: {slug: 42}).call } + + it "substitutes wildcards in the urlname" do + is_expected.to eq("/products/42") + end + end + + context "with wildcard params as ActionController::Parameters" do + let(:parent) { create(:alchemy_page, name: "Products") } + let(:page) { create(:alchemy_page, parent: parent, name: "Product Detail", page_layout: "page_with_wildcard_url") } + + subject(:url) { described_class.new(page, wildcard_params: ActionController::Parameters.new({slug: 42})).call } + + it "substitutes wildcards in the urlname" do + is_expected.to eq("/products/42") + end + end + + context "with wildcard and query params mixed" do + let(:parent) { create(:alchemy_page, name: "Products") } + let(:page) { create(:alchemy_page, parent: parent, name: "Product Detail", page_layout: "page_with_wildcard_url") } + + subject(:url) { described_class.new(page, {page: 2}, wildcard_params: {slug: 42}).call } + + it "substitutes wildcards and appends remaining params as query string" do + is_expected.to eq("/products/42?page=2") + end + end + context "mounted on a non-root path" do let(:page) do create(:alchemy_page) diff --git a/spec/models/alchemy/page_definition_spec.rb b/spec/models/alchemy/page_definition_spec.rb index 1b034a761d..6e3da881fb 100644 --- a/spec/models/alchemy/page_definition_spec.rb +++ b/spec/models/alchemy/page_definition_spec.rb @@ -20,6 +20,7 @@ module Alchemy it { is_expected.to have_key(:hide) } it { is_expected.to have_key(:editable_by) } it { is_expected.to have_key(:hint) } + it { is_expected.to have_key(:wildcard_url) } end describe "#blank?" do diff --git a/spec/models/alchemy/page_spec.rb b/spec/models/alchemy/page_spec.rb index a5d13b4934..a346e029e1 100644 --- a/spec/models/alchemy/page_spec.rb +++ b/spec/models/alchemy/page_spec.rb @@ -330,6 +330,42 @@ module Alchemy end end + describe ".url_path" do + let(:page) { create(:alchemy_page, name: "Foo") } + + subject { page.url_path } + + it { is_expected.to eq("/foo") } + + context "with optional parameters" do + subject { page.url_path({page: 2}) } + + it { is_expected.to eq("/foo?page=2") } + end + + context "with a custom url_path_class" do + let(:url_path_class) { Struct.new(:page, :params, :wildcard_params) { def call = "/bar" } } + + before do + described_class.url_path_class = url_path_class + end + + after do + described_class.url_path_class = Alchemy::Page::UrlPath + end + + it { is_expected.to eq("/bar") } + end + + context "with wildcard params" do + let(:page) { create(:alchemy_page, page_layout: "page_with_wildcard_url") } + + subject { page.url_path(wildcard_params: {slug: 42}) } + + it { is_expected.to eq("/42") } + end + end + describe ".all_from_clipboard_for_select" do context "with clipboard holding pages having non unique page layout" do it "should return the pages" do @@ -1876,6 +1912,59 @@ module Alchemy end end + context "with a page layout that has a wildcard_url" do + let(:parent) { create(:alchemy_page, name: "Products") } + let(:pattern_page) { create(:alchemy_page, parent: parent, name: "Product Details", page_layout: "page_with_wildcard_url") } + + it "uses the wildcard_url pattern instead of the page name" do + expect(pattern_page.urlname).to eq("products/:slug") + end + + it "uses the wildcard_url pattern as slug" do + expect(pattern_page.slug).to eq(":slug") + end + + context "with a child page under a wildcard_url page" do + let(:child) { create(:alchemy_page, parent: pattern_page, name: "Comments") } + + it "includes the parent's wildcard_url pattern in the path" do + expect(child.urlname).to eq("products/:slug/comments") + end + end + end + + context "with conflicting wildcard param keys across layouts" do + let(:parent) { create(:alchemy_page, name: "Items") } + + before do + PageDefinition.add( + name: "conflicting_layout", + wildcard_url: ":slug" + ) + end + + after { PageDefinition.reset! } + + it "is invalid when another layout already uses the same param key" do + page = build(:alchemy_page, parent: parent, name: "Item", page_layout: "conflicting_layout") + expect(page).not_to be_valid + expect(page.errors[:page_layout]).to include( + a_string_matching(/param ":slug".*"page_with_wildcard_url"/) + ) + end + end + + context "with the same wildcard layout under different parents" do + let(:parent_a) { create(:alchemy_page, name: "Section A") } + let(:parent_b) { create(:alchemy_page, name: "Section B") } + + it "allows creating pages with the same layout" do + create(:alchemy_page, parent: parent_a, name: "Detail A", page_layout: "page_with_wildcard_url") + page_b = build(:alchemy_page, parent: parent_b, name: "Detail B", page_layout: "page_with_wildcard_url") + expect(page_b).to be_valid + end + end + context "if new urlname exists as a legacy url" do it "will delete obsolete legacy_urls" do expect(page.urlname).to eq("parentparent/parent/page") @@ -1973,6 +2062,14 @@ module Alchemy expect(page.slug).to be_nil end end + + context "with a page layout that has a wildcard_url" do + let(:page) { build(:alchemy_page, page_layout: "page_with_wildcard_url", urlname: "products/:slug") } + + it "returns the wildcard_url pattern" do + expect(page.slug).to eq(":slug") + end + end end context "page status methods" do diff --git a/spec/models/alchemy/site_spec.rb b/spec/models/alchemy/site_spec.rb index d453b5004a..865c9dc539 100644 --- a/spec/models/alchemy/site_spec.rb +++ b/spec/models/alchemy/site_spec.rb @@ -170,7 +170,7 @@ module Alchemy it "returns all non 'layoutpage' page layout names" do allow(Site).to receive(:definitions).and_return([]) - expect(site.page_layout_names).to eq(%w[index readonly standard everything news search contact erb_layout]) + expect(site.page_layout_names).to eq(%w[index readonly standard everything news search contact page_with_wildcard_url erb_layout]) end context "when layoutpages are requested" do @@ -211,7 +211,8 @@ module Alchemy "contact", "footer", "erb_layout", - "search" + "search", + "page_with_wildcard_url" ]) end end diff --git a/spec/services/alchemy/page_finder_spec.rb b/spec/services/alchemy/page_finder_spec.rb new file mode 100644 index 0000000000..96c5346fcc --- /dev/null +++ b/spec/services/alchemy/page_finder_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Alchemy + RSpec.describe PageFinder do + let(:language) { create(:alchemy_language) } + let!(:language_root) do + create(:alchemy_page, :language_root, language: language) + end + + before do + PageDefinition.reset! + Current.language = language + end + + def create_page(name:, layout: "standard", parent: language_root) + create( + :alchemy_page, :public, + name: name, + page_layout: layout, + parent: parent, + language: language + ) + end + + # Page tree used by most tests: + # + # Products (standard) -> /products + # +-- Product Detail (page_with_wildcard_url) -> /products/:slug + # +-- Comments (standard) -> /products/:slug/comments + + let!(:products_page) { create_page(name: "Products") } + let!(:product_detail_page) { create_page(name: "Product Detail", layout: "page_with_wildcard_url", parent: products_page) } + let!(:comments_page) { create_page(name: "Comments", parent: product_detail_page) } + + it "finds a wildcard page and extracts params" do + result = described_class.new("products/123").call + expect(result.page).to eq(product_detail_page) + expect(result.extracted_params[:slug]).to eq("123") + end + + it "finds a page by exact urlname" do + result = described_class.new("products").call + expect(result.page).to eq(products_page) + end + + it "finds a nested page by its full urlname" do + result = described_class.new(comments_page.urlname).call + expect(result.page).to eq(comments_page) + end + + it "returns nil for a blank path" do + expect(described_class.new("").call).to be_nil + end + + it "returns nil for a path with no matching prefix" do + expect(described_class.new("other/123").call).to be_nil + end + + context "hierarchical patterns (grandchild under pattern page)" do + it "matches /products/42/comments through the pattern parent" do + result = described_class.new("products/42/comments").call + expect(result.page).to eq(comments_page) + expect(result.extracted_params[:slug]).to eq("42") + end + end + end +end diff --git a/spec/types/alchemy/wildcard_url_type_spec.rb b/spec/types/alchemy/wildcard_url_type_spec.rb new file mode 100644 index 0000000000..9576c9fd1d --- /dev/null +++ b/spec/types/alchemy/wildcard_url_type_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Alchemy + RSpec.describe WildcardUrlType do + subject(:type) { described_class.new } + + describe "#cast" do + it "returns nil for nil" do + expect(type.cast(nil)).to be_nil + end + + it "returns the String unchanged" do + expect(type.cast(":slug")).to eq(":slug") + end + + it "normalizes a Symbol to a leading-colon String" do + expect(type.cast(:slug)).to eq(":slug") + end + + it "returns unsupported values as-is" do + expect(type.cast(42)).to eq(42) + end + end + + describe "#assert_valid_value" do + it "accepts nil" do + expect { type.assert_valid_value(nil) }.not_to raise_error + end + + it "accepts a String" do + expect { type.assert_valid_value(":slug") }.not_to raise_error + end + + it "accepts a Symbol" do + expect { type.assert_valid_value(:slug) }.not_to raise_error + end + + it "raises for an unsupported value" do + expect { type.assert_valid_value(42) }.to raise_error( + ArgumentError, /is not a valid wildcard_url.*Symbol or String/ + ) + end + + it "raises for a Hash" do + expect { type.assert_valid_value({param: ":id"}) }.to raise_error( + ArgumentError, /is not a valid wildcard_url.*Symbol or String/ + ) + end + + it "raises for a value containing a slash" do + expect { type.assert_valid_value(":year/:slug") }.to raise_error( + ArgumentError, /cannot contain "\/".*single URL segment/ + ) + end + + it "raises for a value without a dynamic segment" do + expect { type.assert_valid_value("static") }.to raise_error( + ArgumentError, /must contain a dynamic segment/ + ) + end + end + + end +end