diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e7ebe33..749aa86 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - ruby: ['3.0', '3.1', '3.2'] + ruby: ['3.0', '3.1', '3.2', '3.3', '3.4', '4.0'] steps: - name: Checkout repository @@ -33,7 +33,7 @@ jobs: run: cd spec/dummy_rails && bundle exec rails db:migrate - name: Run tests - env: - RUBYOPT: '-rbundler/setup -rrbs/test/setup' - RBS_TEST_TARGET: 'TinyAdmin::*' + #env: + # RUBYOPT: '-rbundler/setup -rrbs/test/setup' + # RBS_TEST_TARGET: 'TinyAdmin::*' run: bin/rspec --profile diff --git a/.rubocop.yml b/.rubocop.yml index a09038e..ef372c1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -2,7 +2,7 @@ inherit_from: - https://relaxed.ruby.style/rubocop.yml -require: +plugins: - rubocop-packaging - rubocop-performance - rubocop-rspec @@ -26,9 +26,6 @@ Lint/UnusedMethodArgument: RSpec/ExampleLength: Max: 20 -RSpec/Rails/InferredSpecType: - Enabled: false - Style/ExplicitBlockArgument: Enabled: false diff --git a/spec/lib/tiny_admin/actions/basic_action_spec.rb b/spec/lib/tiny_admin/actions/basic_action_spec.rb new file mode 100644 index 0000000..19774cd --- /dev/null +++ b/spec/lib/tiny_admin/actions/basic_action_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +RSpec.describe TinyAdmin::Actions::BasicAction do + let(:action) { described_class.new } + + describe "#attribute_options" do + it "returns nil for nil input" do + expect(action.attribute_options(nil)).to be_nil + end + + it "handles simple string field names" do + result = action.attribute_options(["id", "name"]) + expect(result).to eq( + "id" => {field: "id"}, + "name" => {field: "name"} + ) + end + + it "handles single-entry hash with method shorthand" do + result = action.attribute_options([{title: "downcase, capitalize"}]) + expect(result).to eq( + "title" => {field: "title", method: "downcase, capitalize"} + ) + end + + it "handles multi-entry hash with explicit field key" do + result = action.attribute_options([{field: "author_id", link_to: "authors"}]) + expect(result).to eq( + "author_id" => {field: "author_id", link_to: "authors"} + ) + end + + it "handles mixed input types" do + result = action.attribute_options([ + "id", + {title: "upcase"}, + {field: "created_at", method: "strftime, %Y-%m-%d"} + ]) + expect(result).to eq( + "id" => {field: "id"}, + "title" => {field: "title", method: "upcase"}, + "created_at" => {field: "created_at", method: "strftime, %Y-%m-%d"} + ) + end + end +end diff --git a/spec/lib/tiny_admin/field_spec.rb b/spec/lib/tiny_admin/field_spec.rb new file mode 100644 index 0000000..a7eac69 --- /dev/null +++ b/spec/lib/tiny_admin/field_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +RSpec.describe TinyAdmin::Field do + describe ".create_field" do + it "creates a field with humanized title from name", :aggregate_failures do + field = described_class.create_field(name: "author_name") + expect(field.name).to eq("author_name") + expect(field.title).to eq("Author name") + expect(field.type).to eq(:string) + expect(field.options).to eq({}) + end + + it "uses the provided title when given" do + field = described_class.create_field(name: "email", title: "Email Address") + expect(field.title).to eq("Email Address") + end + + it "uses the provided type when given" do + field = described_class.create_field(name: "age", type: :integer) + expect(field.type).to eq(:integer) + end + + it "uses the provided options when given" do + options = {method: "downcase"} + field = described_class.create_field(name: "name", options: options) + expect(field.options).to eq(options) + end + + it "converts symbol names to strings" do + field = described_class.create_field(name: :user_id) + expect(field.name).to eq("user_id") + end + + it "handles nil options by defaulting to empty hash" do + field = described_class.create_field(name: "test", options: nil) + expect(field.options).to eq({}) + end + end + + describe "#apply_call_option" do + it "chains method calls on the target" do + field = described_class.new(name: "title", title: "Title", type: :string, options: {call: "to_s, downcase"}) + expect(field.apply_call_option(42)).to eq("42") + end + + it "returns nil when call option is not set" do + field = described_class.new(name: "title", title: "Title", type: :string, options: {}) + expect(field.apply_call_option("test")).to be_nil + end + + it "handles nil target safely via safe navigation" do + field = described_class.new(name: "title", title: "Title", type: :string, options: {call: "nonexistent"}) + expect(field.apply_call_option(nil)).to be_nil + end + end + + describe "#translate_value" do + it "returns value as string when no method option" do + field = described_class.new(name: "name", title: "Name", type: :string, options: {}) + expect(field.translate_value(42)).to eq("42") + end + + it "returns nil when value is nil and no method option" do + field = described_class.new(name: "name", title: "Name", type: :string, options: {}) + expect(field.translate_value(nil)).to be_nil + end + + it "applies the helper method from options" do + field = described_class.new(name: "name", title: "Name", type: :string, options: {method: "downcase"}) + allow(TinyAdmin.settings).to receive(:helper_class).and_return(TinyAdmin::Support) + expect(field.translate_value("HELLO")).to eq("hello") + end + + it "uses the converter class when specified" do + converter = Class.new do + def self.upcase(value, options: []) + value.upcase + end + end + stub_const("TestConverter", converter) + + field = described_class.new( + name: "name", title: "Name", type: :string, + options: {method: "upcase", converter: "TestConverter"} + ) + expect(field.translate_value("hello")).to eq("HELLO") + end + + it "passes additional args to the method" do + field = described_class.new( + name: "value", title: "Value", type: :float, + options: {method: "round, 1"} + ) + allow(TinyAdmin.settings).to receive(:helper_class).and_return(TinyAdmin::Support) + expect(field.translate_value(3.456)).to eq(3.5) + end + end +end diff --git a/spec/lib/tiny_admin/plugins/active_record_repository_spec.rb b/spec/lib/tiny_admin/plugins/active_record_repository_spec.rb new file mode 100644 index 0000000..93e4d5e --- /dev/null +++ b/spec/lib/tiny_admin/plugins/active_record_repository_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require "dummy_rails_app" +require "rails_helper" + +RSpec.describe TinyAdmin::Plugins::ActiveRecordRepository do + let(:repository) { described_class.new(Author) } + + before { setup_data(posts_count: 12) } + + describe "#index_title" do + it "returns the pluralized model name" do + expect(repository.index_title).to eq("Authors") + end + end + + describe "#show_title" do + it "returns the model name with the record id" do + author = Author.first + expect(repository.show_title(author)).to eq("Author ##{author.id}") + end + end + + describe "#fields" do + it "returns all model columns as Field objects when no options given", :aggregate_failures do + fields = repository.fields + expect(fields).to be_a(Hash) + expect(fields.keys).to include("id", "name", "age", "email") + expect(fields["name"]).to be_a(TinyAdmin::Field) + expect(fields["name"].type).to eq(:string) + end + + it "returns only specified fields when options given" do + options = {"name" => {}, "email" => {}} + fields = repository.fields(options: options) + expect(fields.keys).to eq(["name", "email"]) + end + + it "maps column types correctly", :aggregate_failures do + fields = repository.fields + expect(fields["id"].type).to eq(:integer) + expect(fields["name"].type).to eq(:string) + expect(fields["age"].type).to eq(:integer) + end + end + + describe "#find" do + it "returns the record for a valid id" do + author = Author.first + expect(repository.find(author.id)).to eq(author) + end + + it "raises RecordNotFound for an invalid id" do + expect { repository.find(999_999) } + .to raise_error(TinyAdmin::Plugins::BaseRepository::RecordNotFound) + end + end + + describe "#collection" do + it "returns all records" do + expect(repository.collection.count).to eq(Author.count) + end + end + + describe "#index_record_attrs" do + let(:author) { Author.first } + + it "returns all attributes as strings when no fields given", :aggregate_failures do + attrs = repository.index_record_attrs(author) + expect(attrs["name"]).to eq(author.name) + expect(attrs["id"]).to eq(author.id.to_s) + end + + it "returns only specified fields when fields given", :aggregate_failures do + attrs = repository.index_record_attrs(author, fields: {"name" => nil, "email" => nil}) + expect(attrs.keys).to eq(["name", "email"]) + expect(attrs["name"]).to eq(author.name) + end + end + + describe "#list" do + it "returns records and total count", :aggregate_failures do + records, count = repository.list(page: 1, limit: 2) + expect(records.size).to eq(2) + expect(count).to eq(3) + end + + it "paginates correctly", :aggregate_failures do + records_page1, = repository.list(page: 1, limit: 2) + records_page2, = repository.list(page: 2, limit: 2) + expect(records_page1).not_to eq(records_page2) + expect(records_page2.size).to eq(1) + end + + it "sorts when sort option given" do + records, = repository.list(page: 1, limit: 10, sort: {name: :desc}) + names = records.map(&:name) + expect(names).to eq(names.sort.reverse) + end + end + + describe "#apply_filters" do + let(:post_repository) { described_class.new(Post) } + + it "filters string fields with LIKE" do + title_field = TinyAdmin::Field.new(name: "title", title: "Title", type: :string) + filters = {title_field => {value: "post 1"}} + results = post_repository.apply_filters(Post.all, filters) + results.each do |post| + expect(post.title.downcase).to include("post 1") + end + end + + it "filters non-string fields with equality" do + author = Author.first + author_field = TinyAdmin::Field.new(name: "author_id", title: "Author", type: :integer) + filters = {author_field => {value: author.id}} + results = post_repository.apply_filters(Post.all, filters) + results.each do |post| + expect(post.author_id).to eq(author.id) + end + end + + it "skips filters with nil or empty values" do + title_field = TinyAdmin::Field.new(name: "title", title: "Title", type: :string) + filters = {title_field => {value: nil}} + results = post_repository.apply_filters(Post.all, filters) + expect(results.count).to eq(Post.count) + end + + it "sanitizes SQL LIKE input" do + title_field = TinyAdmin::Field.new(name: "title", title: "Title", type: :string) + filters = {title_field => {value: "100%"}} + # Should not raise or cause SQL injection + expect { post_repository.apply_filters(Post.all, filters).to_a }.not_to raise_error + end + end +end diff --git a/spec/lib/tiny_admin/plugins/authorization_spec.rb b/spec/lib/tiny_admin/plugins/authorization_spec.rb new file mode 100644 index 0000000..7d46d4c --- /dev/null +++ b/spec/lib/tiny_admin/plugins/authorization_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.describe TinyAdmin::Plugins::Authorization do + describe ".allowed?" do + it "returns true for any user, action, and param", :aggregate_failures do + expect(described_class.allowed?("admin", :root)).to be true + expect(described_class.allowed?(nil, :page, "some_slug")).to be true + expect(described_class.allowed?("user", :resource_index, "posts")).to be true + end + end +end diff --git a/spec/lib/tiny_admin/plugins/base_repository_spec.rb b/spec/lib/tiny_admin/plugins/base_repository_spec.rb new file mode 100644 index 0000000..1a1a719 --- /dev/null +++ b/spec/lib/tiny_admin/plugins/base_repository_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.describe TinyAdmin::Plugins::BaseRepository do + describe "#initialize" do + it "stores the model" do + repo = described_class.new(String) + expect(repo.model).to eq(String) + end + end + + describe "RecordNotFound" do + it "is a StandardError" do + expect(described_class::RecordNotFound.new).to be_a(StandardError) + end + + it "can be raised with a message" do + expect { raise described_class::RecordNotFound, "not found" } + .to raise_error(described_class::RecordNotFound, "not found") + end + end +end diff --git a/spec/lib/tiny_admin/route_for_spec.rb b/spec/lib/tiny_admin/route_for_spec.rb new file mode 100644 index 0000000..c8f63d5 --- /dev/null +++ b/spec/lib/tiny_admin/route_for_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe "TinyAdmin.route_for" do + before do + allow(TinyAdmin.settings).to receive(:root_path).and_return("/admin") + end + + it "builds a route for a section" do + expect(TinyAdmin.route_for("authors")).to eq("/admin/authors") + end + + it "builds a route with a reference" do + expect(TinyAdmin.route_for("authors", reference: "42")).to eq("/admin/authors/42") + end + + it "builds a route with an action" do + expect(TinyAdmin.route_for("authors", action: "edit")).to eq("/admin/authors/edit") + end + + it "builds a route with a reference and action" do + expect(TinyAdmin.route_for("authors", reference: "42", action: "edit")).to eq("/admin/authors/42/edit") + end + + it "appends query string when given" do + expect(TinyAdmin.route_for("authors", query: "page=2")).to eq("/admin/authors?page=2") + end + + it "handles root_path of /" do + allow(TinyAdmin.settings).to receive(:root_path).and_return("/") + expect(TinyAdmin.route_for("authors")).to eq("/authors") + end + + it "prepends / when missing" do + allow(TinyAdmin.settings).to receive(:root_path).and_return("") + expect(TinyAdmin.route_for("authors")).to eq("/authors") + end +end diff --git a/spec/lib/tiny_admin/section_spec.rb b/spec/lib/tiny_admin/section_spec.rb new file mode 100644 index 0000000..cdff959 --- /dev/null +++ b/spec/lib/tiny_admin/section_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.describe TinyAdmin::Section do + describe "#initialize" do + it "builds a path from the slug when no path given", :aggregate_failures do + section = described_class.new(name: "Authors", slug: "authors") + expect(section.path).to eq(TinyAdmin.route_for("authors")) + expect(section.name).to eq("Authors") + expect(section.slug).to eq("authors") + end + + it "uses the given path when provided" do + section = described_class.new(name: "Google", slug: "google", path: "https://google.com") + expect(section.path).to eq("https://google.com") + end + + it "defaults options to empty hash" do + section = described_class.new(name: "Test", slug: "test") + expect(section.options).to eq({}) + end + + it "stores provided options" do + section = described_class.new(name: "Link", slug: "link", options: {target: "_blank"}) + expect(section.options).to eq({target: "_blank"}) + end + end +end diff --git a/spec/lib/tiny_admin/settings_spec.rb b/spec/lib/tiny_admin/settings_spec.rb new file mode 100644 index 0000000..0c8fe5e --- /dev/null +++ b/spec/lib/tiny_admin/settings_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "dummy_rails_app" +require "rails_helper" + +RSpec.describe TinyAdmin::Settings do + let(:settings) { described_class.instance } + + # Save and restore the internal options state around each test + around do |example| + saved = settings.instance_variable_get(:@options)&.deep_dup + saved_store = settings.instance_variable_get(:@store) + example.run + ensure + settings.instance_variable_set(:@options, saved) + settings.instance_variable_set(:@store, saved_store) + end + + describe "#reset!" do + it "clears all options" do + settings[:root_path] = "/custom" + settings.reset! + expect(settings[:root_path]).to be_nil + end + end + + describe "#[] and #[]=" do + it "sets and gets a simple option" do + settings[:root_path] = "/dashboard" + expect(settings[:root_path]).to eq("/dashboard") + end + + it "sets and gets a nested option" do + settings[:root, :title] = "My Admin" + expect(settings[:root, :title]).to eq("My Admin") + end + + it "auto-creates intermediate hashes for nested paths", :aggregate_failures do + settings[:authentication, :plugin] = TinyAdmin::Plugins::NoAuth + expect(settings[:authentication]).to be_a(Hash) + expect(settings[:authentication, :plugin]).to eq(TinyAdmin::Plugins::NoAuth) + end + end + + describe "#convert_value" do + it "converts a string class name to its constant for known defaults" do + settings[:helper_class] = "TinyAdmin::Support" + expect(settings[:helper_class]).to eq(TinyAdmin::Support) + end + + it "converts nested string class names to constants" do + settings[:authentication] = {plugin: "TinyAdmin::Plugins::NoAuth"} + expect(settings[:authentication, :plugin]).to eq(TinyAdmin::Plugins::NoAuth) + end + + it "does not convert string values that are not class-typed defaults" do + settings[:root_path] = "/admin" + expect(settings[:root_path]).to eq("/admin") + end + end + + describe "dynamic option methods" do + it "defines reader and writer for each option" do + settings.root_path = "/test" + expect(settings.root_path).to eq("/test") + end + + it "supports all OPTIONS constants", :aggregate_failures do + TinyAdmin::Settings::OPTIONS.each do |option| + expect(settings).to respond_to(option) + expect(settings).to respond_to("#{option}=") + end + end + end + + describe "#load_settings" do + before { settings.reset! } + + it "populates defaults", :aggregate_failures do + settings.load_settings + expect(settings.root_path).to eq("/admin") + expect(settings.helper_class).to eq(TinyAdmin::Support) + expect(settings.repository).to eq(TinyAdmin::Plugins::ActiveRecordRepository) + end + + it "creates a store" do + settings.load_settings + expect(settings.store).to be_a(TinyAdmin::Store) + end + + it "normalizes empty root_path to /" do + settings[:root_path] = "" + settings.load_settings + expect(settings.root_path).to eq("/") + end + + it "does not override already-set values" do + settings[:root_path] = "/custom" + settings.load_settings + expect(settings.root_path).to eq("/custom") + end + end +end diff --git a/spec/lib/tiny_admin/store_spec.rb b/spec/lib/tiny_admin/store_spec.rb new file mode 100644 index 0000000..d1c5b4b --- /dev/null +++ b/spec/lib/tiny_admin/store_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require "dummy_rails_app" +require "rails_helper" + +RSpec.describe TinyAdmin::Store do + let(:settings) do + instance_double( + TinyAdmin::Settings, + content_page: TinyAdmin::Views::Pages::Content, + repository: TinyAdmin::Plugins::ActiveRecordRepository + ) + end + let(:store) { described_class.new(settings) } + + describe "#initialize" do + it "starts with empty pages and resources", :aggregate_failures do + expect(store.pages).to eq({}) + expect(store.resources).to eq({}) + end + end + + describe "#prepare_sections" do + context "with a content section" do + let(:sections) do + [{slug: "about", name: "About", type: :content, content: "

