From 232e569527d46f3859866d6eacefcc9658c9778d Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Thu, 13 Nov 2025 14:54:50 +0100 Subject: [PATCH 1/4] feat: Add test infrastructure with multi-version Ruby support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add minitest test suite covering Configuration, Resource, and Request classes - Add Rakefile with default task for running linting and tests - Update CI workflow to test across Ruby 2.7-3.4 in matrix - Update .rubocop.yml to exclude test files from strict metrics - Add CLAUDE.md with project architecture and development commands 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/ci.yml | 13 ++-- .rubocop.yml | 17 ++++- CLAUDE.md | 95 ++++++++++++++++++++++++++ Gemfile | 6 ++ Rakefile | 15 +++++ lib/deploy/cli.rb | 8 +-- test/configuration_test.rb | 91 +++++++++++++++++++++++++ test/request_test.rb | 132 ++++++++++++++++++++++++++++++++++++ test/resource_test.rb | 135 +++++++++++++++++++++++++++++++++++++ test/test_helper.rb | 10 +++ 10 files changed, 510 insertions(+), 12 deletions(-) create mode 100644 CLAUDE.md create mode 100644 Rakefile create mode 100644 test/configuration_test.rb create mode 100644 test/request_test.rb create mode 100644 test/resource_test.rb create mode 100644 test/test_helper.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 154e760..eaccb53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,17 +2,20 @@ name: CI on: [push] jobs: - lint: + test: runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4'] steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: ${{ matrix.ruby-version }} - name: Install dependencies run: bundle install - - name: Run linter - run: bundle exec rubocop + - name: Run linter and tests + run: bundle exec rake release-please: runs-on: ubuntu-latest @@ -26,7 +29,7 @@ jobs: release: runs-on: ubuntu-latest - needs: [lint, release-please] + needs: [test, release-please] if: ${{ needs.release-please.outputs.release_created }} steps: - uses: actions/checkout@v4 diff --git a/.rubocop.yml b/.rubocop.yml index debf34e..0156e1f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,14 +1,26 @@ AllCops: TargetRubyVersion: 2.7 NewCops: enable + SuggestExtensions: false Metrics/BlockLength: Exclude: - spec/**/* + - test/**/* Metrics/ModuleLength: Exclude: - spec/**/* + - test/**/* + +Metrics/MethodLength: + Max: 14 + Exclude: + - test/**/* + +Metrics/AbcSize: + Exclude: + - test/**/* Style/StringConcatenation: Enabled: false @@ -20,8 +32,9 @@ Naming/FileName: Exclude: - lib/klogger-logger.rb -Metrics/MethodLength: - Max: 14 +Naming/PredicateMethod: + Exclude: + - lib/deploy/resource.rb Metrics/ClassLength: Max: 120 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8077e3e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,95 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +DeployHQ Ruby API library and CLI client. Provides programmatic access to the DeployHQ deployment platform and a command-line tool for triggering deployments. + +## Development Commands + +### Setup +```bash +bundle install +``` + +### Linting and testing +```bash +# Run all checks (linting + tests) +bundle exec rake + +# Run only linting +bundle exec rubocop + +# Run only tests +bundle exec rake test + +# Run a specific test file +bundle exec ruby -Ilib:test test/configuration_test.rb +``` + +### Building the gem +```bash +gem build deployhq.gemspec +``` + +### Testing CLI locally +```bash +ruby -Ilib bin/deployhq +``` + +## Architecture + +### Core Components + +**Deploy Module** (`lib/deploy.rb`): Main entry point that provides configuration management via `Deploy.configure` and `Deploy.configuration`. Configuration can be loaded from files using `Deploy.configuration_file=`. + +**Resource System**: Base class pattern where `Deploy::Resource` provides ActiveRecord-like interface for API objects: +- `find(:all)` and `find(id)` for retrieval +- `save`, `create`, `update` for persistence +- `destroy` for deletion +- Child resources (Project, Deployment, Server, ServerGroup, DeploymentStep, DeploymentStepLog) inherit this behavior + +**Request Layer** (`lib/deploy/request.rb`): HTTP client using Net::HTTP with basic auth. Handles JSON serialization/deserialization and translates HTTP status codes to appropriate exceptions or boolean success states. + +**CLI** (`lib/deploy/cli.rb`): OptionParser-based command-line interface with three main commands: +- `configure`: Interactive setup wizard for creating Deployfile +- `servers`: Lists servers and server groups +- `deploy`: Interactive deployment workflow with real-time progress via WebSocket + +**Configuration** (`lib/deploy/configuration.rb`): Loads from JSON Deployfile containing: +- `account`: DeployHQ account URL (e.g., https://account.deployhq.com) +- `username`: User email or username +- `api_key`: API key from user profile +- `project`: Default project permalink +- `websocket_hostname`: Optional WebSocket endpoint (defaults to wss://websocket.deployhq.com) + +### Resource Relationships + +Projects contain Servers and ServerGroups. Deployments belong to Projects and have DeploymentSteps. DeploymentSteps have DeploymentStepLogs. All child resources use the `:project` param to construct proper API paths like `projects/:permalink/deployments/:id`. + +### WebSocket Integration + +`Deploy::CLI::WebSocketClient` connects to deployment progress streams. `Deploy::CLI::DeploymentProgressOutput` consumes WebSocket messages and renders deployment progress to terminal in real-time. + +## Release Process + +This project uses [Conventional Commits](https://www.conventionalcommits.org/) for automated releases via [release-please](https://github.com/googleapis/release-please). + +**Commit Message Format**: +- `feat:` or `feature:` - New features +- `fix:` - Bug fixes +- `docs:` - Documentation changes +- `refactor:` - Code refactoring +- `perf:` - Performance improvements +- `chore:` - Maintenance tasks + +On merge to master, release-please analyzes commits and creates a release PR. When merged, it: +1. Updates CHANGELOG.md +2. Bumps version in lib/deploy/version.rb +3. Creates GitHub release +4. Publishes gem to RubyGems + +## Configuration File + +Each developer needs a `Deployfile` in their working directory (not committed). Use `deployhq configure` to generate interactively, or create manually following `Deployfile.example`. \ No newline at end of file diff --git a/Gemfile b/Gemfile index 32b58ed..222aaf5 100644 --- a/Gemfile +++ b/Gemfile @@ -5,4 +5,10 @@ source 'http://rubygems.org' gemspec +gem 'rake', '~> 13.0' gem 'rubocop' + +group :test do + gem 'minitest', '~> 5.0' + gem 'webmock', '~> 3.0' +end diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..6d29cd3 --- /dev/null +++ b/Rakefile @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'rake/testtask' +require 'rubocop/rake_task' + +Rake::TestTask.new(:test) do |t| + t.libs << 'test' + t.libs << 'lib' + t.test_files = FileList['test/**/*_test.rb'] + t.warning = false +end + +RuboCop::RakeTask.new + +task default: [:rubocop, :test] diff --git a/lib/deploy/cli.rb b/lib/deploy/cli.rb index ad5103b..eead0b5 100644 --- a/lib/deploy/cli.rb +++ b/lib/deploy/cli.rb @@ -100,7 +100,7 @@ def invoke(args) def server_list @server_groups ||= @project.server_groups - if @server_groups.count.positive? + if @server_groups.any? @server_groups.each do |group| puts "Group: #{group.name}" puts group.servers.map { |server| format_server(server) }.join("\n\n") @@ -108,9 +108,9 @@ def server_list end @ungrouped_servers ||= @project.servers - return unless @ungrouped_servers.count.positive? + return unless @ungrouped_servers.any? - puts "\n" if @server_groups.count.positive? + puts "\n" if @server_groups.any? puts 'Ungrouped Servers' puts @ungrouped_servers.map { |server| format_server(server) }.join("\n\n") end @@ -197,7 +197,6 @@ def format_server(server) end.join("\n") end - # rubocop:disable Lint/FormatParameterMismatch def format_kv_pair(hash) longest_key = hash.keys.map(&:length).max + 2 hash.each_with_index.map do |(k, v), _i| @@ -205,7 +204,6 @@ def format_kv_pair(hash) str end.join("\n") end - # rubocop:enable Lint/FormatParameterMismatch end diff --git a/test/configuration_test.rb b/test/configuration_test.rb new file mode 100644 index 0000000..eb3858d --- /dev/null +++ b/test/configuration_test.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'tempfile' + +class ConfigurationTest < Minitest::Test + + def test_default_websocket_hostname + config = Deploy::Configuration.new + assert_equal 'wss://websocket.deployhq.com', config.websocket_hostname + end + + def test_custom_websocket_hostname + config = Deploy::Configuration.new + config.websocket_hostname = 'wss://custom.example.com' + assert_equal 'wss://custom.example.com', config.websocket_hostname + end + + def test_setting_attributes + config = Deploy::Configuration.new + config.account = 'https://test.deployhq.com' + config.username = 'testuser' + config.api_key = 'test-api-key' + config.project = 'test-project' + + assert_equal 'https://test.deployhq.com', config.account + assert_equal 'testuser', config.username + assert_equal 'test-api-key', config.api_key + assert_equal 'test-project', config.project + end + + def test_from_file_with_all_fields + config_data = { + 'account' => 'https://test.deployhq.com', + 'username' => 'testuser', + 'api_key' => 'test-key', + 'project' => 'test-project', + 'websocket_hostname' => 'wss://test.example.com' + } + + Tempfile.create(['config', '.json']) do |f| + f.write(JSON.generate(config_data)) + f.rewind + + config = Deploy::Configuration.from_file(f.path) + + assert_equal 'https://test.deployhq.com', config.account + assert_equal 'testuser', config.username + assert_equal 'test-key', config.api_key + assert_equal 'test-project', config.project + assert_equal 'wss://test.example.com', config.websocket_hostname + end + end + + def test_from_file_without_websocket_hostname + config_data = { + 'account' => 'https://test.deployhq.com', + 'username' => 'testuser', + 'api_key' => 'test-key', + 'project' => 'test-project' + } + + Tempfile.create(['config', '.json']) do |f| + f.write(JSON.generate(config_data)) + f.rewind + + config = Deploy::Configuration.from_file(f.path) + + assert_equal 'https://test.deployhq.com', config.account + assert_equal 'wss://websocket.deployhq.com', config.websocket_hostname + end + end + + def test_from_file_raises_on_missing_file + assert_raises(Errno::ENOENT) do + Deploy::Configuration.from_file('/nonexistent/file.json') + end + end + + def test_from_file_raises_on_invalid_json + Tempfile.create(['config', '.json']) do |f| + f.write('invalid json {') + f.rewind + + assert_raises(JSON::ParserError) do + Deploy::Configuration.from_file(f.path) + end + end + end + +end diff --git a/test/request_test.rb b/test/request_test.rb new file mode 100644 index 0000000..1a87cb1 --- /dev/null +++ b/test/request_test.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'test_helper' + +class RequestTest < Minitest::Test + + def setup + Deploy.configure do |config| + config.account = 'https://test.deployhq.com' + config.username = 'testuser' + config.api_key = 'test-key' + end + end + + def test_successful_get_request + stub_request(:get, 'https://test.deployhq.com/test/path') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } + ) + .to_return(status: 200, body: '{"status":"ok"}', headers: {}) + + request = Deploy::Request.new('test/path', :get) + request.make + + assert request.success? + assert_equal '{"status":"ok"}', request.output + end + + def test_successful_post_request + stub_request(:post, 'https://test.deployhq.com/test/path') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }, + body: '{"key":"value"}' + ) + .to_return(status: 200, body: '{"created":true}', headers: {}) + + request = Deploy::Request.new('test/path', :post) + request.data = { 'key' => 'value' } + request.make + + assert request.success? + assert_equal '{"created":true}', request.output + end + + def test_not_found_error + stub_request(:get, 'https://test.deployhq.com/missing') + .to_return(status: 404, body: 'Not Found', headers: {}) + + request = Deploy::Request.new('missing', :get) + + error = assert_raises(Deploy::Errors::CommunicationError) do + request.make + end + + assert_match(/Not Found/, error.message) + end + + def test_access_denied_error + stub_request(:get, 'https://test.deployhq.com/forbidden') + .to_return(status: 403, body: 'Forbidden', headers: {}) + + request = Deploy::Request.new('forbidden', :get) + + error = assert_raises(Deploy::Errors::AccessDenied) do + request.make + end + + assert_match(/Access Denied/, error.message) + end + + def test_unauthorized_error + stub_request(:get, 'https://test.deployhq.com/unauthorized') + .to_return(status: 401, body: 'Unauthorized', headers: {}) + + request = Deploy::Request.new('unauthorized', :get) + + assert_raises(Deploy::Errors::AccessDenied) do + request.make + end + end + + def test_service_unavailable_error + stub_request(:get, 'https://test.deployhq.com/unavailable') + .to_return(status: 503, body: 'Service Unavailable', headers: {}) + + request = Deploy::Request.new('unavailable', :get) + + assert_raises(Deploy::Errors::ServiceUnavailable) do + request.make + end + end + + def test_client_error_returns_false + stub_request(:get, 'https://test.deployhq.com/bad-request') + .to_return(status: 400, body: 'Bad Request', headers: {}) + + request = Deploy::Request.new('bad-request', :get) + request.make + + refute request.success? + assert_equal 'Bad Request', request.output + end + + def test_put_request + stub_request(:put, 'https://test.deployhq.com/test/123') + .with( + basic_auth: %w[testuser test-key], + body: '{"status":"updated"}' + ) + .to_return(status: 200, body: '{"updated":true}', headers: {}) + + request = Deploy::Request.new('test/123', :put) + request.data = { 'status' => 'updated' } + request.make + + assert request.success? + end + + def test_delete_request + stub_request(:delete, 'https://test.deployhq.com/test/123') + .with(basic_auth: %w[testuser test-key]) + .to_return(status: 200, body: '', headers: {}) + + request = Deploy::Request.new('test/123', :delete) + request.make + + assert request.success? + end + +end diff --git a/test/resource_test.rb b/test/resource_test.rb new file mode 100644 index 0000000..c162bc7 --- /dev/null +++ b/test/resource_test.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ResourceTest < Minitest::Test + + def setup + Deploy.configure do |config| + config.account = 'https://test.deployhq.com' + config.username = 'testuser' + config.api_key = 'test-key' + end + end + + def test_method_missing_for_attribute_access + resource = Deploy::Resource.new + resource.attributes = { 'name' => 'Test Resource', 'status' => 'active' } + + assert_equal 'Test Resource', resource.name + assert_equal 'active', resource.status + end + + def test_method_missing_for_attribute_setting + resource = Deploy::Resource.new + resource.name = 'New Name' + resource.status = 'inactive' + + assert_equal 'New Name', resource.attributes['name'] + assert_equal 'inactive', resource.attributes['status'] + end + + def test_new_record_with_no_id + resource = Deploy::Resource.new + assert resource.new_record? + end + + def test_existing_record_with_id + resource = Deploy::Resource.new + resource.id = 123 + refute resource.new_record? + end + + def test_class_name + assert_equal 'resource', Deploy::Resource.class_name + end + + def test_collection_path + assert_equal 'resources', Deploy::Resource.collection_path + end + + def test_member_path + assert_equal 'resources/123', Deploy::Resource.member_path(123) + end + + def test_find_single_success + stub_request(:get, 'https://test.deployhq.com/resources/123') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } + ) + .to_return( + status: 200, + body: JSON.generate({ 'id' => 123, 'name' => 'Test Resource' }), + headers: { 'Content-Type' => 'application/json' } + ) + + resource = Deploy::Resource.find(123) + + assert_equal 123, resource.id + assert_equal 'Test Resource', resource.name + end + + def test_find_all_success + stub_request(:get, 'https://test.deployhq.com/resources') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } + ) + .to_return( + status: 200, + body: JSON.generate([ + { 'id' => 1, 'name' => 'Resource 1' }, + { 'id' => 2, 'name' => 'Resource 2' } + ]), + headers: { 'Content-Type' => 'application/json' } + ) + + resources = Deploy::Resource.find(:all) + + assert_equal 2, resources.length + assert_equal 1, resources[0].id + assert_equal 'Resource 1', resources[0].name + assert_equal 2, resources[1].id + assert_equal 'Resource 2', resources[1].name + end + + def test_find_all_with_pagination + stub_request(:get, 'https://test.deployhq.com/resources') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } + ) + .to_return( + status: 200, + body: JSON.generate({ + 'records' => [ + { 'id' => 1, 'name' => 'Resource 1' }, + { 'id' => 2, 'name' => 'Resource 2' } + ], + 'pagination' => { 'page' => 1, 'per_page' => 10 } + }), + headers: { 'Content-Type' => 'application/json' } + ) + + resources = Deploy::Resource.find(:all) + + assert_equal 2, resources.length + assert_equal 1, resources[0].id + end + + def test_destroy_success + resource = Deploy::Resource.new + resource.id = 123 + + stub_request(:delete, 'https://test.deployhq.com/resources/123') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } + ) + .to_return(status: 200, body: '', headers: {}) + + assert resource.destroy + end + +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..a24b254 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) + +require 'minitest/autorun' +require 'webmock/minitest' +require 'deploy' + +# Disable external HTTP requests during tests +WebMock.disable_net_connect! From 7dbd6f853116b9ab2c154dfad09540eaaa9026a9 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Thu, 13 Nov 2025 15:01:11 +0100 Subject: [PATCH 2/4] chore: Update gemspec metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update authors to DeployHQ Team - Update email to support@deployhq.com - Fix homepage URL to correct GitHub organization (deployhq) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- deployhq.gemspec | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deployhq.gemspec b/deployhq.gemspec index ba689e0..5e54a9c 100644 --- a/deployhq.gemspec +++ b/deployhq.gemspec @@ -19,7 +19,7 @@ Gem::Specification.new do |s| s.add_dependency('json', '~> 2.6') s.add_dependency('websocket-eventmachine-client', '~> 1.2') - s.authors = ['Adam Cooke'] - s.email = ['adam@k.io'] - s.homepage = 'https://github.com/krystal/deployhq-lib' + s.authors = ['DeployHQ Team'] + s.email = ['support@deployhq.com'] + s.homepage = 'https://github.com/deployhq/deployhq-lib' end From 4c31ad316e8affb7d4119db3b85b645ad9dc398a Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Thu, 13 Nov 2025 15:06:05 +0100 Subject: [PATCH 3/4] fix: Add flush before rewind in Tempfile tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add explicit f.flush call after f.write and before f.rewind in all Tempfile tests to ensure data is flushed from user-space buffer to disk before File.read reads the file. This prevents potential intermittent test failures due to buffered data not being visible to subsequent reads. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- test/configuration_test.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/configuration_test.rb b/test/configuration_test.rb index eb3858d..d516f6d 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -40,6 +40,7 @@ def test_from_file_with_all_fields Tempfile.create(['config', '.json']) do |f| f.write(JSON.generate(config_data)) + f.flush f.rewind config = Deploy::Configuration.from_file(f.path) @@ -62,6 +63,7 @@ def test_from_file_without_websocket_hostname Tempfile.create(['config', '.json']) do |f| f.write(JSON.generate(config_data)) + f.flush f.rewind config = Deploy::Configuration.from_file(f.path) @@ -80,6 +82,7 @@ def test_from_file_raises_on_missing_file def test_from_file_raises_on_invalid_json Tempfile.create(['config', '.json']) do |f| f.write('invalid json {') + f.flush f.rewind assert_raises(JSON::ParserError) do From d7660681ec33bc9f0ef688d162c782b6b8be6af7 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Thu, 13 Nov 2025 15:57:32 +0100 Subject: [PATCH 4/4] chore: Switch test framework from Minitest to RSpec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Minitest with RSpec to normalize testing infrastructure across projects. All 27 tests converted and passing with identical coverage. Changes: - Replace test/ directory with spec/ - Update Gemfile and Rakefile for RSpec - Add .rspec configuration - Update CLAUDE.md with RSpec commands 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 1 + .rspec | 3 + CLAUDE.md | 7 +- Gemfile | 4 +- Rakefile | 11 +-- spec/configuration_spec.rb | 106 ++++++++++++++++++++++++ spec/request_spec.rb | 142 ++++++++++++++++++++++++++++++++ spec/resource_spec.rb | 161 +++++++++++++++++++++++++++++++++++++ spec/spec_helper.rb | 30 +++++++ test/configuration_test.rb | 94 ---------------------- test/request_test.rb | 132 ------------------------------ test/resource_test.rb | 135 ------------------------------- test/test_helper.rb | 10 --- 13 files changed, 453 insertions(+), 383 deletions(-) create mode 100644 .rspec create mode 100644 spec/configuration_spec.rb create mode 100644 spec/request_spec.rb create mode 100644 spec/resource_spec.rb create mode 100644 spec/spec_helper.rb delete mode 100644 test/configuration_test.rb delete mode 100644 test/request_test.rb delete mode 100644 test/resource_test.rb delete mode 100644 test/test_helper.rb diff --git a/.gitignore b/.gitignore index fa3b077..64318ca 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ examples .idea Deployfile Gemfile.lock +spec/examples.txt diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..bb69742 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--require spec_helper +--format documentation +--color \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 8077e3e..1a4b648 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,10 +22,13 @@ bundle exec rake bundle exec rubocop # Run only tests -bundle exec rake test +bundle exec rspec # Run a specific test file -bundle exec ruby -Ilib:test test/configuration_test.rb +bundle exec rspec spec/configuration_spec.rb + +# Run tests with verbose output +bundle exec rspec --format documentation ``` ### Building the gem diff --git a/Gemfile b/Gemfile index 222aaf5..18fbad0 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,6 @@ gem 'rake', '~> 13.0' gem 'rubocop' group :test do - gem 'minitest', '~> 5.0' - gem 'webmock', '~> 3.0' + gem 'rspec', '~> 3.13' + gem 'webmock', '~> 3.23' end diff --git a/Rakefile b/Rakefile index 6d29cd3..5b9f9c6 100644 --- a/Rakefile +++ b/Rakefile @@ -1,15 +1,10 @@ # frozen_string_literal: true -require 'rake/testtask' +require 'rspec/core/rake_task' require 'rubocop/rake_task' -Rake::TestTask.new(:test) do |t| - t.libs << 'test' - t.libs << 'lib' - t.test_files = FileList['test/**/*_test.rb'] - t.warning = false -end +RSpec::Core::RakeTask.new(:spec) RuboCop::RakeTask.new -task default: [:rubocop, :test] +task default: [:rubocop, :spec] diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb new file mode 100644 index 0000000..0ad90c7 --- /dev/null +++ b/spec/configuration_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tempfile' + +RSpec.describe Deploy::Configuration do + describe '#websocket_hostname' do + it 'has a default value' do + config = described_class.new + expect(config.websocket_hostname).to eq('wss://websocket.deployhq.com') + end + + it 'allows setting a custom value' do + config = described_class.new + config.websocket_hostname = 'wss://custom.example.com' + expect(config.websocket_hostname).to eq('wss://custom.example.com') + end + end + + describe 'attribute setters and getters' do + it 'sets and retrieves all configuration attributes' do + config = described_class.new + config.account = 'https://test.deployhq.com' + config.username = 'testuser' + config.api_key = 'test-api-key' + config.project = 'test-project' + + expect(config.account).to eq('https://test.deployhq.com') + expect(config.username).to eq('testuser') + expect(config.api_key).to eq('test-api-key') + expect(config.project).to eq('test-project') + end + end + + describe '.from_file' do + context 'with all fields present' do + it 'loads configuration from JSON file' do + config_data = { + 'account' => 'https://test.deployhq.com', + 'username' => 'testuser', + 'api_key' => 'test-key', + 'project' => 'test-project', + 'websocket_hostname' => 'wss://test.example.com' + } + + Tempfile.create(['config', '.json']) do |f| + f.write(JSON.generate(config_data)) + f.flush + f.rewind + + config = described_class.from_file(f.path) + + expect(config.account).to eq('https://test.deployhq.com') + expect(config.username).to eq('testuser') + expect(config.api_key).to eq('test-key') + expect(config.project).to eq('test-project') + expect(config.websocket_hostname).to eq('wss://test.example.com') + end + end + end + + context 'without websocket_hostname' do + it 'uses the default websocket hostname' do + config_data = { + 'account' => 'https://test.deployhq.com', + 'username' => 'testuser', + 'api_key' => 'test-key', + 'project' => 'test-project' + } + + Tempfile.create(['config', '.json']) do |f| + f.write(JSON.generate(config_data)) + f.flush + f.rewind + + config = described_class.from_file(f.path) + + expect(config.account).to eq('https://test.deployhq.com') + expect(config.websocket_hostname).to eq('wss://websocket.deployhq.com') + end + end + end + + context 'with missing file' do + it 'raises Errno::ENOENT' do + expect do + described_class.from_file('/nonexistent/file.json') + end.to raise_error(Errno::ENOENT) + end + end + + context 'with invalid JSON' do + it 'raises JSON::ParserError' do + Tempfile.create(['config', '.json']) do |f| + f.write('invalid json {') + f.flush + f.rewind + + expect do + described_class.from_file(f.path) + end.to raise_error(JSON::ParserError) + end + end + end + end +end diff --git a/spec/request_spec.rb b/spec/request_spec.rb new file mode 100644 index 0000000..1efe24d --- /dev/null +++ b/spec/request_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Deploy::Request do + before do + Deploy.configure do |config| + config.account = 'https://test.deployhq.com' + config.username = 'testuser' + config.api_key = 'test-key' + end + end + + describe 'successful GET request' do + it 'makes a GET request with proper authentication' do + stub_request(:get, 'https://test.deployhq.com/test/path') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } + ) + .to_return(status: 200, body: '{"status":"ok"}', headers: {}) + + request = described_class.new('test/path', :get) + request.make + + expect(request.success?).to be true + expect(request.output).to eq('{"status":"ok"}') + end + end + + describe 'successful POST request' do + it 'makes a POST request with data' do + stub_request(:post, 'https://test.deployhq.com/test/path') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }, + body: '{"key":"value"}' + ) + .to_return(status: 200, body: '{"created":true}', headers: {}) + + request = described_class.new('test/path', :post) + request.data = { 'key' => 'value' } + request.make + + expect(request.success?).to be true + expect(request.output).to eq('{"created":true}') + end + end + + describe 'error handling' do + context 'when resource is not found' do + it 'raises CommunicationError' do + stub_request(:get, 'https://test.deployhq.com/missing') + .to_return(status: 404, body: 'Not Found', headers: {}) + + request = described_class.new('missing', :get) + + expect do + request.make + end.to raise_error(Deploy::Errors::CommunicationError, /Not Found/) + end + end + + context 'when access is forbidden' do + it 'raises AccessDenied' do + stub_request(:get, 'https://test.deployhq.com/forbidden') + .to_return(status: 403, body: 'Forbidden', headers: {}) + + request = described_class.new('forbidden', :get) + + expect do + request.make + end.to raise_error(Deploy::Errors::AccessDenied, /Access Denied/) + end + end + + context 'when unauthorized' do + it 'raises AccessDenied' do + stub_request(:get, 'https://test.deployhq.com/unauthorized') + .to_return(status: 401, body: 'Unauthorized', headers: {}) + + request = described_class.new('unauthorized', :get) + + expect { request.make }.to raise_error(Deploy::Errors::AccessDenied) + end + end + + context 'when service is unavailable' do + it 'raises ServiceUnavailable' do + stub_request(:get, 'https://test.deployhq.com/unavailable') + .to_return(status: 503, body: 'Service Unavailable', headers: {}) + + request = described_class.new('unavailable', :get) + + expect { request.make }.to raise_error(Deploy::Errors::ServiceUnavailable) + end + end + + context 'with client error' do + it 'returns false and sets output' do + stub_request(:get, 'https://test.deployhq.com/bad-request') + .to_return(status: 400, body: 'Bad Request', headers: {}) + + request = described_class.new('bad-request', :get) + request.make + + expect(request.success?).to be false + expect(request.output).to eq('Bad Request') + end + end + end + + describe 'PUT request' do + it 'makes a PUT request with data' do + stub_request(:put, 'https://test.deployhq.com/test/123') + .with( + basic_auth: %w[testuser test-key], + body: '{"status":"updated"}' + ) + .to_return(status: 200, body: '{"updated":true}', headers: {}) + + request = described_class.new('test/123', :put) + request.data = { 'status' => 'updated' } + request.make + + expect(request.success?).to be true + end + end + + describe 'DELETE request' do + it 'makes a DELETE request' do + stub_request(:delete, 'https://test.deployhq.com/test/123') + .with(basic_auth: %w[testuser test-key]) + .to_return(status: 200, body: '', headers: {}) + + request = described_class.new('test/123', :delete) + request.make + + expect(request.success?).to be true + end + end +end diff --git a/spec/resource_spec.rb b/spec/resource_spec.rb new file mode 100644 index 0000000..e02d0b4 --- /dev/null +++ b/spec/resource_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Deploy::Resource do + before do + Deploy.configure do |config| + config.account = 'https://test.deployhq.com' + config.username = 'testuser' + config.api_key = 'test-key' + end + end + + describe '#method_missing' do + context 'for attribute access' do + it 'allows reading attributes via method calls' do + resource = described_class.new + resource.attributes = { 'name' => 'Test Resource', 'status' => 'active' } + + expect(resource.name).to eq('Test Resource') + expect(resource.status).to eq('active') + end + end + + context 'for attribute setting' do + it 'allows setting attributes via method calls' do + resource = described_class.new + resource.name = 'New Name' + resource.status = 'inactive' + + expect(resource.attributes['name']).to eq('New Name') + expect(resource.attributes['status']).to eq('inactive') + end + end + end + + describe '#new_record?' do + context 'with no id' do + it 'returns true' do + resource = described_class.new + expect(resource.new_record?).to be true + end + end + + context 'with an id' do + it 'returns false' do + resource = described_class.new + resource.id = 123 + expect(resource.new_record?).to be false + end + end + end + + describe '.class_name' do + it 'returns the lowercase class name' do + expect(described_class.class_name).to eq('resource') + end + end + + describe '.collection_path' do + it 'returns the pluralized class name' do + expect(described_class.collection_path).to eq('resources') + end + end + + describe '.member_path' do + it 'returns the path for a specific resource' do + expect(described_class.member_path(123)).to eq('resources/123') + end + end + + describe '.find' do + context 'finding a single resource' do + it 'returns a resource instance with attributes' do + stub_request(:get, 'https://test.deployhq.com/resources/123') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } + ) + .to_return( + status: 200, + body: JSON.generate({ 'id' => 123, 'name' => 'Test Resource' }), + headers: { 'Content-Type' => 'application/json' } + ) + + resource = described_class.find(123) + + expect(resource.id).to eq(123) + expect(resource.name).to eq('Test Resource') + end + end + + context 'finding all resources' do + it 'returns an array of resource instances' do + stub_request(:get, 'https://test.deployhq.com/resources') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } + ) + .to_return( + status: 200, + body: JSON.generate([ + { 'id' => 1, 'name' => 'Resource 1' }, + { 'id' => 2, 'name' => 'Resource 2' } + ]), + headers: { 'Content-Type' => 'application/json' } + ) + + resources = described_class.find(:all) + + expect(resources.length).to eq(2) + expect(resources[0].id).to eq(1) + expect(resources[0].name).to eq('Resource 1') + expect(resources[1].id).to eq(2) + expect(resources[1].name).to eq('Resource 2') + end + end + + context 'finding all resources with pagination' do + it 'extracts records from paginated response' do + stub_request(:get, 'https://test.deployhq.com/resources') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } + ) + .to_return( + status: 200, + body: JSON.generate({ + 'records' => [ + { 'id' => 1, 'name' => 'Resource 1' }, + { 'id' => 2, 'name' => 'Resource 2' } + ], + 'pagination' => { 'page' => 1, 'per_page' => 10 } + }), + headers: { 'Content-Type' => 'application/json' } + ) + + resources = described_class.find(:all) + + expect(resources.length).to eq(2) + expect(resources[0].id).to eq(1) + end + end + end + + describe '#destroy' do + it 'makes a DELETE request and returns true on success' do + resource = described_class.new + resource.id = 123 + + stub_request(:delete, 'https://test.deployhq.com/resources/123') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } + ) + .to_return(status: 200, body: '', headers: {}) + + expect(resource.destroy).to be true + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..6c1c5ba --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) + +require 'webmock/rspec' +require 'deploy' + +# Disable external HTTP requests during tests +WebMock.disable_net_connect! + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + config.example_status_persistence_file_path = 'spec/examples.txt' + config.disable_monkey_patching! + config.warnings = false + + config.default_formatter = 'doc' if config.files_to_run.one? + + config.order = :random + Kernel.srand config.seed +end diff --git a/test/configuration_test.rb b/test/configuration_test.rb deleted file mode 100644 index d516f6d..0000000 --- a/test/configuration_test.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -require 'test_helper' -require 'tempfile' - -class ConfigurationTest < Minitest::Test - - def test_default_websocket_hostname - config = Deploy::Configuration.new - assert_equal 'wss://websocket.deployhq.com', config.websocket_hostname - end - - def test_custom_websocket_hostname - config = Deploy::Configuration.new - config.websocket_hostname = 'wss://custom.example.com' - assert_equal 'wss://custom.example.com', config.websocket_hostname - end - - def test_setting_attributes - config = Deploy::Configuration.new - config.account = 'https://test.deployhq.com' - config.username = 'testuser' - config.api_key = 'test-api-key' - config.project = 'test-project' - - assert_equal 'https://test.deployhq.com', config.account - assert_equal 'testuser', config.username - assert_equal 'test-api-key', config.api_key - assert_equal 'test-project', config.project - end - - def test_from_file_with_all_fields - config_data = { - 'account' => 'https://test.deployhq.com', - 'username' => 'testuser', - 'api_key' => 'test-key', - 'project' => 'test-project', - 'websocket_hostname' => 'wss://test.example.com' - } - - Tempfile.create(['config', '.json']) do |f| - f.write(JSON.generate(config_data)) - f.flush - f.rewind - - config = Deploy::Configuration.from_file(f.path) - - assert_equal 'https://test.deployhq.com', config.account - assert_equal 'testuser', config.username - assert_equal 'test-key', config.api_key - assert_equal 'test-project', config.project - assert_equal 'wss://test.example.com', config.websocket_hostname - end - end - - def test_from_file_without_websocket_hostname - config_data = { - 'account' => 'https://test.deployhq.com', - 'username' => 'testuser', - 'api_key' => 'test-key', - 'project' => 'test-project' - } - - Tempfile.create(['config', '.json']) do |f| - f.write(JSON.generate(config_data)) - f.flush - f.rewind - - config = Deploy::Configuration.from_file(f.path) - - assert_equal 'https://test.deployhq.com', config.account - assert_equal 'wss://websocket.deployhq.com', config.websocket_hostname - end - end - - def test_from_file_raises_on_missing_file - assert_raises(Errno::ENOENT) do - Deploy::Configuration.from_file('/nonexistent/file.json') - end - end - - def test_from_file_raises_on_invalid_json - Tempfile.create(['config', '.json']) do |f| - f.write('invalid json {') - f.flush - f.rewind - - assert_raises(JSON::ParserError) do - Deploy::Configuration.from_file(f.path) - end - end - end - -end diff --git a/test/request_test.rb b/test/request_test.rb deleted file mode 100644 index 1a87cb1..0000000 --- a/test/request_test.rb +++ /dev/null @@ -1,132 +0,0 @@ -# frozen_string_literal: true - -require 'test_helper' - -class RequestTest < Minitest::Test - - def setup - Deploy.configure do |config| - config.account = 'https://test.deployhq.com' - config.username = 'testuser' - config.api_key = 'test-key' - end - end - - def test_successful_get_request - stub_request(:get, 'https://test.deployhq.com/test/path') - .with( - basic_auth: %w[testuser test-key], - headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } - ) - .to_return(status: 200, body: '{"status":"ok"}', headers: {}) - - request = Deploy::Request.new('test/path', :get) - request.make - - assert request.success? - assert_equal '{"status":"ok"}', request.output - end - - def test_successful_post_request - stub_request(:post, 'https://test.deployhq.com/test/path') - .with( - basic_auth: %w[testuser test-key], - headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }, - body: '{"key":"value"}' - ) - .to_return(status: 200, body: '{"created":true}', headers: {}) - - request = Deploy::Request.new('test/path', :post) - request.data = { 'key' => 'value' } - request.make - - assert request.success? - assert_equal '{"created":true}', request.output - end - - def test_not_found_error - stub_request(:get, 'https://test.deployhq.com/missing') - .to_return(status: 404, body: 'Not Found', headers: {}) - - request = Deploy::Request.new('missing', :get) - - error = assert_raises(Deploy::Errors::CommunicationError) do - request.make - end - - assert_match(/Not Found/, error.message) - end - - def test_access_denied_error - stub_request(:get, 'https://test.deployhq.com/forbidden') - .to_return(status: 403, body: 'Forbidden', headers: {}) - - request = Deploy::Request.new('forbidden', :get) - - error = assert_raises(Deploy::Errors::AccessDenied) do - request.make - end - - assert_match(/Access Denied/, error.message) - end - - def test_unauthorized_error - stub_request(:get, 'https://test.deployhq.com/unauthorized') - .to_return(status: 401, body: 'Unauthorized', headers: {}) - - request = Deploy::Request.new('unauthorized', :get) - - assert_raises(Deploy::Errors::AccessDenied) do - request.make - end - end - - def test_service_unavailable_error - stub_request(:get, 'https://test.deployhq.com/unavailable') - .to_return(status: 503, body: 'Service Unavailable', headers: {}) - - request = Deploy::Request.new('unavailable', :get) - - assert_raises(Deploy::Errors::ServiceUnavailable) do - request.make - end - end - - def test_client_error_returns_false - stub_request(:get, 'https://test.deployhq.com/bad-request') - .to_return(status: 400, body: 'Bad Request', headers: {}) - - request = Deploy::Request.new('bad-request', :get) - request.make - - refute request.success? - assert_equal 'Bad Request', request.output - end - - def test_put_request - stub_request(:put, 'https://test.deployhq.com/test/123') - .with( - basic_auth: %w[testuser test-key], - body: '{"status":"updated"}' - ) - .to_return(status: 200, body: '{"updated":true}', headers: {}) - - request = Deploy::Request.new('test/123', :put) - request.data = { 'status' => 'updated' } - request.make - - assert request.success? - end - - def test_delete_request - stub_request(:delete, 'https://test.deployhq.com/test/123') - .with(basic_auth: %w[testuser test-key]) - .to_return(status: 200, body: '', headers: {}) - - request = Deploy::Request.new('test/123', :delete) - request.make - - assert request.success? - end - -end diff --git a/test/resource_test.rb b/test/resource_test.rb deleted file mode 100644 index c162bc7..0000000 --- a/test/resource_test.rb +++ /dev/null @@ -1,135 +0,0 @@ -# frozen_string_literal: true - -require 'test_helper' - -class ResourceTest < Minitest::Test - - def setup - Deploy.configure do |config| - config.account = 'https://test.deployhq.com' - config.username = 'testuser' - config.api_key = 'test-key' - end - end - - def test_method_missing_for_attribute_access - resource = Deploy::Resource.new - resource.attributes = { 'name' => 'Test Resource', 'status' => 'active' } - - assert_equal 'Test Resource', resource.name - assert_equal 'active', resource.status - end - - def test_method_missing_for_attribute_setting - resource = Deploy::Resource.new - resource.name = 'New Name' - resource.status = 'inactive' - - assert_equal 'New Name', resource.attributes['name'] - assert_equal 'inactive', resource.attributes['status'] - end - - def test_new_record_with_no_id - resource = Deploy::Resource.new - assert resource.new_record? - end - - def test_existing_record_with_id - resource = Deploy::Resource.new - resource.id = 123 - refute resource.new_record? - end - - def test_class_name - assert_equal 'resource', Deploy::Resource.class_name - end - - def test_collection_path - assert_equal 'resources', Deploy::Resource.collection_path - end - - def test_member_path - assert_equal 'resources/123', Deploy::Resource.member_path(123) - end - - def test_find_single_success - stub_request(:get, 'https://test.deployhq.com/resources/123') - .with( - basic_auth: %w[testuser test-key], - headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } - ) - .to_return( - status: 200, - body: JSON.generate({ 'id' => 123, 'name' => 'Test Resource' }), - headers: { 'Content-Type' => 'application/json' } - ) - - resource = Deploy::Resource.find(123) - - assert_equal 123, resource.id - assert_equal 'Test Resource', resource.name - end - - def test_find_all_success - stub_request(:get, 'https://test.deployhq.com/resources') - .with( - basic_auth: %w[testuser test-key], - headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } - ) - .to_return( - status: 200, - body: JSON.generate([ - { 'id' => 1, 'name' => 'Resource 1' }, - { 'id' => 2, 'name' => 'Resource 2' } - ]), - headers: { 'Content-Type' => 'application/json' } - ) - - resources = Deploy::Resource.find(:all) - - assert_equal 2, resources.length - assert_equal 1, resources[0].id - assert_equal 'Resource 1', resources[0].name - assert_equal 2, resources[1].id - assert_equal 'Resource 2', resources[1].name - end - - def test_find_all_with_pagination - stub_request(:get, 'https://test.deployhq.com/resources') - .with( - basic_auth: %w[testuser test-key], - headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } - ) - .to_return( - status: 200, - body: JSON.generate({ - 'records' => [ - { 'id' => 1, 'name' => 'Resource 1' }, - { 'id' => 2, 'name' => 'Resource 2' } - ], - 'pagination' => { 'page' => 1, 'per_page' => 10 } - }), - headers: { 'Content-Type' => 'application/json' } - ) - - resources = Deploy::Resource.find(:all) - - assert_equal 2, resources.length - assert_equal 1, resources[0].id - end - - def test_destroy_success - resource = Deploy::Resource.new - resource.id = 123 - - stub_request(:delete, 'https://test.deployhq.com/resources/123') - .with( - basic_auth: %w[testuser test-key], - headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } - ) - .to_return(status: 200, body: '', headers: {}) - - assert resource.destroy - end - -end diff --git a/test/test_helper.rb b/test/test_helper.rb deleted file mode 100644 index a24b254..0000000 --- a/test/test_helper.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -$LOAD_PATH.unshift File.expand_path('../lib', __dir__) - -require 'minitest/autorun' -require 'webmock/minitest' -require 'deploy' - -# Disable external HTTP requests during tests -WebMock.disable_net_connect!