diff --git a/.bundle/config b/.bundle/config index 0146a1ce..7095f6e9 100644 --- a/.bundle/config +++ b/.bundle/config @@ -5,3 +5,4 @@ BUNDLE_CACHE_PATH: "vendor/cache" BUNDLE_CACHE_ALL: "true" BUNDLE_SPECIFIC_PLATFORM: "true" BUNDLE_NO_INSTALL: "true" +BUNDLE_DEPLOYMENT: "true" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1ae6131d..c7958903 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -78,6 +78,7 @@ The linter is powered by `rubocop` with its config file located at `.rubocop.yml - Naming of variables and methods that lead to expressions and blocks reading more like English sentences. - Less lines of code over more. Keep changes minimal and focused. - The `docs/design.md` file is the main design document for the project. It might be out-of-date but it should still contain a general high-level overview of the project. +- Do not modify the `.bundle/config` file. ## Pull Request Requirements diff --git a/.rubocop.yml b/.rubocop.yml index 62c1ca4f..cbf40f79 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -16,5 +16,9 @@ GitHub/InsecureHashAlgorithm: Exclude: - "spec/unit/lib/hooks/plugins/request_validator/hmac_spec.rb" +GitHub/AvoidObjectSendWithDynamicMethod: + Exclude: + - "spec/unit/lib/hooks/core/logger_factory_spec.rb" + Style/HashSyntax: Enabled: false diff --git a/lib/hooks/core/signal_handler.rb b/lib/hooks/core/signal_handler.rb index d9c5b62f..a6d978e3 100644 --- a/lib/hooks/core/signal_handler.rb +++ b/lib/hooks/core/signal_handler.rb @@ -33,6 +33,7 @@ def request_shutdown # Setup signal traps for graceful shutdown # NOTE: Disabled for now to let Puma handle signals properly + # :nocov: def setup_signal_traps %w[SIGINT SIGTERM].each do |signal| Signal.trap(signal) do @@ -48,6 +49,7 @@ def setup_signal_traps end end end + # :nocov: end end end diff --git a/spec/unit/hooks_spec.rb b/spec/unit/hooks_spec.rb index 6246d153..2f60539b 100644 --- a/spec/unit/hooks_spec.rb +++ b/spec/unit/hooks_spec.rb @@ -1,3 +1,59 @@ # frozen_string_literal: true require_relative "spec_helper" + +describe Hooks do + describe ".build" do + context "with default parameters" do + it "creates a builder and builds the application" do + allow(Hooks::Core::Builder).to receive(:new).and_call_original + allow_any_instance_of(Hooks::Core::Builder).to receive(:build).and_return("mock_app") + + result = Hooks.build + + expect(Hooks::Core::Builder).to have_received(:new).with(config: nil, log: nil) + expect(result).to eq("mock_app") + end + end + + context "with custom config" do + it "passes config to builder" do + config_hash = { log_level: "debug" } + allow(Hooks::Core::Builder).to receive(:new).and_call_original + allow_any_instance_of(Hooks::Core::Builder).to receive(:build).and_return("mock_app") + + result = Hooks.build(config: config_hash) + + expect(Hooks::Core::Builder).to have_received(:new).with(config: config_hash, log: nil) + expect(result).to eq("mock_app") + end + end + + context "with custom logger" do + it "passes logger to builder" do + custom_logger = double("Logger") + allow(Hooks::Core::Builder).to receive(:new).and_call_original + allow_any_instance_of(Hooks::Core::Builder).to receive(:build).and_return("mock_app") + + result = Hooks.build(log: custom_logger) + + expect(Hooks::Core::Builder).to have_received(:new).with(config: nil, log: custom_logger) + expect(result).to eq("mock_app") + end + end + + context "with both config and logger" do + it "passes both to builder" do + config_hash = { environment: "test" } + custom_logger = double("Logger") + allow(Hooks::Core::Builder).to receive(:new).and_call_original + allow_any_instance_of(Hooks::Core::Builder).to receive(:build).and_return("mock_app") + + result = Hooks.build(config: config_hash, log: custom_logger) + + expect(Hooks::Core::Builder).to have_received(:new).with(config: config_hash, log: custom_logger) + expect(result).to eq("mock_app") + end + end + end +end diff --git a/spec/unit/lib/hooks/core/builder_spec.rb b/spec/unit/lib/hooks/core/builder_spec.rb new file mode 100644 index 00000000..879f7c80 --- /dev/null +++ b/spec/unit/lib/hooks/core/builder_spec.rb @@ -0,0 +1,316 @@ +# frozen_string_literal: true + +describe Hooks::Core::Builder do + let(:log) { instance_double(Logger).as_null_object } + let(:temp_dir) { "/tmp/hooks_builder_test" } + + before do + FileUtils.mkdir_p(temp_dir) + end + + after do + FileUtils.rm_rf(temp_dir) + end + + describe "#initialize" do + it "initializes with no parameters" do + builder = described_class.new(log:) + + expect(builder.instance_variable_get(:@log)).to eq(log) + expect(builder.instance_variable_get(:@config_input)).to be_nil + end + + it "initializes with config parameter" do + config = { log_level: "debug" } + builder = described_class.new(config:, log:) + + expect(builder.instance_variable_get(:@config_input)).to eq(config) + end + + it "initializes with custom logger" do + builder = described_class.new(log:) + expect(builder.instance_variable_get(:@log)).to eq(log) + end + + it "initializes with both config and logger" do + config = { environment: "test" } + builder = described_class.new(config: config, log:) + + expect(builder.instance_variable_get(:@config_input)).to eq(config) + expect(builder.instance_variable_get(:@log)).to eq(log) + end + end + + describe "#build" do + context "with minimal configuration" do + let(:builder) { described_class.new(log:) } + + before do + # Mock dependencies to prevent actual file system operations + allow(Hooks::Core::ConfigLoader).to receive(:load).and_return({ + log_level: "info", + environment: "test", + endpoints_dir: "/nonexistent" + }) + allow(Hooks::Core::ConfigValidator).to receive(:validate_global_config).and_return({ + log_level: "info", + environment: "test", + endpoints_dir: "/nonexistent" + }) + allow(Hooks::Core::ConfigLoader).to receive(:load_endpoints).and_return([]) + allow(Hooks::Core::ConfigValidator).to receive(:validate_endpoints).and_return([]) + allow(Hooks::App::API).to receive(:create).and_return("mock_api") + end + + it "builds and returns an API instance" do + result = builder.build + + expect(result).to eq("mock_api") + end + + it "calls ConfigLoader.load with the config input" do + expect(Hooks::Core::ConfigLoader).to receive(:load).with(config_path: nil) + + builder.build + end + + it "validates the global configuration" do + config = { log_level: "info", environment: "test", endpoints_dir: "/nonexistent" } + expect(Hooks::Core::ConfigValidator).to receive(:validate_global_config).with(config) + + builder.build + end + + it "loads endpoints from the endpoints directory" do + expect(Hooks::Core::ConfigLoader).to receive(:load_endpoints).with("/nonexistent") + builder.build + end + + it "validates the loaded endpoints" do + expect(Hooks::Core::ConfigValidator).to receive(:validate_endpoints).with([]) + + builder.build + end + + it "creates API with all required parameters" do + expect(Hooks::App::API).to receive(:create) do |args| + expect(args[:config]).to be_a(Hash) + expect(args[:endpoints]).to eq([]) + expect(args[:log]).to respond_to(:info) + expect(args[:signal_handler]).to be_a(Hooks::Core::SignalHandler) + "mock_api" + end + + builder.build + end + end + + context "with custom configuration" do + let(:config) { { log_level: "debug", environment: "development" } } + let(:builder) { described_class.new(config:, log:) } + + before do + allow(Hooks::Core::ConfigLoader).to receive(:load).and_return(config) + allow(Hooks::Core::ConfigValidator).to receive(:validate_global_config).and_return(config) + allow(Hooks::Core::ConfigLoader).to receive(:load_endpoints).and_return([]) + allow(Hooks::Core::ConfigValidator).to receive(:validate_endpoints).and_return([]) + allow(Hooks::App::API).to receive(:create).and_return("mock_api") + end + + it "passes the custom config to ConfigLoader" do + expect(Hooks::Core::ConfigLoader).to receive(:load).with(config_path: config) + + builder.build + end + end + + context "with custom logger" do + let(:custom_logger) { double("Logger", info: nil) } + let(:builder) { described_class.new(log: custom_logger) } + + before do + allow(Hooks::Core::ConfigLoader).to receive(:load).and_return({ log_level: "info" }) + allow(Hooks::Core::ConfigValidator).to receive(:validate_global_config).and_return({ log_level: "info" }) + allow(Hooks::Core::ConfigLoader).to receive(:load_endpoints).and_return([]) + allow(Hooks::Core::ConfigValidator).to receive(:validate_endpoints).and_return([]) + allow(Hooks::App::API).to receive(:create).and_return("mock_api") + end + + it "uses the custom logger instead of creating one" do + expect(Hooks::Core::LoggerFactory).not_to receive(:create) + + builder.build + end + + it "passes the custom logger to API.create" do + expect(Hooks::App::API).to receive(:create) do |args| + expect(args[:log]).to eq(custom_logger) + "mock_api" + end + + builder.build + end + end + + context "with endpoints" do + let(:endpoints) do + [ + { path: "/webhook/test1", handler: "Handler1" }, + { path: "/webhook/test2", handler: "Handler2" } + ] + end + let(:builder) { described_class.new(log:) } + + before do + allow(Hooks::Core::ConfigLoader).to receive(:load).and_return({ + endpoints_dir: "/test/endpoints" + }) + allow(Hooks::Core::ConfigValidator).to receive(:validate_global_config).and_return({ + endpoints_dir: "/test/endpoints" + }) + allow(Hooks::Core::ConfigLoader).to receive(:load_endpoints).and_return(endpoints) + allow(Hooks::Core::ConfigValidator).to receive(:validate_endpoints).and_return(endpoints) + allow(Hooks::App::API).to receive(:create).and_return("mock_api") + end + + it "loads endpoints from the specified directory" do + expect(Hooks::Core::ConfigLoader).to receive(:load_endpoints).with("/test/endpoints") + + builder.build + end + + it "validates the loaded endpoints" do + expect(Hooks::Core::ConfigValidator).to receive(:validate_endpoints).with(endpoints) + + builder.build + end + + it "passes validated endpoints to API.create" do + expect(Hooks::App::API).to receive(:create) do |args| + expect(args[:endpoints]).to eq(endpoints) + "mock_api" + end + + builder.build + end + end + + context "with logging" do + let(:builder) { described_class.new } + let(:mock_logger) { double("Logger", info: nil) } + + before do + allow(Hooks::Core::ConfigLoader).to receive(:load).and_return({ + log_level: "debug", + environment: "test" + }) + allow(Hooks::Core::ConfigValidator).to receive(:validate_global_config).and_return({ + log_level: "debug", + environment: "test" + }) + allow(Hooks::Core::ConfigLoader).to receive(:load_endpoints).and_return([]) + allow(Hooks::Core::ConfigValidator).to receive(:validate_endpoints).and_return([]) + allow(Hooks::Core::LoggerFactory).to receive(:create).and_return(mock_logger) + allow(Hooks::App::API).to receive(:create).and_return("mock_api") + end + + it "creates a logger with the configured log level" do + expect(Hooks::Core::LoggerFactory).to receive(:create).with( + log_level: "debug", + custom_logger: nil + ) + + builder.build + end + + it "logs startup information" do + expect(mock_logger).to receive(:info).with("starting hooks server v#{Hooks::VERSION}") + expect(mock_logger).to receive(:info).with("config: 0 endpoints loaded") + expect(mock_logger).to receive(:info).with("environment: test") + expect(mock_logger).to receive(:info).with("available endpoints: ") + + builder.build + end + + it "logs endpoint information when endpoints are present" do + endpoints = [ + { path: "/webhook/test1", handler: "Handler1" }, + { path: "/webhook/test2", handler: "Handler2" } + ] + allow(Hooks::Core::ConfigLoader).to receive(:load_endpoints).and_return(endpoints) + allow(Hooks::Core::ConfigValidator).to receive(:validate_endpoints).and_return(endpoints) + + expect(mock_logger).to receive(:info).with("config: 2 endpoints loaded") + expect(mock_logger).to receive(:info).with("available endpoints: /webhook/test1, /webhook/test2") + + builder.build + end + end + + context "error handling" do + let(:builder) { described_class.new(log:) } + + it "raises ConfigurationError when global config validation fails" do + allow(Hooks::Core::ConfigLoader).to receive(:load).and_return({}) + allow(Hooks::Core::ConfigValidator).to receive(:validate_global_config) + .and_raise(Hooks::Core::ConfigValidator::ValidationError, "Invalid config") + + expect { + builder.build + }.to raise_error(Hooks::Core::ConfigurationError, + "Configuration validation failed: Invalid config") + end + + it "raises ConfigurationError when endpoint validation fails" do + allow(Hooks::Core::ConfigLoader).to receive(:load).and_return({ endpoints_dir: "/test" }) + allow(Hooks::Core::ConfigValidator).to receive(:validate_global_config).and_return({ endpoints_dir: "/test" }) + allow(Hooks::Core::ConfigLoader).to receive(:load_endpoints).and_return([{}]) + allow(Hooks::Core::ConfigValidator).to receive(:validate_endpoints) + .and_raise(Hooks::Core::ConfigValidator::ValidationError, "Invalid endpoint") + + expect { + builder.build + }.to raise_error(Hooks::Core::ConfigurationError, + "Endpoint validation failed: Invalid endpoint") + end + end + end + + describe "#load_and_validate_config" do + let(:builder) { described_class.new(log:) } + + it "is a private method" do + expect(described_class.private_instance_methods).to include(:load_and_validate_config) + end + end + + describe "#load_endpoints" do + let(:builder) { described_class.new(log:) } + + it "is a private method" do + expect(described_class.private_instance_methods).to include(:load_endpoints) + end + end + + describe "#load_endpoints" do + describe "with its own log" do + let(:builder) { described_class.new } + + it "is a private method" do + expect(described_class.private_instance_methods).to include(:load_endpoints) + end + end + end + + describe "ConfigurationError" do + it "is a StandardError" do + expect(Hooks::Core::ConfigurationError.new).to be_a(StandardError) + end + + it "can be raised with a custom message" do + expect { + raise Hooks::Core::ConfigurationError, "Custom error" + }.to raise_error(Hooks::Core::ConfigurationError, "Custom error") + end + end +end diff --git a/spec/unit/lib/hooks/core/config_loader_spec.rb b/spec/unit/lib/hooks/core/config_loader_spec.rb new file mode 100644 index 00000000..740011c1 --- /dev/null +++ b/spec/unit/lib/hooks/core/config_loader_spec.rb @@ -0,0 +1,365 @@ +# frozen_string_literal: true + +describe Hooks::Core::ConfigLoader do + describe ".load" do + context "with no config_path provided" do + it "returns default configuration" do + config = described_class.load + + expect(config).to include( + handler_dir: "./handlers", + log_level: "info", + request_limit: 1_048_576, + request_timeout: 30, + root_path: "/webhooks", + health_path: "/health", + version_path: "/version", + environment: "production", + production: true, + endpoints_dir: "./config/endpoints", + use_catchall_route: false, + symbolize_payload: true, + normalize_headers: true + ) + end + end + + context "with hash config_path" do + it "merges hash with defaults" do + custom_config = { log_level: "debug", environment: "test" } + + config = described_class.load(config_path: custom_config) + + expect(config[:log_level]).to eq("debug") + expect(config[:environment]).to eq("test") + expect(config[:production]).to be false # should be false when environment is test + expect(config[:handler_dir]).to eq("./handlers") # defaults should remain + end + end + + context "with file config_path" do + let(:temp_dir) { "/tmp/hooks_test" } + + before do + FileUtils.mkdir_p(temp_dir) + end + + after do + FileUtils.rm_rf(temp_dir) + end + + context "when file exists and is YAML" do + let(:config_file) { File.join(temp_dir, "config.yml") } + let(:yaml_content) do + { + "log_level" => "debug", + "environment" => "development", + "request_timeout" => 60 + } + end + + before do + File.write(config_file, yaml_content.to_yaml) + end + + it "loads and merges YAML config" do + config = described_class.load(config_path: config_file) + + expect(config[:log_level]).to eq("debug") + expect(config[:environment]).to eq("development") + expect(config[:request_timeout]).to eq(60) + expect(config[:production]).to be false + expect(config[:handler_dir]).to eq("./handlers") # defaults should remain + end + end + + context "when file exists and is JSON" do + let(:config_file) { File.join(temp_dir, "config.json") } + let(:json_content) do + { + "log_level" => "warn", + "environment" => "staging", + "endpoints_dir" => "./custom/endpoints" + } + end + + before do + File.write(config_file, json_content.to_json) + end + + it "loads and merges JSON config" do + config = described_class.load(config_path: config_file) + + expect(config[:log_level]).to eq("warn") + expect(config[:environment]).to eq("staging") + expect(config[:endpoints_dir]).to eq("./custom/endpoints") + expect(config[:production]).to be false + end + end + + context "when file does not exist" do + let(:config_file) { File.join(temp_dir, "nonexistent.yml") } + + it "returns default configuration" do + config = described_class.load(config_path: config_file) + + expect(config[:log_level]).to eq("info") + expect(config[:environment]).to eq("production") + expect(config[:production]).to be true + end + end + + context "when file has invalid content" do + let(:config_file) { File.join(temp_dir, "invalid.yml") } + + before do + File.write(config_file, "invalid: yaml: content: [") + end + + it "returns default configuration" do + config = described_class.load(config_path: config_file) + + expect(config[:log_level]).to eq("info") + expect(config[:environment]).to eq("production") + end + end + + context "when file has unsupported extension" do + let(:config_file) { File.join(temp_dir, "config.txt") } + + before do + File.write(config_file, "log_level: debug") + end + + it "returns default configuration" do + config = described_class.load(config_path: config_file) + + expect(config[:log_level]).to eq("info") + expect(config[:environment]).to eq("production") + end + end + end + + context "with environment variables" do + around do |example| + original_env = ENV.to_h + example.run + ENV.replace(original_env) + end + + it "overrides config with environment variables" do + ENV["HOOKS_LOG_LEVEL"] = "error" + ENV["HOOKS_ENVIRONMENT"] = "development" + ENV["HOOKS_REQUEST_LIMIT"] = "2097152" + ENV["HOOKS_REQUEST_TIMEOUT"] = "45" + + config = described_class.load + + expect(config[:log_level]).to eq("error") + expect(config[:environment]).to eq("development") + expect(config[:request_limit]).to eq(2_097_152) + expect(config[:request_timeout]).to eq(45) + expect(config[:production]).to be false + end + + it "handles partial environment variable overrides" do + ENV["HOOKS_LOG_LEVEL"] = "warn" + + config = described_class.load + + expect(config[:log_level]).to eq("warn") + expect(config[:environment]).to eq("production") # should remain default + expect(config[:production]).to be true + end + + it "processes empty environment variables (empty strings are truthy)" do + ENV["HOOKS_LOG_LEVEL"] = "" + + config = described_class.load + + expect(config[:log_level]).to eq("") # empty string is processed + end + end + + context "with production environment detection" do + it "sets production to true when environment is production" do + config = described_class.load(config_path: { environment: "production" }) + + expect(config[:production]).to be true + end + + it "sets production to false when environment is not production" do + ["development", "test", "staging", "custom"].each do |env| + config = described_class.load(config_path: { environment: env }) + + expect(config[:production]).to be false + end + end + end + end + + describe ".load_endpoints" do + let(:temp_dir) { "/tmp/hooks_endpoints_test" } + + before do + FileUtils.mkdir_p(temp_dir) + end + + after do + FileUtils.rm_rf(temp_dir) + end + + context "when endpoints_dir is nil" do + it "returns empty array" do + endpoints = described_class.load_endpoints(nil) + + expect(endpoints).to eq([]) + end + end + + context "when endpoints_dir does not exist" do + it "returns empty array" do + endpoints = described_class.load_endpoints("/nonexistent/dir") + + expect(endpoints).to eq([]) + end + end + + context "when endpoints_dir exists with YAML files" do + let(:endpoint1_file) { File.join(temp_dir, "endpoint1.yml") } + let(:endpoint2_file) { File.join(temp_dir, "endpoint2.yaml") } + let(:endpoint1_config) do + { + "path" => "/webhook/test1", + "handler" => "TestHandler1", + "method" => "POST" + } + end + let(:endpoint2_config) do + { + "path" => "/webhook/test2", + "handler" => "TestHandler2", + "method" => "PUT" + } + end + + before do + File.write(endpoint1_file, endpoint1_config.to_yaml) + File.write(endpoint2_file, endpoint2_config.to_yaml) + end + + it "loads all YAML endpoint configurations" do + endpoints = described_class.load_endpoints(temp_dir) + + expect(endpoints).to have_attributes(size: 2) + expect(endpoints).to include( + path: "/webhook/test1", + handler: "TestHandler1", + method: "POST" + ) + expect(endpoints).to include( + path: "/webhook/test2", + handler: "TestHandler2", + method: "PUT" + ) + end + end + + context "when endpoints_dir exists with JSON files" do + let(:endpoint_file) { File.join(temp_dir, "endpoint.json") } + let(:endpoint_config) do + { + "path" => "/webhook/json", + "handler" => "JsonHandler", + "method" => "POST" + } + end + + before do + File.write(endpoint_file, endpoint_config.to_json) + end + + it "loads JSON endpoint configuration" do + endpoints = described_class.load_endpoints(temp_dir) + + expect(endpoints).to have_attributes(size: 1) + expect(endpoints.first).to eq( + path: "/webhook/json", + handler: "JsonHandler", + method: "POST" + ) + end + end + + context "when endpoints_dir has mixed valid and invalid files" do + let(:valid_file) { File.join(temp_dir, "valid.yml") } + let(:invalid_file) { File.join(temp_dir, "invalid.yml") } + let(:txt_file) { File.join(temp_dir, "ignored.txt") } + let(:valid_config) do + { + "path" => "/webhook/valid", + "handler" => "ValidHandler" + } + end + + before do + File.write(valid_file, valid_config.to_yaml) + File.write(invalid_file, "invalid: yaml: [") + File.write(txt_file, "This should be ignored") + end + + it "loads only valid configurations and ignores invalid ones" do + endpoints = described_class.load_endpoints(temp_dir) + + expect(endpoints).to have_attributes(size: 1) + expect(endpoints.first).to eq( + path: "/webhook/valid", + handler: "ValidHandler" + ) + end + end + end + + describe ".symbolize_keys" do + it "converts string keys to symbols in a hash" do + input = { "key1" => "value1", "key2" => "value2" } + result = described_class.send(:symbolize_keys, input) + + expect(result).to eq({ key1: "value1", key2: "value2" }) + end + + it "recursively converts nested hash keys" do + input = { + "level1" => { + "level2" => "value" + } + } + result = described_class.send(:symbolize_keys, input) + + expect(result).to eq({ + level1: { + level2: "value" + } + }) + end + + it "converts keys in arrays of hashes" do + input = [ + { "key1" => "value1" }, + { "key2" => "value2" } + ] + result = described_class.send(:symbolize_keys, input) + + expect(result).to eq([ + { key1: "value1" }, + { key2: "value2" } + ]) + end + + it "returns non-hash/array objects unchanged" do + expect(described_class.send(:symbolize_keys, "string")).to eq("string") + expect(described_class.send(:symbolize_keys, 123)).to eq(123) + expect(described_class.send(:symbolize_keys, nil)).to be_nil + end + end +end diff --git a/spec/unit/lib/hooks/core/config_validator_spec.rb b/spec/unit/lib/hooks/core/config_validator_spec.rb new file mode 100644 index 00000000..f55b2fc8 --- /dev/null +++ b/spec/unit/lib/hooks/core/config_validator_spec.rb @@ -0,0 +1,529 @@ +# frozen_string_literal: true + +describe Hooks::Core::ConfigValidator do + describe ".validate_global_config" do + context "with valid configuration" do + it "returns validated configuration with all optional fields" do + config = { + handler_dir: "./custom_handlers", + log_level: "debug", + request_limit: 2_048_000, + request_timeout: 45, + root_path: "/custom/webhooks", + health_path: "/custom/health", + version_path: "/custom/version", + environment: "development", + endpoints_dir: "./custom/endpoints", + use_catchall_route: true, + symbolize_payload: false, + normalize_headers: false + } + + result = described_class.validate_global_config(config) + + expect(result).to eq(config) + end + + it "returns validated configuration with minimal fields" do + config = {} + + result = described_class.validate_global_config(config) + + expect(result).to eq({}) + end + + it "accepts production environment" do + config = { environment: "production" } + + result = described_class.validate_global_config(config) + + expect(result).to eq(config) + end + + it "accepts valid log levels" do + %w[debug info warn error].each do |log_level| + config = { log_level: log_level } + + result = described_class.validate_global_config(config) + + expect(result[:log_level]).to eq(log_level) + end + end + end + + context "with invalid configuration" do + it "raises ValidationError for invalid log level" do + config = { log_level: "invalid" } + + expect { + described_class.validate_global_config(config) + }.to raise_error(described_class::ValidationError, /Invalid global configuration/) + end + + it "raises ValidationError for invalid environment" do + config = { environment: "staging" } + + expect { + described_class.validate_global_config(config) + }.to raise_error(described_class::ValidationError, /Invalid global configuration/) + end + + it "raises ValidationError for zero request_limit" do + config = { request_limit: 0 } + + expect { + described_class.validate_global_config(config) + }.to raise_error(described_class::ValidationError, /Invalid global configuration/) + end + + it "raises ValidationError for negative request_limit" do + config = { request_limit: -100 } + + expect { + described_class.validate_global_config(config) + }.to raise_error(described_class::ValidationError, /Invalid global configuration/) + end + + it "raises ValidationError for zero request_timeout" do + config = { request_timeout: 0 } + + expect { + described_class.validate_global_config(config) + }.to raise_error(described_class::ValidationError, /Invalid global configuration/) + end + + it "raises ValidationError for negative request_timeout" do + config = { request_timeout: -30 } + + expect { + described_class.validate_global_config(config) + }.to raise_error(described_class::ValidationError, /Invalid global configuration/) + end + + it "raises ValidationError for empty string values" do + config = { + handler_dir: "", + root_path: "", + health_path: "" + } + + expect { + described_class.validate_global_config(config) + }.to raise_error(described_class::ValidationError, /Invalid global configuration/) + end + + it "coerces boolean-like string values" do + config = { + use_catchall_route: "true", + symbolize_payload: "false", + normalize_headers: "1" + } + + result = described_class.validate_global_config(config) + + expect(result[:use_catchall_route]).to be true + expect(result[:symbolize_payload]).to be false + expect(result[:normalize_headers]).to be true # "1" gets coerced to true + end + + it "raises ValidationError for non-string paths" do + config = { + handler_dir: 123, + root_path: [], + endpoints_dir: {} + } + + expect { + described_class.validate_global_config(config) + }.to raise_error(described_class::ValidationError, /Invalid global configuration/) + end + + it "coerces string numeric values" do + config = { + request_limit: "1024", + request_timeout: "30" + } + + result = described_class.validate_global_config(config) + + expect(result[:request_limit]).to eq(1024) + expect(result[:request_timeout]).to eq(30) + end + + it "raises ValidationError for invalid boolean string values" do + config = { use_catchall_route: "invalid_boolean" } + + expect { + described_class.validate_global_config(config) + }.to raise_error(described_class::ValidationError, /Invalid global configuration/) + end + + it "raises ValidationError for non-numeric string values for integers" do + config = { request_limit: "not_a_number" } + + expect { + described_class.validate_global_config(config) + }.to raise_error(described_class::ValidationError, /Invalid global configuration/) + end + + it "coerces float values to integers by truncating" do + config = { request_timeout: 30.5 } + + result = described_class.validate_global_config(config) + + expect(result[:request_timeout]).to eq(30) + end + end + end + + describe ".validate_endpoint_config" do + context "with valid configuration" do + it "returns validated configuration with required fields only" do + config = { + path: "/webhook/simple", + handler: "SimpleHandler" + } + + result = described_class.validate_endpoint_config(config) + + expect(result).to eq(config) + end + + it "returns validated configuration with request validator" do + config = { + path: "/webhook/secure", + handler: "SecureHandler", + request_validator: { + type: "hmac", + secret_env_key: "WEBHOOK_SECRET", + header: "X-Hub-Signature-256", + algorithm: "sha256", + timestamp_header: "X-Timestamp", + timestamp_tolerance: 300, + format: "algorithm=signature", + version_prefix: "v0", + payload_template: "v0:{timestamp}:{body}" + } + } + + result = described_class.validate_endpoint_config(config) + + expect(result).to eq(config) + end + + it "returns validated configuration with opts hash" do + config = { + path: "/webhook/custom", + handler: "CustomHandler", + opts: { + custom_option: "value", + another_option: 123 + } + } + + result = described_class.validate_endpoint_config(config) + + expect(result).to eq(config) + end + + it "returns validated configuration with all optional fields" do + config = { + path: "/webhook/full", + handler: "FullHandler", + request_validator: { + type: "custom_validator", + secret_env_key: "SECRET", + header: "X-Signature" + }, + opts: { + timeout: 60 + } + } + + result = described_class.validate_endpoint_config(config) + + expect(result).to eq(config) + end + end + + context "with invalid configuration" do + it "raises ValidationError for missing path" do + config = { handler: "TestHandler" } + + expect { + described_class.validate_endpoint_config(config) + }.to raise_error(described_class::ValidationError, /Invalid endpoint configuration/) + end + + it "raises ValidationError for missing handler" do + config = { path: "/webhook/test" } + + expect { + described_class.validate_endpoint_config(config) + }.to raise_error(described_class::ValidationError, /Invalid endpoint configuration/) + end + + it "raises ValidationError for empty path" do + config = { + path: "", + handler: "TestHandler" + } + + expect { + described_class.validate_endpoint_config(config) + }.to raise_error(described_class::ValidationError, /Invalid endpoint configuration/) + end + + it "raises ValidationError for empty handler" do + config = { + path: "/webhook/test", + handler: "" + } + + expect { + described_class.validate_endpoint_config(config) + }.to raise_error(described_class::ValidationError, /Invalid endpoint configuration/) + end + + it "raises ValidationError for non-string path" do + config = { + path: 123, + handler: "TestHandler" + } + + expect { + described_class.validate_endpoint_config(config) + }.to raise_error(described_class::ValidationError, /Invalid endpoint configuration/) + end + + it "raises ValidationError for non-string handler" do + config = { + path: "/webhook/test", + handler: [] + } + + expect { + described_class.validate_endpoint_config(config) + }.to raise_error(described_class::ValidationError, /Invalid endpoint configuration/) + end + + it "raises ValidationError for request_validator missing required type" do + config = { + path: "/webhook/test", + handler: "TestHandler", + request_validator: { + header: "X-Signature" + } + } + + expect { + described_class.validate_endpoint_config(config) + }.to raise_error(described_class::ValidationError, /Invalid endpoint configuration/) + end + + it "raises ValidationError for request_validator with empty type" do + config = { + path: "/webhook/test", + handler: "TestHandler", + request_validator: { + type: "" + } + } + + expect { + described_class.validate_endpoint_config(config) + }.to raise_error(described_class::ValidationError, /Invalid endpoint configuration/) + end + + it "raises ValidationError for non-hash request_validator" do + config = { + path: "/webhook/test", + handler: "TestHandler", + request_validator: "hmac" + } + + expect { + described_class.validate_endpoint_config(config) + }.to raise_error(described_class::ValidationError, /Invalid endpoint configuration/) + end + + it "raises ValidationError for non-hash opts" do + config = { + path: "/webhook/test", + handler: "TestHandler", + opts: "invalid" + } + + expect { + described_class.validate_endpoint_config(config) + }.to raise_error(described_class::ValidationError, /Invalid endpoint configuration/) + end + + it "raises ValidationError for zero timestamp_tolerance" do + config = { + path: "/webhook/test", + handler: "TestHandler", + request_validator: { + type: "hmac", + timestamp_tolerance: 0 + } + } + + expect { + described_class.validate_endpoint_config(config) + }.to raise_error(described_class::ValidationError, /Invalid endpoint configuration/) + end + + it "raises ValidationError for negative timestamp_tolerance" do + config = { + path: "/webhook/test", + handler: "TestHandler", + request_validator: { + type: "hmac", + timestamp_tolerance: -100 + } + } + + expect { + described_class.validate_endpoint_config(config) + }.to raise_error(described_class::ValidationError, /Invalid endpoint configuration/) + end + + it "raises ValidationError for empty string fields in request_validator" do + config = { + path: "/webhook/test", + handler: "TestHandler", + request_validator: { + type: "hmac", + secret_env_key: "", + header: "", + algorithm: "" + } + } + + expect { + described_class.validate_endpoint_config(config) + }.to raise_error(described_class::ValidationError, /Invalid endpoint configuration/) + end + end + end + + describe ".validate_endpoints" do + context "with valid endpoints array" do + it "returns validated endpoints for empty array" do + endpoints = [] + + result = described_class.validate_endpoints(endpoints) + + expect(result).to eq([]) + end + + it "returns validated endpoints for single valid endpoint" do + endpoints = [ + { + path: "/webhook/test", + handler: "TestHandler" + } + ] + + result = described_class.validate_endpoints(endpoints) + + expect(result).to eq(endpoints) + end + + it "returns validated endpoints for multiple valid endpoints" do + endpoints = [ + { + path: "/webhook/test1", + handler: "TestHandler1" + }, + { + path: "/webhook/test2", + handler: "TestHandler2", + request_validator: { + type: "hmac", + header: "X-Hub-Signature" + } + } + ] + + result = described_class.validate_endpoints(endpoints) + + expect(result).to eq(endpoints) + end + end + + context "with invalid endpoints array" do + it "raises ValidationError with endpoint index for first invalid endpoint" do + endpoints = [ + { + path: "/webhook/valid", + handler: "ValidHandler" + }, + { + path: "/webhook/invalid", + # missing handler + }, + { + path: "/webhook/another", + handler: "AnotherHandler" + } + ] + + expect { + described_class.validate_endpoints(endpoints) + }.to raise_error(described_class::ValidationError, /Endpoint 1:.*Invalid endpoint configuration/) + end + + it "raises ValidationError with endpoint index for last invalid endpoint" do + endpoints = [ + { + path: "/webhook/valid1", + handler: "ValidHandler1" + }, + { + path: "/webhook/valid2", + handler: "ValidHandler2" + }, + { + handler: "InvalidHandler" + # missing path + } + ] + + expect { + described_class.validate_endpoints(endpoints) + }.to raise_error(described_class::ValidationError, /Endpoint 2:.*Invalid endpoint configuration/) + end + + it "raises ValidationError for endpoint with invalid request_validator" do + endpoints = [ + { + path: "/webhook/test", + handler: "TestHandler", + request_validator: { + # missing required type + header: "X-Signature" + } + } + ] + + expect { + described_class.validate_endpoints(endpoints) + }.to raise_error(described_class::ValidationError, /Endpoint 0:.*Invalid endpoint configuration/) + end + end + end + + describe "ValidationError" do + it "is a StandardError" do + expect(described_class::ValidationError.new).to be_a(StandardError) + end + + it "can be raised with a custom message" do + expect { + raise described_class::ValidationError, "Custom validation error" + }.to raise_error(described_class::ValidationError, "Custom validation error") + end + end +end diff --git a/spec/unit/lib/hooks/core/logger_factory_spec.rb b/spec/unit/lib/hooks/core/logger_factory_spec.rb new file mode 100644 index 00000000..e4052fc4 --- /dev/null +++ b/spec/unit/lib/hooks/core/logger_factory_spec.rb @@ -0,0 +1,328 @@ +# frozen_string_literal: true + +describe Hooks::Core::LoggerFactory do + describe ".create" do + context "with default parameters" do + it "creates a logger with INFO level and JSON formatter" do + logger = described_class.create + + expect(logger).to be_a(Logger) + expect(logger.level).to eq(Logger::INFO) + end + + it "logs to STDOUT by default" do + logger = described_class.create + + # The internal instance variable should be set to STDOUT + expect(logger.instance_variable_get(:@logdev).dev).to eq($stdout) + end + end + + context "with custom log level" do + it "creates logger with DEBUG level" do + logger = described_class.create(log_level: "debug") + + expect(logger.level).to eq(Logger::DEBUG) + end + + it "creates logger with WARN level" do + logger = described_class.create(log_level: "warn") + + expect(logger.level).to eq(Logger::WARN) + end + + it "creates logger with ERROR level" do + logger = described_class.create(log_level: "error") + + expect(logger.level).to eq(Logger::ERROR) + end + + it "creates logger with INFO level for invalid level" do + logger = described_class.create(log_level: "invalid") + + expect(logger.level).to eq(Logger::INFO) + end + + it "handles nil log level gracefully" do + logger = described_class.create(log_level: nil) + + expect(logger.level).to eq(Logger::INFO) + end + + it "handles case insensitive log levels" do + logger = described_class.create(log_level: "DEBUG") + + expect(logger.level).to eq(Logger::DEBUG) + end + end + + context "with custom logger" do + it "returns the custom logger instance" do + custom_logger = Logger.new(StringIO.new) + custom_logger.level = Logger::WARN + + result = described_class.create(custom_logger: custom_logger) + + expect(result).to be(custom_logger) + expect(result.level).to eq(Logger::WARN) + end + + it "ignores log_level parameter when custom_logger is provided" do + custom_logger = Logger.new(StringIO.new) + custom_logger.level = Logger::ERROR + + result = described_class.create(log_level: "debug", custom_logger: custom_logger) + + expect(result).to be(custom_logger) + expect(result.level).to eq(Logger::ERROR) # Should remain unchanged + end + end + + context "JSON formatting" do + let(:output) { StringIO.new } + let(:logger) do + logger = described_class.create(log_level: "debug") + logger.instance_variable_set(:@logdev, Logger::LogDevice.new(output)) + logger + end + + it "formats log messages as JSON" do + logger.info("Test message") + + output.rewind + log_line = output.read + parsed = JSON.parse(log_line) + + expect(parsed).to include( + "level" => "info", + "message" => "Test message" + ) + expect(parsed["timestamp"]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z/) + end + + it "includes request context when available" do + Thread.current[:hooks_request_context] = { + "request_id" => "test-123", + "endpoint" => "/webhook/test" + } + + logger.warn("Context test") + + output.rewind + log_line = output.read + parsed = JSON.parse(log_line) + + expect(parsed).to include( + "level" => "warn", + "message" => "Context test", + "request_id" => "test-123", + "endpoint" => "/webhook/test" + ) + ensure + Thread.current[:hooks_request_context] = nil + end + + it "works without request context" do + Thread.current[:hooks_request_context] = nil + + logger.error("No context test") + + output.rewind + log_line = output.read + parsed = JSON.parse(log_line) + + expect(parsed).to include( + "level" => "error", + "message" => "No context test" + ) + expect(parsed).not_to have_key("request_id") + end + + it "handles different severity levels correctly" do + ["debug", "info", "warn", "error"].each do |level| + output.truncate(0) + output.rewind + + logger.send(level, "#{level} message") + + output.rewind + log_line = output.read + parsed = JSON.parse(log_line) + + expect(parsed["level"]).to eq(level) + expect(parsed["message"]).to eq("#{level} message") + end + end + end + end + + describe ".parse_log_level" do + it "converts string log levels to Logger constants" do + expect(described_class.send(:parse_log_level, "debug")).to eq(Logger::DEBUG) + expect(described_class.send(:parse_log_level, "info")).to eq(Logger::INFO) + expect(described_class.send(:parse_log_level, "warn")).to eq(Logger::WARN) + expect(described_class.send(:parse_log_level, "error")).to eq(Logger::ERROR) + end + + it "handles case insensitive input" do + expect(described_class.send(:parse_log_level, "DEBUG")).to eq(Logger::DEBUG) + expect(described_class.send(:parse_log_level, "Info")).to eq(Logger::INFO) + expect(described_class.send(:parse_log_level, "WARN")).to eq(Logger::WARN) + expect(described_class.send(:parse_log_level, "Error")).to eq(Logger::ERROR) + end + + it "defaults to INFO for invalid levels" do + expect(described_class.send(:parse_log_level, "invalid")).to eq(Logger::INFO) + expect(described_class.send(:parse_log_level, "")).to eq(Logger::INFO) + expect(described_class.send(:parse_log_level, nil)).to eq(Logger::INFO) + end + end + + describe ".json_formatter" do + let(:formatter) { described_class.send(:json_formatter) } + let(:test_time) { Time.parse("2023-01-01T12:00:00Z") } + + it "returns a proc" do + expect(formatter).to be_a(Proc) + end + + it "formats log entry as JSON with newline" do + result = formatter.call("INFO", test_time, nil, "Test message") + parsed = JSON.parse(result.chomp) + + expect(parsed).to eq({ + "timestamp" => "2023-01-01T12:00:00Z", + "level" => "info", + "message" => "Test message" + }) + expect(result).to end_with("\n") + end + + it "includes thread context when available" do + Thread.current[:hooks_request_context] = { "user_id" => 123 } + + result = formatter.call("WARN", test_time, nil, "Warning message") + parsed = JSON.parse(result.chomp) + + expect(parsed).to eq({ + "timestamp" => "2023-01-01T12:00:00Z", + "level" => "warn", + "message" => "Warning message", + "user_id" => 123 + }) + ensure + Thread.current[:hooks_request_context] = nil + end + + it "handles complex message objects" do + complex_message = { error: "Something failed", details: { code: 500 } } + + result = formatter.call("ERROR", test_time, nil, complex_message) + parsed = JSON.parse(result.chomp) + + # JSON parsing converts symbol keys to strings + expect(parsed["message"]).to eq({ + "error" => "Something failed", + "details" => { "code" => 500 } + }) + end + end +end + +describe Hooks::Core::LogContext do + after do + Thread.current[:hooks_request_context] = nil + end + + describe ".set" do + it "sets request context in thread local storage" do + context = { "request_id" => "test-123", "user" => "testuser" } + + described_class.set(context) + + expect(Thread.current[:hooks_request_context]).to eq(context) + end + + it "overwrites existing context" do + Thread.current[:hooks_request_context] = { "old" => "data" } + + new_context = { "new" => "data" } + described_class.set(new_context) + + expect(Thread.current[:hooks_request_context]).to eq(new_context) + end + end + + describe ".clear" do + it "clears request context" do + Thread.current[:hooks_request_context] = { "test" => "data" } + + described_class.clear + + expect(Thread.current[:hooks_request_context]).to be_nil + end + + it "works when context is already nil" do + Thread.current[:hooks_request_context] = nil + + expect { described_class.clear }.not_to raise_error + expect(Thread.current[:hooks_request_context]).to be_nil + end + end + + describe ".with" do + it "sets context for block execution then restores original" do + original_context = { "original" => "value" } + Thread.current[:hooks_request_context] = original_context + + block_context = { "block" => "value" } + context_during_block = nil + + described_class.with(block_context) do + context_during_block = Thread.current[:hooks_request_context] + end + + expect(context_during_block).to eq(block_context) + expect(Thread.current[:hooks_request_context]).to eq(original_context) + end + + it "restores context even if block raises exception" do + original_context = { "original" => "value" } + Thread.current[:hooks_request_context] = original_context + + block_context = { "block" => "value" } + + expect { + described_class.with(block_context) do + raise StandardError, "Test error" + end + }.to raise_error(StandardError, "Test error") + + expect(Thread.current[:hooks_request_context]).to eq(original_context) + end + + it "works when original context is nil" do + Thread.current[:hooks_request_context] = nil + + block_context = { "block" => "value" } + context_during_block = nil + + described_class.with(block_context) do + context_during_block = Thread.current[:hooks_request_context] + end + + expect(context_during_block).to eq(block_context) + expect(Thread.current[:hooks_request_context]).to be_nil + end + + it "yields to the block" do + yielded_value = nil + + described_class.with({}) do |arg| + yielded_value = arg + end + + expect(yielded_value).to be_nil # with doesn't pass arguments + end + end +end diff --git a/spec/unit/lib/hooks/core/signal_handler_spec.rb b/spec/unit/lib/hooks/core/signal_handler_spec.rb new file mode 100644 index 00000000..a5764a12 --- /dev/null +++ b/spec/unit/lib/hooks/core/signal_handler_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +describe Hooks::Core::SignalHandler do + let(:logger) { double("Logger", info: nil) } + + describe "#initialize" do + it "initializes with logger and default graceful timeout" do + handler = described_class.new(logger) + + expect(handler.shutdown_requested?).to be false + end + + it "initializes with custom graceful timeout" do + handler = described_class.new(logger, graceful_timeout: 60) + + expect(handler.shutdown_requested?).to be false + end + + it "stores the logger instance" do + handler = described_class.new(logger) + + expect(handler.instance_variable_get(:@logger)).to be(logger) + end + + it "stores the graceful timeout" do + handler = described_class.new(logger, graceful_timeout: 45) + + expect(handler.instance_variable_get(:@graceful_timeout)).to eq(45) + end + + it "sets default graceful timeout to 30 seconds" do + handler = described_class.new(logger) + + expect(handler.instance_variable_get(:@graceful_timeout)).to eq(30) + end + + it "initializes shutdown_requested to false" do + handler = described_class.new(logger) + + expect(handler.instance_variable_get(:@shutdown_requested)).to be false + end + end + + describe "#shutdown_requested?" do + let(:handler) { described_class.new(logger) } + + it "returns false initially" do + expect(handler.shutdown_requested?).to be false + end + + it "returns true after shutdown is requested" do + handler.request_shutdown + + expect(handler.shutdown_requested?).to be true + end + + it "remains true once set" do + handler.request_shutdown + + expect(handler.shutdown_requested?).to be true + expect(handler.shutdown_requested?).to be true + end + end + + describe "#request_shutdown" do + let(:handler) { described_class.new(logger) } + + it "sets shutdown_requested to true" do + expect(handler.shutdown_requested?).to be false + + handler.request_shutdown + + expect(handler.shutdown_requested?).to be true + end + + it "logs the shutdown request" do + expect(logger).to receive(:info).with("Shutdown requested") + + handler.request_shutdown + end + + it "can be called multiple times without error" do + expect(logger).to receive(:info).twice + + handler.request_shutdown + handler.request_shutdown + + expect(handler.shutdown_requested?).to be true + end + + it "logs each time it's called" do + expect(logger).to receive(:info).with("Shutdown requested").twice + + handler.request_shutdown + handler.request_shutdown + end + end + + describe "#setup_signal_traps" do + let(:handler) { described_class.new(logger) } + + it "is a private method" do + expect(described_class.private_instance_methods).to include(:setup_signal_traps) + end + + # Note: The setup_signal_traps method is currently disabled in the implementation + # as noted in the comment "NOTE: Disabled for now to let Puma handle signals properly" + # The signal trapping functionality is commented out but the method structure remains + it "exists but is currently disabled" do + expect(handler.respond_to?(:setup_signal_traps, true)).to be true + end + end + + describe "thread safety" do + let(:handler) { described_class.new(logger) } + + it "handles concurrent access to shutdown_requested?" do + allow(logger).to receive(:info) + + threads = 10.times.map do + Thread.new do + 100.times do + handler.shutdown_requested? + handler.request_shutdown if rand < 0.1 # Randomly request shutdown + end + end + end + + threads.each(&:join) + + # Should not raise any errors and should end up with shutdown requested + expect(handler.shutdown_requested?).to be true + end + end + + describe "edge cases" do + context "with nil logger" do + it "raises an error when trying to log" do + handler = described_class.new(nil) + + expect { + handler.request_shutdown + }.to raise_error(NoMethodError) + end + end + + context "with zero graceful timeout" do + it "accepts zero timeout without error" do + expect { + described_class.new(logger, graceful_timeout: 0) + }.not_to raise_error + end + end + + context "with negative graceful timeout" do + it "accepts negative timeout without error" do + expect { + described_class.new(logger, graceful_timeout: -10) + }.not_to raise_error + end + end + end +end diff --git a/spec/unit/lib/hooks/handlers/base_spec.rb b/spec/unit/lib/hooks/handlers/base_spec.rb new file mode 100644 index 00000000..acceca88 --- /dev/null +++ b/spec/unit/lib/hooks/handlers/base_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +describe Hooks::Handlers::Base do + describe "#call" do + let(:handler) { described_class.new } + let(:payload) { { "data" => "test" } } + let(:headers) { { "Content-Type" => "application/json" } } + let(:config) { { "endpoint" => "/test" } } + + it "raises NotImplementedError by default" do + expect { + handler.call(payload: payload, headers: headers, config: config) + }.to raise_error(NotImplementedError, "Handler must implement #call method") + end + + it "can be subclassed and overridden" do + test_handler_class = Class.new(described_class) do + def call(payload:, headers:, config:) + { + received_payload: payload, + received_headers: headers, + received_config: config, + status: "success" + } + end + end + + handler = test_handler_class.new + result = handler.call(payload: payload, headers: headers, config: config) + + expect(result).to eq({ + received_payload: payload, + received_headers: headers, + received_config: config, + status: "success" + }) + end + + it "accepts different payload types" do + test_handler_class = Class.new(described_class) do + def call(payload:, headers:, config:) + { payload_class: payload.class.name } + end + end + + handler = test_handler_class.new + + # Test with hash + result = handler.call(payload: { "test" => "data" }, headers: headers, config: config) + expect(result[:payload_class]).to eq("Hash") + + # Test with string + result = handler.call(payload: "raw string", headers: headers, config: config) + expect(result[:payload_class]).to eq("String") + + # Test with nil + result = handler.call(payload: nil, headers: headers, config: config) + expect(result[:payload_class]).to eq("NilClass") + end + + it "accepts different header types" do + test_handler_class = Class.new(described_class) do + def call(payload:, headers:, config:) + { headers_received: headers } + end + end + + handler = test_handler_class.new + + # Test with hash + headers_hash = { "User-Agent" => "test", "X-Custom" => "value" } + result = handler.call(payload: payload, headers: headers_hash, config: config) + expect(result[:headers_received]).to eq(headers_hash) + + # Test with empty hash + result = handler.call(payload: payload, headers: {}, config: config) + expect(result[:headers_received]).to eq({}) + + # Test with nil + result = handler.call(payload: payload, headers: nil, config: config) + expect(result[:headers_received]).to be_nil + end + + it "accepts different config types" do + test_handler_class = Class.new(described_class) do + def call(payload:, headers:, config:) + { config_received: config } + end + end + + handler = test_handler_class.new + + # Test with complex config + complex_config = { + "endpoint" => "/test", + "opts" => { "timeout" => 30 }, + "handler" => "TestHandler" + } + result = handler.call(payload: payload, headers: headers, config: complex_config) + expect(result[:config_received]).to eq(complex_config) + + # Test with empty config + result = handler.call(payload: payload, headers: headers, config: {}) + expect(result[:config_received]).to eq({}) + end + + it "requires all keyword arguments" do + expect { + handler.call(payload: payload, headers: headers) + }.to raise_error(ArgumentError, /missing keyword.*config/) + + expect { + handler.call(payload: payload, config: config) + }.to raise_error(ArgumentError, /missing keyword.*headers/) + + expect { + handler.call(headers: headers, config: config) + }.to raise_error(ArgumentError, /missing keyword.*payload/) + end + end + + describe "inheritance" do + it "can be inherited" do + child_class = Class.new(described_class) + expect(child_class.ancestors).to include(described_class) + end + + it "maintains method signature in subclasses" do + child_class = Class.new(described_class) do + def call(payload:, headers:, config:) + "child implementation" + end + end + + handler = child_class.new + result = handler.call( + payload: { "test" => "data" }, + headers: { "Content-Type" => "application/json" }, + config: { "endpoint" => "/test" } + ) + + expect(result).to eq("child implementation") + end + end + + describe "documentation compliance" do + it "has the expected public interface" do + expect(described_class.instance_methods(false)).to include(:call) + end + + it "call method accepts the documented parameters" do + method = described_class.instance_method(:call) + expect(method.parameters).to include([:keyreq, :payload]) + expect(method.parameters).to include([:keyreq, :headers]) + expect(method.parameters).to include([:keyreq, :config]) + end + end +end diff --git a/spec/unit/lib/hooks/plugins/lifecycle_spec.rb b/spec/unit/lib/hooks/plugins/lifecycle_spec.rb new file mode 100644 index 00000000..2bd42872 --- /dev/null +++ b/spec/unit/lib/hooks/plugins/lifecycle_spec.rb @@ -0,0 +1,247 @@ +# frozen_string_literal: true + +describe Hooks::Plugins::Lifecycle do + let(:plugin) { described_class.new } + let(:env) { { "REQUEST_METHOD" => "POST", "PATH_INFO" => "/webhook" } } + let(:response) { { "status" => "success", "data" => "processed" } } + let(:exception) { StandardError.new("Test error") } + + describe "#on_request" do + it "can be called without error" do + expect { plugin.on_request(env) }.not_to raise_error + end + + it "returns nil by default" do + result = plugin.on_request(env) + expect(result).to be_nil + end + + it "accepts any environment hash" do + empty_env = {} + complex_env = { + "REQUEST_METHOD" => "POST", + "PATH_INFO" => "/webhook/test", + "HTTP_USER_AGENT" => "TestAgent/1.0", + "CONTENT_TYPE" => "application/json" + } + + expect { plugin.on_request(empty_env) }.not_to raise_error + expect { plugin.on_request(complex_env) }.not_to raise_error + end + + it "can be overridden in subclasses" do + custom_plugin_class = Class.new(described_class) do + def on_request(env) + @request_called = true + @received_env = env + "request processed" + end + + attr_reader :request_called, :received_env + end + + plugin = custom_plugin_class.new + result = plugin.on_request(env) + + expect(plugin.request_called).to be true + expect(plugin.received_env).to eq(env) + expect(result).to eq("request processed") + end + end + + describe "#on_response" do + it "can be called without error" do + expect { plugin.on_response(env, response) }.not_to raise_error + end + + it "returns nil by default" do + result = plugin.on_response(env, response) + expect(result).to be_nil + end + + it "accepts any environment and response" do + empty_env = {} + empty_response = {} + nil_response = nil + + expect { plugin.on_response(empty_env, empty_response) }.not_to raise_error + expect { plugin.on_response(env, nil_response) }.not_to raise_error + end + + it "can be overridden in subclasses" do + custom_plugin_class = Class.new(described_class) do + def on_response(env, response) + @response_called = true + @received_env = env + @received_response = response + "response processed" + end + + attr_reader :response_called, :received_env, :received_response + end + + plugin = custom_plugin_class.new + result = plugin.on_response(env, response) + + expect(plugin.response_called).to be true + expect(plugin.received_env).to eq(env) + expect(plugin.received_response).to eq(response) + expect(result).to eq("response processed") + end + end + + describe "#on_error" do + it "can be called without error" do + expect { plugin.on_error(exception, env) }.not_to raise_error + end + + it "returns nil by default" do + result = plugin.on_error(exception, env) + expect(result).to be_nil + end + + it "accepts any exception and environment" do + runtime_error = RuntimeError.new("Runtime error") + argument_error = ArgumentError.new("Argument error") + empty_env = {} + + expect { plugin.on_error(runtime_error, env) }.not_to raise_error + expect { plugin.on_error(argument_error, empty_env) }.not_to raise_error + end + + it "can be overridden in subclasses" do + custom_plugin_class = Class.new(described_class) do + def on_error(exception, env) + @error_called = true + @received_exception = exception + @received_env = env + "error handled" + end + + attr_reader :error_called, :received_exception, :received_env + end + + plugin = custom_plugin_class.new + result = plugin.on_error(exception, env) + + expect(plugin.error_called).to be true + expect(plugin.received_exception).to eq(exception) + expect(plugin.received_env).to eq(env) + expect(result).to eq("error handled") + end + end + + describe "inheritance" do + it "can be inherited" do + child_class = Class.new(described_class) + expect(child_class.ancestors).to include(described_class) + end + + it "maintains all lifecycle methods in subclasses" do + child_class = Class.new(described_class) do + def on_request(env) + "child_request" + end + + def on_response(env, response) + "child_response" + end + + def on_error(exception, env) + "child_error" + end + end + + plugin = child_class.new + + expect(plugin.on_request(env)).to eq("child_request") + expect(plugin.on_response(env, response)).to eq("child_response") + expect(plugin.on_error(exception, env)).to eq("child_error") + end + + it "allows selective overriding of lifecycle methods" do + partial_plugin_class = Class.new(described_class) do + def on_request(env) + "overridden request" + end + # on_response and on_error use default implementation + end + + plugin = partial_plugin_class.new + + expect(plugin.on_request(env)).to eq("overridden request") + expect(plugin.on_response(env, response)).to be_nil + expect(plugin.on_error(exception, env)).to be_nil + end + end + + describe "method signatures" do + it "on_request accepts one parameter" do + method = described_class.instance_method(:on_request) + expect(method.arity).to eq(1) + expect(method.parameters).to eq([[:req, :env]]) + end + + it "on_response accepts two parameters" do + method = described_class.instance_method(:on_response) + expect(method.arity).to eq(2) + expect(method.parameters).to eq([[:req, :env], [:req, :response]]) + end + + it "on_error accepts two parameters" do + method = described_class.instance_method(:on_error) + expect(method.arity).to eq(2) + expect(method.parameters).to eq([[:req, :exception], [:req, :env]]) + end + end + + describe "instance creation" do + it "can be instantiated" do + expect { described_class.new }.not_to raise_error + end + + it "creates unique instances" do + plugin1 = described_class.new + plugin2 = described_class.new + + expect(plugin1).not_to be(plugin2) + end + end + + describe "integration example" do + it "demonstrates typical plugin usage" do + logging_plugin_class = Class.new(described_class) do + attr_reader :logs + + def initialize + @logs = [] + end + + def on_request(env) + @logs << "Request: #{env['REQUEST_METHOD']} #{env['PATH_INFO']}" + end + + def on_response(env, response) + @logs << "Response: #{response&.dig('status') || 'unknown'}" + end + + def on_error(exception, env) + @logs << "Error: #{exception.message}" + end + end + + plugin = logging_plugin_class.new + + # Simulate request lifecycle + plugin.on_request(env) + plugin.on_response(env, response) + plugin.on_error(exception, env) + + expect(plugin.logs).to eq([ + "Request: POST /webhook", + "Response: success", + "Error: Test error" + ]) + end + end +end diff --git a/spec/unit/lib/hooks/plugins/request_validator/base_spec.rb b/spec/unit/lib/hooks/plugins/request_validator/base_spec.rb new file mode 100644 index 00000000..08b7970b --- /dev/null +++ b/spec/unit/lib/hooks/plugins/request_validator/base_spec.rb @@ -0,0 +1,273 @@ +# frozen_string_literal: true + +describe Hooks::Plugins::RequestValidator::Base do + describe ".valid?" do + let(:payload) { '{"test": "data"}' } + let(:headers) { { "Content-Type" => "application/json" } } + let(:secret) { "test_secret" } + let(:config) { { "endpoint" => "/test" } } + + it "raises NotImplementedError by default" do + expect { + described_class.valid?( + payload: payload, + headers: headers, + secret: secret, + config: config + ) + }.to raise_error(NotImplementedError, "Validator must implement .valid? class method") + end + + it "can be subclassed and overridden" do + test_validator_class = Class.new(described_class) do + def self.valid?(payload:, headers:, secret:, config:) + # Simple test implementation - check if secret is present + !secret.nil? && !secret.empty? + end + end + + # Should return true with valid secret + result = test_validator_class.valid?( + payload: payload, + headers: headers, + secret: "valid_secret", + config: config + ) + expect(result).to be true + + # Should return false with empty secret + result = test_validator_class.valid?( + payload: payload, + headers: headers, + secret: "", + config: config + ) + expect(result).to be false + + # Should return false with nil secret + result = test_validator_class.valid?( + payload: payload, + headers: headers, + secret: nil, + config: config + ) + expect(result).to be false + end + + it "accepts different payload types" do + test_validator_class = Class.new(described_class) do + def self.valid?(payload:, headers:, secret:, config:) + # Return payload class name for testing + payload.class.name == "String" + end + end + + # Test with string payload + result = test_validator_class.valid?( + payload: '{"json": "string"}', + headers: headers, + secret: secret, + config: config + ) + expect(result).to be true + + # Test with non-string payload (should be false per our test implementation) + result = test_validator_class.valid?( + payload: { json: "hash" }, + headers: headers, + secret: secret, + config: config + ) + expect(result).to be false + end + + it "accepts different header types" do + test_validator_class = Class.new(described_class) do + def self.valid?(payload:, headers:, secret:, config:) + headers.is_a?(Hash) + end + end + + # Test with hash headers + result = test_validator_class.valid?( + payload: payload, + headers: { "X-Test" => "value" }, + secret: secret, + config: config + ) + expect(result).to be true + + # Test with nil headers + result = test_validator_class.valid?( + payload: payload, + headers: nil, + secret: secret, + config: config + ) + expect(result).to be false + end + + it "accepts different secret types" do + test_validator_class = Class.new(described_class) do + def self.valid?(payload:, headers:, secret:, config:) + secret.respond_to?(:to_s) + end + end + + # Test with string secret + result = test_validator_class.valid?( + payload: payload, + headers: headers, + secret: "string_secret", + config: config + ) + expect(result).to be true + + # Test with symbol secret + result = test_validator_class.valid?( + payload: payload, + headers: headers, + secret: :symbol_secret, + config: config + ) + expect(result).to be true + + # Test with number secret + result = test_validator_class.valid?( + payload: payload, + headers: headers, + secret: 12345, + config: config + ) + expect(result).to be true + end + + it "accepts different config types" do + test_validator_class = Class.new(described_class) do + def self.valid?(payload:, headers:, secret:, config:) + config.is_a?(Hash) + end + end + + # Test with hash config + result = test_validator_class.valid?( + payload: payload, + headers: headers, + secret: secret, + config: { "validator" => "test" } + ) + expect(result).to be true + + # Test with empty hash config + result = test_validator_class.valid?( + payload: payload, + headers: headers, + secret: secret, + config: {} + ) + expect(result).to be true + + # Test with nil config + result = test_validator_class.valid?( + payload: payload, + headers: headers, + secret: secret, + config: nil + ) + expect(result).to be false + end + + it "requires all keyword arguments" do + expect { + described_class.valid?(payload: payload, headers: headers, secret: secret) + }.to raise_error(ArgumentError, /missing keyword.*config/) + + expect { + described_class.valid?(payload: payload, headers: headers, config: config) + }.to raise_error(ArgumentError, /missing keyword.*secret/) + + expect { + described_class.valid?(payload: payload, secret: secret, config: config) + }.to raise_error(ArgumentError, /missing keyword.*headers/) + + expect { + described_class.valid?(headers: headers, secret: secret, config: config) + }.to raise_error(ArgumentError, /missing keyword.*payload/) + end + end + + describe "inheritance" do + it "can be inherited" do + child_class = Class.new(described_class) + expect(child_class.ancestors).to include(described_class) + end + + it "maintains method signature in subclasses" do + child_class = Class.new(described_class) do + def self.valid?(payload:, headers:, secret:, config:) + true # Always valid for testing + end + end + + result = child_class.valid?( + payload: '{"test": "data"}', + headers: { "Content-Type" => "application/json" }, + secret: "test_secret", + config: { "endpoint" => "/test" } + ) + + expect(result).to be true + end + + it "subclasses can have different validation logic" do + test_payload = '{"test": "data"}' + test_headers = { "Content-Type" => "application/json" } + test_secret = "test_secret" + test_config = { "endpoint" => "/test" } + + always_valid_class = Class.new(described_class) do + def self.valid?(payload:, headers:, secret:, config:) + true + end + end + + never_valid_class = Class.new(described_class) do + def self.valid?(payload:, headers:, secret:, config:) + false + end + end + + expect( + always_valid_class.valid?( + payload: test_payload, + headers: test_headers, + secret: test_secret, + config: test_config + ) + ).to be true + + expect( + never_valid_class.valid?( + payload: test_payload, + headers: test_headers, + secret: test_secret, + config: test_config + ) + ).to be false + end + end + + describe "documentation compliance" do + it "has the expected public interface" do + expect(described_class.methods(false)).to include(:valid?) + end + + it "valid? method accepts the documented parameters" do + method = described_class.method(:valid?) + expect(method.parameters).to include([:keyreq, :payload]) + expect(method.parameters).to include([:keyreq, :headers]) + expect(method.parameters).to include([:keyreq, :secret]) + expect(method.parameters).to include([:keyreq, :config]) + end + end +end diff --git a/spec/unit/required_coverage_percentage.rb b/spec/unit/required_coverage_percentage.rb index b3a33155..c2d85f0a 100644 --- a/spec/unit/required_coverage_percentage.rb +++ b/spec/unit/required_coverage_percentage.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -REQUIRED_COVERAGE_PERCENTAGE = 46 +REQUIRED_COVERAGE_PERCENTAGE = 70