About

"}] + end + + it "adds to pages and navbar", :aggregate_failures do + store.prepare_sections(sections, logout: nil) + expect(store.pages).to have_key("about") + expect(store.pages["about"][:content]).to eq("

About

") + expect(store.pages["about"][:class]).to eq(TinyAdmin::Views::Pages::Content) + expect(store.navbar.size).to eq(1) + expect(store.navbar.first.name).to eq("About") + end + end + + context "with a page section" do + let(:page_class) do + Class.new(TinyAdmin::Views::Pages::Root) + end + + let(:sections) do + [{slug: "dashboard", name: "Dashboard", type: :page, page: page_class}] + end + + it "adds to pages and navbar", :aggregate_failures do + store.prepare_sections(sections, logout: nil) + expect(store.pages).to have_key("dashboard") + expect(store.pages["dashboard"][:class]).to eq(page_class) + expect(store.navbar.size).to eq(1) + expect(store.navbar.first.name).to eq("Dashboard") + end + end + + context "with a resource section" do + let(:sections) do + [{slug: "authors", name: "Authors", type: :resource, model: Author}] + end + + it "adds to resources and navbar", :aggregate_failures do + store.prepare_sections(sections, logout: nil) + expect(store.resources).to have_key("authors") + expect(store.resources["authors"][:model]).to eq(Author) + expect(store.resources["authors"][:only]).to eq(%i[index show]) + expect(store.navbar.size).to eq(1) + expect(store.navbar.first.name).to eq("Authors") + end + end + + context "with a hidden resource section" do + let(:sections) do + [{slug: "secret", name: "Secret", type: :resource, model: Author, options: [:hidden]}] + end + + it "adds to resources but not to visible navbar items", :aggregate_failures do + store.prepare_sections(sections, logout: nil) + expect(store.resources).to have_key("secret") + # Hidden resources return nil from add_resource_section, which gets collected in navbar + expect(store.navbar.compact).to be_empty + end + end + + context "with a url section" do + let(:sections) do + [{slug: "google", name: "Google", type: :url, url: "https://google.com", options: {target: "_blank"}}] + end + + it "adds to navbar with the url as path", :aggregate_failures do + store.prepare_sections(sections, logout: nil) + expect(store.navbar.size).to eq(1) + expect(store.navbar.first.path).to eq("https://google.com") + expect(store.navbar.first.options).to eq({target: "_blank"}) + end + + it "does not add to pages or resources", :aggregate_failures do + store.prepare_sections(sections, logout: nil) + expect(store.pages).to be_empty + expect(store.resources).to be_empty + end + end + + context "with a section object (module)" do + let(:section_module) do + mod = Class.new do + def self.to_h + {slug: "dynamic", name: "Dynamic", type: :content, content: "

