diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a90900a..6a140df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: bundler-cache: true - name: Lint code for consistent style - run: bin/rubocop -f github + run: bundle exec rubocop -f github test: runs-on: ubuntu-latest @@ -44,10 +44,7 @@ jobs: bundler-cache: true - name: Run tests - env: - RAILS_ENV: test - # REDIS_URL: redis://localhost:6379/0 - run: bin/rails db:test:prepare test + run: bundle exec rspec - name: Keep screenshots from failed system tests uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 0439d1f..dcd2884 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,5 @@ /doc/ /log/*.log /pkg/ -/tmp/ -/test/dummy/db/*.sqlite3 -/test/dummy/db/*.sqlite3-* -/test/dummy/log/*.log -/test/dummy/storage/ -/test/dummy/tmp/ +/spec/internal/tmp/ Claude.md diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..f8f74f2 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--color +--require spec_helper +--format documentation \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index a4fbf1d..35da460 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ PATH remote: . specs: - fileboost (0.1.0) - rails (>= 8.0.2.1) + fileboost (0.1.1) + activestorage (>= 6.0) GEM remote: https://rubygems.org/ @@ -83,10 +83,15 @@ GEM benchmark (0.4.1) bigdecimal (3.2.3) builder (3.3.0) + combustion (1.5.0) + activesupport (>= 3.0.0) + railties (>= 3.0.0) + thor (>= 0.14.6) concurrent-ruby (1.3.5) connection_pool (2.5.4) crass (1.0.6) date (3.4.1) + diff-lcs (1.6.2) drb (2.2.3) erb (5.0.2) erubi (1.13.1) @@ -126,6 +131,8 @@ GEM nio4r (2.7.4) nokogiri (1.18.9-arm64-darwin) racc (~> 1.4) + nokogiri (1.18.9-x86_64-linux-gnu) + racc (~> 1.4) parallel (1.27.0) parser (3.3.9.0) ast (~> 2.4.1) @@ -189,6 +196,27 @@ GEM regexp_parser (2.11.2) reline (0.6.2) io-console (~> 0.5) + rspec (3.13.1) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.5) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (6.1.5) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.5) rubocop (1.80.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -220,6 +248,7 @@ GEM ruby-progressbar (1.13.0) securerandom (0.4.1) sqlite3 (2.7.3-arm64-darwin) + sqlite3 (2.7.3-x86_64-linux-gnu) stringio (3.1.7) thor (1.4.0) timeout (0.4.3) @@ -238,13 +267,20 @@ GEM PLATFORMS arm64-darwin + x86_64-linux DEPENDENCIES + bundler (~> 2) + combustion (~> 1) fileboost! propshaft puma + rails (~> 8.0.0) + rake (~> 13.0) + rspec (~> 3.0) + rspec-rails (~> 6.0) rubocop-rails-omakase - sqlite3 + sqlite3 (~> 2.0, >= 0) BUNDLED WITH 2.7.1 diff --git a/Rakefile b/Rakefile index e7793b5..dbd59ee 100644 --- a/Rakefile +++ b/Rakefile @@ -1,8 +1,7 @@ require "bundler/setup" +require "bundler/gem_tasks" +require "rspec/core/rake_task" -APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) -load "rails/tasks/engine.rake" - -load "rails/tasks/statistics.rake" +RSpec::Core::RakeTask.new(:spec) -require "bundler/gem_tasks" +task default: :spec diff --git a/fileboost.gemspec b/fileboost.gemspec index 4db3941..72f01d9 100644 --- a/fileboost.gemspec +++ b/fileboost.gemspec @@ -22,8 +22,11 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency "activestorage", ">= 6.0" - spec.add_development_dependency "bundler", "~> 1.15" - spec.add_development_dependency "combustion", "~> 1.1" + spec.add_development_dependency "bundler", "~> 2" + spec.add_development_dependency "combustion", "~> 1" spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "rails", "~> 8.0.0" + spec.add_development_dependency "rspec", "~> 3.0" + spec.add_development_dependency "rspec-rails", "~> 6.0" + spec.add_development_dependency "sqlite3", "~> 2.0" end diff --git a/lib/fileboost/config.rb b/lib/fileboost/config.rb index e93cdbe..e76dce4 100644 --- a/lib/fileboost/config.rb +++ b/lib/fileboost/config.rb @@ -11,7 +11,7 @@ def initialize end def valid? - project_id.present? && token.present? + !project_id.empty? && !token.empty? end def base_url @@ -26,4 +26,4 @@ def self.config def self.configure yield(config) if block_given? end -end \ No newline at end of file +end diff --git a/lib/fileboost/engine.rb b/lib/fileboost/engine.rb index c68d898..32adc79 100644 --- a/lib/fileboost/engine.rb +++ b/lib/fileboost/engine.rb @@ -7,11 +7,5 @@ class Engine < ::Rails::Engine include Fileboost::Helpers end end - - initializer "fileboost.active_storage" do - ActiveSupport.on_load :active_storage_blob do - # Extend ActiveStorage::Blob with fileboost-specific methods if needed - end - end end end diff --git a/lib/fileboost/error_handler.rb b/lib/fileboost/error_handler.rb index 26b83d5..1354769 100644 --- a/lib/fileboost/error_handler.rb +++ b/lib/fileboost/error_handler.rb @@ -1,57 +1,7 @@ module Fileboost - class ErrorHandler - class << self - def handle_with_fallback(error_context, &block) - begin - yield - rescue StandardError => e - log_error(error_context, e) - - if Fileboost.config.fallback_to_rails - yield_fallback if block_given? - else - nil - end - end - end - - def handle_gracefully(error_context, default_value = nil, &block) - begin - yield - rescue StandardError => e - log_error(error_context, e) - default_value - end - end - - private - - def log_error(context, error) - return unless defined?(Rails) && Rails.logger - - Rails.logger.warn( - "[Fileboost] Error in #{context}: #{error.class}: #{error.message}" - ) - - # Log backtrace in development for debugging - if Rails.env.development? - Rails.logger.debug( - "[Fileboost] Backtrace:\n#{error.backtrace.take(5).join("\n")}" - ) - end - end - - def yield_fallback - # This would be implemented by the calling code - # The pattern is to pass a fallback block when needed - nil - end - end - end - # Specific exception classes for better error handling class ConfigurationError < StandardError; end class SignatureGenerationError < StandardError; end class UrlBuildError < StandardError; end class AssetPathExtractionError < StandardError; end -end \ No newline at end of file +end diff --git a/lib/fileboost/helpers.rb b/lib/fileboost/helpers.rb index e7b387b..50909cc 100644 --- a/lib/fileboost/helpers.rb +++ b/lib/fileboost/helpers.rb @@ -18,9 +18,6 @@ def fileboost_image_tag(asset, **options) # Generate the optimized URL optimized_url = fileboost_url_for(asset, resize: resize_options) - # Return empty string if no URL could be generated - return "" if optimized_url.blank? - # Use the optimized URL with Rails image_tag for consistency image_tag(optimized_url, **options) end @@ -36,16 +33,10 @@ def fileboost_image_tag(asset, **options) # fileboost_url_for(user.avatar.blob, resize: { w: 1200, h: 400, q: 85 }) def fileboost_url_for(asset, **options) # Validate that asset is an ActiveStorage object - unless valid_activestorage_asset?(asset) - Rails.logger.error("[Fileboost] Invalid asset type #{asset.class}. Only ActiveStorage objects are supported.") if defined?(Rails) - return nil - end + raise ArgumentError, "Invalid asset type #{asset.class}. Only ActiveStorage objects are supported." unless valid_activestorage_asset?(asset) # Validate configuration - unless Fileboost.config.valid? - log_configuration_warning - return nil - end + raise Fileboost::ConfigurationError, "Invalid Fileboost configuration" unless Fileboost.config.valid? # Build the optimized URL Fileboost::UrlBuilder.build_url(asset, **options) @@ -71,10 +62,13 @@ def fileboost_responsive_urls(asset, sizes, **base_options) sizes.each do |size_config| suffix = size_config[:suffix] || size_config["suffix"] size_options = size_config.except(:suffix, "suffix") - combined_options = base_options.merge(size_options) + + # Merge size options into base resize options + merged_resize_options = (base_options[:resize] || {}).merge(size_options) + combined_options = base_options.merge(resize: merged_resize_options) url = fileboost_url_for(asset, **combined_options) - urls[suffix] = url if url.present? + urls[suffix] = url if !url.nil? && !url.empty? end urls @@ -91,17 +85,5 @@ def valid_activestorage_asset?(asset) false end - - # Log configuration warnings - def log_configuration_warning - missing_configs = [] - missing_configs << "project_id" if Fileboost.config.project_id.blank? - missing_configs << "token" if Fileboost.config.token.blank? - - Rails.logger.warn( - "[Fileboost] Configuration incomplete. Missing: #{missing_configs.join(', ')}. " \ - "Set FILEBOOST_PROJECT_ID and FILEBOOST_TOKEN environment variables or configure them in your initializer." - ) if defined?(Rails) - end end end diff --git a/lib/fileboost/signature_generator.rb b/lib/fileboost/signature_generator.rb index 8660ed2..d1eb031 100644 --- a/lib/fileboost/signature_generator.rb +++ b/lib/fileboost/signature_generator.rb @@ -3,42 +3,17 @@ module Fileboost class SignatureGenerator - def self.generate(project_id:, asset_path:, params: {}) - return nil unless project_id.present? && asset_path.present? && Fileboost.config.token.present? - + def self.generate(asset_path:, params: {}) # Sort parameters for consistent signature generation sorted_params = params.sort.to_h query_string = sorted_params.map { |k, v| "#{k}=#{v}" }.join("&") # Create the signing string: project_id:asset_path:sorted_query_params - signing_string = [project_id, asset_path, query_string].join(":") - Rails.logger.debug("signature payload #{project_id}, #{asset_path}, #{query_string}, #{Fileboost.config.token}") + signing_string = [ Fileboost.config.project_id, asset_path, query_string ].join(":") # Generate HMAC-SHA256 signature for secure authentication with Fileboost.dev digest = OpenSSL::HMAC.digest("SHA256", Fileboost.config.token, signing_string) # Use URL-safe base64 encoding and remove padding for maximum URL compatibility Base64.urlsafe_encode64(digest, padding: false) - rescue StandardError => e - if defined?(Rails) && Rails.env.development? - raise e - else - Rails.logger.warn("[Fileboost] Failed to generate signature: #{e.message}") if defined?(Rails) - nil - end - end - - def self.verify_signature(project_id:, asset_path:, params: {}, signature:) - expected_signature = generate(project_id: project_id, asset_path: asset_path, params: params) - return false if expected_signature.nil? || signature.nil? - - # Use secure comparison to prevent timing attacks - ActiveSupport::SecurityUtils.secure_compare(signature, expected_signature) - rescue StandardError => e - if defined?(Rails) && Rails.env.development? - raise e - else - Rails.logger.warn("[Fileboost] Failed to verify signature: #{e.message}") if defined?(Rails) - false - end end end end diff --git a/lib/fileboost/url_builder.rb b/lib/fileboost/url_builder.rb index 1a7eb68..188e4ef 100644 --- a/lib/fileboost/url_builder.rb +++ b/lib/fileboost/url_builder.rb @@ -31,44 +31,36 @@ class UrlBuilder RESIZE_PARAMS = %w[w width h height q quality f format b blur br brightness c contrast r rotation fit].freeze def self.build_url(asset, **options) - return nil unless Fileboost.config.valid? - + raise ConfigurationError, "Invalid configuration" unless Fileboost.config.valid? + asset_path = extract_asset_path(asset) - return nil unless asset_path.present? + raise AssetPathExtractionError, "Unable to extract asset path" unless !asset_path.nil? && !asset_path.empty? project_id = Fileboost.config.project_id base_url = Fileboost.config.base_url - + # Build the full asset URL path for Fileboost.dev service full_path = "/#{project_id}#{asset_path}" - + # Extract and normalize transformation parameters transformation_params = extract_transformation_params(options) - + # Generate HMAC signature for secure authentication signature = Fileboost::SignatureGenerator.generate( - project_id: project_id, asset_path: asset_path, params: transformation_params ) - - return nil unless signature - + + raise SignatureGenerationError, "Failed to generate signature" unless signature + # Add signature to parameters all_params = transformation_params.merge("sig" => signature) - + # Build final URL uri = URI.join(base_url, full_path) uri.query = URI.encode_www_form(all_params) unless all_params.empty? - + uri.to_s - rescue StandardError => e - if defined?(Rails) && Rails.env.development? - raise e - else - Rails.logger.warn("[Fileboost] Failed to build URL: #{e.message}") if defined?(Rails) - nil - end end private @@ -78,57 +70,48 @@ def self.extract_asset_path(asset) when ActiveStorage::Blob # ActiveStorage Blob Rails.application.routes.url_helpers.rails_blob_path(asset, only_path: true) - + when ActiveStorage::Attached # ActiveStorage Attachment (has_one_attached, has_many_attached) - if asset.respond_to?(:blob) && asset.blob.present? + if asset.respond_to?(:blob) && !asset.blob.nil? Rails.application.routes.url_helpers.rails_blob_path(asset.blob, only_path: true) else - Rails.logger.warn("[Fileboost] ActiveStorage attachment has no blob") if defined?(Rails) - nil + raise AssetPathExtractionError, "ActiveStorage attachment has no blob" end - + when ActiveStorage::VariantWithRecord # ActiveStorage Variant - use blob URL to avoid triggering variant generation Rails.application.routes.url_helpers.rails_blob_path(asset.blob, only_path: true) - + else # Only ActiveStorage objects are supported - Rails.logger.warn("[Fileboost] Unsupported asset type: #{asset.class}. Only ActiveStorage objects are supported.") if defined?(Rails) - nil - end - rescue StandardError => e - if defined?(Rails) && Rails.env.development? - raise e - else - Rails.logger.warn("[Fileboost] Failed to extract asset path: #{e.message}") if defined?(Rails) - nil + raise AssetPathExtractionError, "Unsupported asset type: #{asset.class}. Only ActiveStorage objects are supported." end end def self.extract_transformation_params(options) params = {} - + # Only handle nested resize parameter if options[:resize].is_a?(Hash) resize_options = options[:resize] resize_options.each do |key, value| key_str = key.to_s - + # Only process valid resize parameters next unless RESIZE_PARAMS.include?(key_str) - + # Use alias if available param_key = PARAM_ALIASES[key_str] || key_str - + # Convert value to string and validate param_value = normalize_param_value(param_key, value) - next if param_value.blank? - + next if param_value.nil? || param_value.empty? + params[param_key] = param_value end end - + params end @@ -152,4 +135,4 @@ def self.normalize_param_value(key, value) end end end -end \ No newline at end of file +end diff --git a/lib/generators/fileboost/install_generator.rb b/lib/generators/fileboost/install_generator.rb index 61092f9..eea7c4b 100644 --- a/lib/generators/fileboost/install_generator.rb +++ b/lib/generators/fileboost/install_generator.rb @@ -4,7 +4,7 @@ module Fileboost module Generators class InstallGenerator < Rails::Generators::Base desc "Create Fileboost initializer file" - + def self.source_root @source_root ||= File.expand_path("templates", __dir__) end @@ -24,4 +24,4 @@ def readme(path) end end end -end \ No newline at end of file +end diff --git a/spec/fileboost/config_spec.rb b/spec/fileboost/config_spec.rb new file mode 100644 index 0000000..2d262f0 --- /dev/null +++ b/spec/fileboost/config_spec.rb @@ -0,0 +1,51 @@ +require "spec_helper" + +RSpec.describe Fileboost::Config do + describe "#initialize" do + it "loads configuration from environment variables" do + ENV["FILEBOOST_PROJECT_ID"] = "test_project" + ENV["FILEBOOST_TOKEN"] = "test_token" + + config = Fileboost::Config.new + + expect(config.project_id).to eq("test_project") + expect(config.token).to eq("test_token") + + ENV.delete("FILEBOOST_PROJECT_ID") + ENV.delete("FILEBOOST_TOKEN") + end + end + + describe "#valid?" do + it "returns true when both project_id and token are present" do + config = Fileboost::Config.new + config.project_id = "test_project" + config.token = "test_token" + + expect(config).to be_valid + end + + it "returns false when project_id is missing" do + config = Fileboost::Config.new + config.project_id = "" + config.token = "test_token" + + expect(config).not_to be_valid + end + + it "returns false when token is missing" do + config = Fileboost::Config.new + config.project_id = "test_project" + config.token = "" + + expect(config).not_to be_valid + end + end + + describe "#base_url" do + it "returns the hardcoded CDN domain" do + config = Fileboost::Config.new + expect(config.base_url).to eq("https://cdn.fileboost.dev") + end + end +end diff --git a/spec/fileboost/helpers_spec.rb b/spec/fileboost/helpers_spec.rb new file mode 100644 index 0000000..d168002 --- /dev/null +++ b/spec/fileboost/helpers_spec.rb @@ -0,0 +1,99 @@ +require "spec_helper" + +RSpec.describe Fileboost::Helpers do + include Fileboost::Helpers + + let(:user) { User.create!(name: "Test User") } + let(:blob) do + ActiveStorage::Blob.create_and_upload!( + io: StringIO.new("fake image data"), + filename: "test.jpg", + content_type: "image/jpeg" + ) + end + + before do + Fileboost.configure do |config| + config.project_id = "test_project" + config.token = "test_token" + end + end + + describe "#fileboost_url_for" do + it "generates URL for ActiveStorage::Blob" do + url = fileboost_url_for(blob, resize: { w: 300 }) + + expect(url).to include("https://cdn.fileboost.dev") + expect(url).to include("w=300") + expect(url).to include("sig=") + end + + it "generates URL for ActiveStorage attachment" do + user.avatar.attach(blob) + + url = fileboost_url_for(user.avatar, resize: { w: 300 }) + + expect(url).to include("https://cdn.fileboost.dev") + expect(url).to include("w=300") + end + + it "raises ArgumentError for invalid asset type" do + expect { + fileboost_url_for("invalid", resize: { w: 300 }) + }.to raise_error(ArgumentError, /Invalid asset type/) + end + + it "raises ConfigurationError for invalid configuration" do + Fileboost.config.project_id = "" + + expect { + fileboost_url_for(blob) + }.to raise_error(Fileboost::ConfigurationError) + end + end + + describe "#fileboost_image_tag" do + it "generates image tag with optimized URL" do + allow(self).to receive(:image_tag).and_return('test') + + result = fileboost_image_tag(blob, resize: { w: 300 }, alt: "test") + + expect(result).to eq('test') + end + end + + describe "#fileboost_responsive_urls" do + it "generates multiple URLs for responsive images" do + sizes = [ + { width: 400, suffix: "sm" }, + { width: 800, suffix: "md" }, + { width: 1200, suffix: "lg" } + ] + + urls = fileboost_responsive_urls(blob, sizes, resize: { quality: 85 }) + + expect(urls).to have_key("sm") + expect(urls).to have_key("md") + expect(urls).to have_key("lg") + expect(urls["sm"]).to include("w=400") + expect(urls["md"]).to include("w=800") + expect(urls["lg"]).to include("w=1200") + end + end + + describe "#valid_activestorage_asset?" do + it "returns true for ActiveStorage::Blob" do + expect(send(:valid_activestorage_asset?, blob)).to be true + end + + it "returns true for ActiveStorage::Attached" do + user.avatar.attach(blob) + expect(send(:valid_activestorage_asset?, user.avatar)).to be true + end + + it "returns false for invalid asset types" do + expect(send(:valid_activestorage_asset?, "string")).to be false + expect(send(:valid_activestorage_asset?, 123)).to be false + end + end +end diff --git a/spec/fileboost/signature_generator_spec.rb b/spec/fileboost/signature_generator_spec.rb new file mode 100644 index 0000000..4265262 --- /dev/null +++ b/spec/fileboost/signature_generator_spec.rb @@ -0,0 +1,66 @@ +require "spec_helper" + +RSpec.describe Fileboost::SignatureGenerator do + before do + Fileboost.configure do |config| + config.project_id = "test_project" + config.token = "test_token" + end + end + + describe ".generate" do + it "generates a signature for valid parameters" do + signature = Fileboost::SignatureGenerator.generate( + asset_path: "/test/path.jpg", + params: { "w" => "300", "h" => "200" } + ) + + expect(signature).to be_a(String) + expect(signature).not_to be_empty + end + + it "generates consistent signatures for same inputs" do + params = { "w" => "300", "h" => "200" } + + signature1 = Fileboost::SignatureGenerator.generate( + asset_path: "/test/path.jpg", + params: params + ) + + signature2 = Fileboost::SignatureGenerator.generate( + asset_path: "/test/path.jpg", + params: params + ) + + expect(signature1).to eq(signature2) + end + + it "generates different signatures for different inputs" do + signature1 = Fileboost::SignatureGenerator.generate( + asset_path: "/test/path1.jpg", + params: { "w" => "300" } + ) + + signature2 = Fileboost::SignatureGenerator.generate( + asset_path: "/test/path2.jpg", + params: { "w" => "300" } + ) + + expect(signature1).not_to eq(signature2) + end + + it "parameter order does not affect signature" do + signature1 = Fileboost::SignatureGenerator.generate( + asset_path: "/test/path.jpg", + params: { "w" => "300", "h" => "200" } + ) + + signature2 = Fileboost::SignatureGenerator.generate( + asset_path: "/test/path.jpg", + params: { "h" => "200", "w" => "300" } + ) + + expect(signature1).to eq(signature2) + end + end +end diff --git a/spec/fileboost/url_builder_spec.rb b/spec/fileboost/url_builder_spec.rb new file mode 100644 index 0000000..44c7fb5 --- /dev/null +++ b/spec/fileboost/url_builder_spec.rb @@ -0,0 +1,104 @@ +require "spec_helper" + +RSpec.describe Fileboost::UrlBuilder do + let(:user) { User.create!(name: "Test User") } + let(:blob) do + ActiveStorage::Blob.create_and_upload!( + io: StringIO.new("fake image data"), + filename: "test.jpg", + content_type: "image/jpeg" + ) + end + + before do + Fileboost.configure do |config| + config.project_id = "test_project" + config.token = "test_token" + end + end + + describe ".build_url" do + context "with valid ActiveStorage::Blob" do + it "builds a URL with signature" do + url = Fileboost::UrlBuilder.build_url(blob, resize: { w: 300, h: 200 }) + + expect(url).to include("https://cdn.fileboost.dev") + expect(url).to include("test_project") + expect(url).to include("sig=") + expect(url).to include("w=300") + expect(url).to include("h=200") + end + end + + context "with ActiveStorage attachment" do + it "builds a URL for has_one_attached" do + user.avatar.attach(blob) + + url = Fileboost::UrlBuilder.build_url(user.avatar, resize: { w: 300 }) + + expect(url).to include("https://cdn.fileboost.dev") + expect(url).to include("w=300") + expect(url).to include("sig=") + end + end + + context "with invalid configuration" do + it "raises ConfigurationError when config is invalid" do + Fileboost.config.project_id = "" + + expect { + Fileboost::UrlBuilder.build_url(blob) + }.to raise_error(Fileboost::ConfigurationError) + end + end + + context "with unsupported asset type" do + it "raises AssetPathExtractionError" do + expect { + Fileboost::UrlBuilder.build_url("invalid_asset") + }.to raise_error(Fileboost::AssetPathExtractionError) + end + end + end + + describe ".extract_transformation_params" do + it "extracts and normalizes resize parameters" do + options = { resize: { width: 300, height: 200, quality: 85, format: "webp" } } + + params = Fileboost::UrlBuilder.send(:extract_transformation_params, options) + + expect(params["w"]).to eq("300") + expect(params["h"]).to eq("200") + expect(params["q"]).to eq("85") + expect(params["f"]).to eq("webp") + end + + it "ignores invalid parameters" do + options = { resize: { invalid_param: "value", w: 300 } } + + params = Fileboost::UrlBuilder.send(:extract_transformation_params, options) + + expect(params).not_to have_key("invalid_param") + expect(params["w"]).to eq("300") + end + end + + describe ".normalize_param_value" do + it "normalizes numeric parameters" do + expect(Fileboost::UrlBuilder.send(:normalize_param_value, "w", 300)).to eq("300") + expect(Fileboost::UrlBuilder.send(:normalize_param_value, "q", "85")).to eq("85") + expect(Fileboost::UrlBuilder.send(:normalize_param_value, "w", 0)).to be_nil + end + + it "validates format parameter" do + expect(Fileboost::UrlBuilder.send(:normalize_param_value, "f", "webp")).to eq("webp") + expect(Fileboost::UrlBuilder.send(:normalize_param_value, "f", "invalid")).to be_nil + end + + it "validates fit parameter" do + expect(Fileboost::UrlBuilder.send(:normalize_param_value, "fit", "cover")).to eq("cover") + expect(Fileboost::UrlBuilder.send(:normalize_param_value, "fit", "scale_down")).to eq("scale-down") + expect(Fileboost::UrlBuilder.send(:normalize_param_value, "fit", "invalid")).to be_nil + end + end +end diff --git a/spec/fileboost_spec.rb b/spec/fileboost_spec.rb new file mode 100644 index 0000000..88e9bd2 --- /dev/null +++ b/spec/fileboost_spec.rb @@ -0,0 +1,32 @@ +require "spec_helper" + +RSpec.describe Fileboost do + it "has a version number" do + expect(Fileboost::VERSION).not_to be nil + end + + describe ".configure" do + it "yields configuration object" do + expect { |b| Fileboost.configure(&b) }.to yield_with_args(Fileboost::Config) + end + + it "allows setting configuration values" do + Fileboost.configure do |config| + config.project_id = "new_project" + config.token = "new_token" + end + + expect(Fileboost.config.project_id).to eq("new_project") + expect(Fileboost.config.token).to eq("new_token") + end + end + + describe ".config" do + it "returns the same configuration instance" do + config1 = Fileboost.config + config2 = Fileboost.config + + expect(config1).to be(config2) + end + end +end diff --git a/spec/internal/app/models/application_record.rb b/spec/internal/app/models/application_record.rb new file mode 100644 index 0000000..b63caeb --- /dev/null +++ b/spec/internal/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/spec/internal/app/models/user.rb b/spec/internal/app/models/user.rb new file mode 100644 index 0000000..74ea160 --- /dev/null +++ b/spec/internal/app/models/user.rb @@ -0,0 +1,4 @@ +class User < ApplicationRecord + has_one_attached :avatar + has_many_attached :images +end diff --git a/spec/internal/config/database.yml b/spec/internal/config/database.yml new file mode 100644 index 0000000..235a242 --- /dev/null +++ b/spec/internal/config/database.yml @@ -0,0 +1,3 @@ +test: + adapter: sqlite3 + database: ":memory:" \ No newline at end of file diff --git a/spec/internal/config/routes.rb b/spec/internal/config/routes.rb new file mode 100644 index 0000000..18f456b --- /dev/null +++ b/spec/internal/config/routes.rb @@ -0,0 +1,3 @@ +Rails.application.routes.draw do + # Routes for testing purposes +end diff --git a/spec/internal/config/storage.yml b/spec/internal/config/storage.yml new file mode 100644 index 0000000..d2bf1b6 --- /dev/null +++ b/spec/internal/config/storage.yml @@ -0,0 +1,3 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> \ No newline at end of file diff --git a/spec/internal/db/schema.rb b/spec/internal/db/schema.rb new file mode 100644 index 0000000..7b0a8c2 --- /dev/null +++ b/spec/internal/db/schema.rb @@ -0,0 +1,37 @@ +ActiveRecord::Schema.define do + # Schema for testing ActiveStorage functionality + create_table "active_storage_blobs", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.string "service_name", null: false + t.bigint "byte_size", null: false + t.string "checksum" + t.datetime "created_at", null: false + t.index [ "key" ], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_attachments", force: :cascade do |t| + t.string "name", null: false + t.references "record", null: false, polymorphic: true, index: false + t.references "blob", null: false + t.datetime "created_at", null: false + t.index [ "record_type", "record_id", "name", "blob_id" ], name: "index_active_storage_attachments_uniqueness", unique: true + t.foreign_key "active_storage_blobs", column: "blob_id" + end + + create_table "active_storage_variant_records", force: :cascade do |t| + t.belongs_to "blob", null: false, index: false + t.string "variation_digest", null: false + t.index [ "blob_id", "variation_digest" ], name: "index_active_storage_variant_records_uniqueness", unique: true + t.foreign_key "active_storage_blobs", column: "blob_id" + end + + # Test model for ActiveStorage attachments + create_table "users", force: :cascade do |t| + t.string "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end +end diff --git a/spec/internal/log/test.log b/spec/internal/log/test.log new file mode 100644 index 0000000..e69de29 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..f297737 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,54 @@ +require "bundler/setup" + +# Configure Rails Environment +ENV["RAILS_ENV"] = "test" + +require "combustion" + +# Initialize Combustion with the components we need +Combustion.initialize! :active_storage, :action_controller, :action_view do + config.load_defaults Rails::VERSION::STRING.to_f + + # Configure Active Storage for testing + config.active_storage.variant_processor = :mini_magick + config.active_storage.service = :test +end + +require "rspec/rails" +require "fileboost" + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on Module and main + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end + + # Include Rails test helpers + config.include Rails.application.routes.url_helpers + + # Set up database before suite + config.before(:suite) do + Rails.logger.level = Logger::WARN unless ENV["VERBOSE"] + + # Ensure database tables are created + ActiveRecord::Schema.verbose = false + load Rails.root.join("db", "schema.rb") + end + + # Clean up after each test + config.around(:each) do |example| + # Reset Fileboost configuration before each test + Fileboost.instance_variable_set(:@config, nil) + + # Run test in a transaction that gets rolled back + ActiveRecord::Base.transaction do + example.run + raise ActiveRecord::Rollback + end + end +end diff --git a/test/fileboost_test.rb b/test/fileboost_test.rb deleted file mode 100644 index 3e9f3df..0000000 --- a/test/fileboost_test.rb +++ /dev/null @@ -1,40 +0,0 @@ -require "test_helper" - -class FileboostTest < ActiveSupport::TestCase - def setup - # Reset configuration for each test - Fileboost.config.project_id = "test-project" - Fileboost.config.token = "test-token" - end - - test "it has a version number" do - assert Fileboost::VERSION - end - - test "configuration can be set via block" do - Fileboost.configure do |config| - config.project_id = "new-project" - config.token = "new-token" - end - - assert_equal "new-project", Fileboost.config.project_id - assert_equal "new-token", Fileboost.config.token - end - - test "configuration validates required fields" do - Fileboost.config.project_id = nil - assert_not Fileboost.config.valid? - - Fileboost.config.project_id = "test" - Fileboost.config.token = nil - assert_not Fileboost.config.valid? - - Fileboost.config.token = "test" - assert Fileboost.config.valid? - end - - test "base_url is hardcoded to cdn.fileboost.dev" do - assert_equal "https://cdn.fileboost.dev", Fileboost.config.base_url - assert_equal "cdn.fileboost.dev", Fileboost::Config::CDN_DOMAIN - end -end diff --git a/test/resize_parameter_test.rb b/test/resize_parameter_test.rb deleted file mode 100644 index 4a76513..0000000 --- a/test/resize_parameter_test.rb +++ /dev/null @@ -1,99 +0,0 @@ -require "test_helper" - -class ResizeParameterTest < ActiveSupport::TestCase - def setup - Fileboost.config.project_id = "test-project" - Fileboost.config.token = "test-secret-key" - end - - test "extract_transformation_params handles resize parameter" do - options = { - resize: { - width: 400, - height: 300, - quality: 85, - format: :webp - }, - class: "hero-image" - } - - params = Fileboost::UrlBuilder.send(:extract_transformation_params, options) - - assert_equal "400", params["w"] - assert_equal "300", params["h"] - assert_equal "85", params["q"] - assert_equal "webp", params["f"] - assert_nil params["class"] # Should not be included in transformation params - end - - test "extract_transformation_params handles short parameter names in resize" do - options = { - resize: { - w: 400, - h: 300, - q: 85, - f: :webp - } - } - - params = Fileboost::UrlBuilder.send(:extract_transformation_params, options) - - assert_equal "400", params["w"] - assert_equal "300", params["h"] - assert_equal "85", params["q"] - assert_equal "webp", params["f"] - end - - test "extract_transformation_params ignores direct parameters without resize" do - options = { - width: 500, - height: 400, - quality: 90 - } - - params = Fileboost::UrlBuilder.send(:extract_transformation_params, options) - - # Should be empty since no resize parameter - assert_empty params - end - - test "extract_transformation_params ignores invalid resize parameters" do - options = { - resize: { - width: 400, - invalid_param: "ignored", - height: 300 - } - } - - params = Fileboost::UrlBuilder.send(:extract_transformation_params, options) - - assert_equal "400", params["w"] - assert_equal "300", params["h"] - assert_nil params["invalid_param"] - end - - test "extract_transformation_params handles empty resize parameter" do - options = { - resize: {}, - width: 400 # Should be ignored - } - - params = Fileboost::UrlBuilder.send(:extract_transformation_params, options) - - # Should be empty since resize parameter is empty hash - assert_empty params - end - - test "extract_transformation_params handles non-hash resize parameter" do - options = { - resize: "invalid", - width: 400 - } - - params = Fileboost::UrlBuilder.send(:extract_transformation_params, options) - - # Should be empty since resize parameter is not a hash - assert_empty params - end -end \ No newline at end of file diff --git a/test/signature_generator_test.rb b/test/signature_generator_test.rb deleted file mode 100644 index a5125f0..0000000 --- a/test/signature_generator_test.rb +++ /dev/null @@ -1,95 +0,0 @@ -require "test_helper" - -class SignatureGeneratorTest < ActiveSupport::TestCase - def setup - Fileboost.config.token = "test-secret-key" - end - - test "generates consistent signatures for same inputs" do - signature1 = Fileboost::SignatureGenerator.generate( - project_id: "test-project", - asset_path: "/test/image.jpg", - params: { w: "400", h: "300" } - ) - - signature2 = Fileboost::SignatureGenerator.generate( - project_id: "test-project", - asset_path: "/test/image.jpg", - params: { w: "400", h: "300" } - ) - - assert_equal signature1, signature2 - assert signature1.present? - end - - test "generates different signatures for different inputs" do - signature1 = Fileboost::SignatureGenerator.generate( - project_id: "test-project", - asset_path: "/test/image1.jpg", - params: { w: "400", h: "300" } - ) - - signature2 = Fileboost::SignatureGenerator.generate( - project_id: "test-project", - asset_path: "/test/image2.jpg", - params: { w: "400", h: "300" } - ) - - assert_not_equal signature1, signature2 - end - - test "parameter order does not affect signature" do - signature1 = Fileboost::SignatureGenerator.generate( - project_id: "test-project", - asset_path: "/test/image.jpg", - params: { w: "400", h: "300", q: "85" } - ) - - signature2 = Fileboost::SignatureGenerator.generate( - project_id: "test-project", - asset_path: "/test/image.jpg", - params: { q: "85", h: "300", w: "400" } - ) - - assert_equal signature1, signature2 - end - - test "returns nil when token is missing" do - Fileboost.config.token = nil - - signature = Fileboost::SignatureGenerator.generate( - project_id: "test-project", - asset_path: "/test/image.jpg", - params: { w: "400" } - ) - - assert_nil signature - end - - test "signature verification works correctly" do - params = { w: "400", h: "300" } - signature = Fileboost::SignatureGenerator.generate( - project_id: "test-project", - asset_path: "/test/image.jpg", - params: params - ) - - assert Fileboost::SignatureGenerator.verify_signature( - project_id: "test-project", - asset_path: "/test/image.jpg", - params: params, - signature: signature - ) - end - - test "signature verification fails with wrong signature" do - params = { w: "400", h: "300" } - - assert_not Fileboost::SignatureGenerator.verify_signature( - project_id: "test-project", - asset_path: "/test/image.jpg", - params: params, - signature: "wrong-signature" - ) - end -end \ No newline at end of file diff --git a/test/test_helper.rb b/test/test_helper.rb deleted file mode 100644 index cf669c8..0000000 --- a/test/test_helper.rb +++ /dev/null @@ -1,15 +0,0 @@ -# Configure Rails Environment -ENV["RAILS_ENV"] = "test" - -require_relative "../test/dummy/config/environment" -ActiveRecord::Migrator.migrations_paths = [ File.expand_path("../test/dummy/db/migrate", __dir__) ] -ActiveRecord::Migrator.migrations_paths << File.expand_path("../db/migrate", __dir__) -require "rails/test_help" - -# Load fixtures from the engine -if ActiveSupport::TestCase.respond_to?(:fixture_paths=) - ActiveSupport::TestCase.fixture_paths = [ File.expand_path("fixtures", __dir__) ] - ActionDispatch::IntegrationTest.fixture_paths = ActiveSupport::TestCase.fixture_paths - ActiveSupport::TestCase.file_fixture_path = File.expand_path("fixtures", __dir__) + "/files" - ActiveSupport::TestCase.fixtures :all -end diff --git a/test/url_builder_test.rb b/test/url_builder_test.rb deleted file mode 100644 index 050d6de..0000000 --- a/test/url_builder_test.rb +++ /dev/null @@ -1,24 +0,0 @@ -require "test_helper" - -class UrlBuilderTest < ActiveSupport::TestCase - def setup - Fileboost.config.project_id = "test-project" - Fileboost.config.token = "test-secret-key" - end - - test "returns nil for non-ActiveStorage objects" do - url = Fileboost::UrlBuilder.build_url("/test/image.jpg", width: 400, height: 300) - assert_nil url - - url = Fileboost::UrlBuilder.build_url("image.jpg", width: 400) - assert_nil url - end - - test "returns nil when configuration is invalid" do - Fileboost.config.project_id = nil - - url = Fileboost::UrlBuilder.build_url("any-object", width: 400) - - assert_nil url - end -end \ No newline at end of file