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('
')
+
+ result = fileboost_image_tag(blob, resize: { w: 300 }, alt: "test")
+
+ expect(result).to eq('
')
+ 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