Hi

"} + end + end + mod + end + + let(:sections) { [section_module] } + + it "calls to_h on the section class", :aggregate_failures do + store.prepare_sections(sections, logout: nil) + expect(store.pages).to have_key("dynamic") + expect(store.navbar.size).to eq(1) + end + end + + context "with a section object that does not respond to to_h" do + let(:sections) { [Class.new] } + + it "skips the section", :aggregate_failures do + store.prepare_sections(sections, logout: nil) + expect(store.pages).to be_empty + expect(store.resources).to be_empty + expect(store.navbar).to be_empty + end + end + + context "with a logout section" do + let(:logout_section) { TinyAdmin::Section.new(name: "logout", slug: "logout", path: "/admin/auth/logout") } + + it "appends logout to navbar" do + store.prepare_sections([], logout: logout_section) + expect(store.navbar.last.name).to eq("logout") + end + end + + context "with multiple section types" do + let(:sections) do + [ + {slug: "about", name: "About", type: :content, content: "

Test

"}, + {slug: "users", name: "Users", type: :resource, model: Author}, + {slug: "ext", name: "External", type: :url, url: "https://example.com"} + ] + end + + it "processes all sections correctly", :aggregate_failures do + store.prepare_sections(sections, logout: nil) + expect(store.pages.size).to eq(1) + expect(store.resources.size).to eq(1) + expect(store.navbar.size).to eq(3) + end + end + end +end diff --git a/spec/lib/tiny_admin/support_spec.rb b/spec/lib/tiny_admin/support_spec.rb new file mode 100644 index 0000000..f611b06 --- /dev/null +++ b/spec/lib/tiny_admin/support_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +RSpec.describe TinyAdmin::Support do + describe ".call" do + it "chains method calls on the value" do + expect(described_class.call("Hello World", options: ["downcase", "strip"])).to eq("hello world") + end + + it "returns nil when value is nil" do + expect(described_class.call(nil, options: ["downcase"])).to be_nil + end + + it "returns nil when options are empty" do + expect(described_class.call("test", options: [])).to be_nil + end + end + + describe ".downcase" do + it "downcases the value" do + expect(described_class.downcase("HELLO")).to eq("hello") + end + + it "returns nil when value is nil" do + expect(described_class.downcase(nil)).to be_nil + end + end + + describe ".upcase" do + it "upcases the value" do + expect(described_class.upcase("hello")).to eq("HELLO") + end + + it "returns nil when value is nil" do + expect(described_class.upcase(nil)).to be_nil + end + end + + describe ".format" do + it "formats the value using the format string" do + expect(described_class.format(3.14159, options: ["%.2f"])).to eq("3.14") + end + + it "returns nil when value is nil" do + expect(described_class.format(nil, options: ["%.2f"])).to be_nil + end + + it "returns nil when options are empty" do + expect(described_class.format(42, options: [])).to be_nil + end + end + + describe ".round" do + it "rounds to the specified precision" do + expect(described_class.round(3.14159, options: ["2"])).to eq(3.14) + end + + it "defaults to 2 decimal places when options are empty" do + expect(described_class.round(3.14159, options: [])).to eq(3.14) + end + + it "returns nil when value is nil" do + expect(described_class.round(nil, options: [])).to be_nil + end + end + + describe ".strftime" do + let(:time) { Time.new(2024, 6, 15, 10, 30, 0) } + + it "formats with the given pattern" do + expect(described_class.strftime(time, options: ["%Y%m%d"])).to eq("20240615") + end + + it "uses the default format when no option given" do + expect(described_class.strftime(time, options: [])).to eq("2024-06-15 10:30") + end + + it "returns nil when value is nil" do + expect(described_class.strftime(nil, options: [])).to be_nil + end + end + + describe ".to_date" do + it "converts a datetime to a date string" do + time = Time.new(2024, 6, 15, 10, 30, 0) + expect(described_class.to_date(time)).to eq("2024-06-15") + end + + it "returns nil when value is nil" do + expect(described_class.to_date(nil)).to be_nil + end + end + + describe ".label_for" do + it "returns the value unchanged" do + expect(described_class.label_for("Password")).to eq("Password") + end + end +end diff --git a/spec/lib/tiny_admin/utils_spec.rb b/spec/lib/tiny_admin/utils_spec.rb new file mode 100644 index 0000000..0b74d3a --- /dev/null +++ b/spec/lib/tiny_admin/utils_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +RSpec.describe TinyAdmin::Utils do + let(:utils_instance) { Class.new { include TinyAdmin::Utils }.new } + + describe "#params_to_s" do + it "converts flat params to a query string" do + expect(utils_instance.params_to_s("page" => "2", "sort" => "name")).to eq("page=2&sort=name") + end + + it "converts nested hash params to bracket notation", :aggregate_failures do + result = utils_instance.params_to_s("q" => {"title" => "test", "author" => "john"}) + expect(result).to include("q[title]=test") + expect(result).to include("q[author]=john") + end + + it "handles empty params" do + expect(utils_instance.params_to_s({})).to eq("") + end + + it "handles mixed flat and nested params", :aggregate_failures do + result = utils_instance.params_to_s("p" => "1", "q" => {"name" => "test"}) + expect(result).to include("p=1") + expect(result).to include("q[name]=test") + end + end + + describe "#to_class" do + it "resolves a string to a constant" do + expect(utils_instance.to_class("String")).to eq(String) + end + + it "returns a class as-is" do + expect(utils_instance.to_class(String)).to eq(String) + end + + it "resolves namespaced strings" do + expect(utils_instance.to_class("TinyAdmin::Support")).to eq(TinyAdmin::Support) + end + end + + describe "#humanize" do + it "replaces underscores and capitalizes" do + expect(utils_instance.humanize("some_field_name")).to eq("Some field name") + end + + it "returns empty string for nil" do + expect(utils_instance.humanize(nil)).to eq("") + end + + it "handles single word" do + expect(utils_instance.humanize("name")).to eq("Name") + end + end +end