From 7986a5dea4d0e65ae67d702c423542e93f057d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Zaj=C4=85c?= Date: Mon, 17 Aug 2026 01:11:11 +0200 Subject: [PATCH] Modernize this a little bit --- .github/workflows/ci.yml | 56 +++++ .rubocop.yml | 16 ++ .travis.yml | 4 - Gemfile | 10 + README.md | 56 ++++- Rakefile | 24 +- bin/console | 7 +- ftpsync.gemspec | 44 ++-- lib/ftpsync.rb | 128 ++++++---- lib/ftpsync/version.rb | 4 +- test/ftpsync_test.rb | 234 ++++++++++++++++++- test/integration/ftp_server.rb | 140 +++++++++++ test/integration/ftpsync_integration_test.rb | 109 +++++++++ test/test_helper.rb | 4 +- 14 files changed, 743 insertions(+), 93 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .rubocop.yml delete mode 100644 .travis.yml create mode 100644 test/integration/ftp_server.rb create mode 100644 test/integration/ftpsync_integration_test.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..277722c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + workflow_dispatch: + +jobs: + test: + name: Unit tests (Ruby ${{ matrix.ruby }}) + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental == true }} + strategy: + fail-fast: false + matrix: + include: + - ruby: '3.0' + - ruby: '3.1' + - ruby: '3.2' + - ruby: '3.3' + - ruby: '3.4' + - ruby: '4.0' + # ruby-head (trunk); failures here must not block CI. + - ruby: 'head' + experimental: true + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: ${{ matrix.ruby }} + - run: bundle install + - run: bundle exec rake test + + test-integration: + name: Integration tests (vsftpd via Docker) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: '4.0' + - run: bundle install + - run: bundle exec rake test:integration + + lint: + name: Lint and package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: '4.0' + - run: bundle install + - run: bundle exec rake rubocop + - run: gem build ftpsync.gemspec \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..c46a38a --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,16 @@ +plugins: + - rubocop-minitest + - rubocop-rake + +AllCops: + NewCops: enable + TargetRubyVersion: 2.6 + +Metrics/AbcSize: + Max: 20 + +Metrics/ClassLength: + Max: 200 + +Metrics/MethodLength: + Max: 20 \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9aac067..0000000 --- a/.travis.yml +++ /dev/null @@ -1,4 +0,0 @@ -language: ruby -rvm: - - 2.2.1 -before_install: gem install bundler -v 1.10.5 diff --git a/Gemfile b/Gemfile index 81537c5..4d00c83 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,14 @@ +# frozen_string_literal: true + source 'https://rubygems.org' # Specify your gem's dependencies in ftpsync.gemspec gemspec + +group :development, :test do + gem 'minitest', '~> 5.0' + gem 'rake', '~> 13.0' + gem 'rubocop', '~> 1.0' + gem 'rubocop-minitest', '~> 0.40' + gem 'rubocop-rake', '~> 0.7' +end diff --git a/README.md b/README.md index 154a833..169534e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # FtpSync +[![CI](https://github.com/Quintasan/ftpsync/actions/workflows/ci.yml/badge.svg)](https://github.com/Quintasan/ftpsync/actions/workflows/ci.yml) + A simple library for synchronizing from/to FTP servers. ## Installation @@ -25,22 +27,66 @@ example = FtpSync::Simple.new "ftp.example.com", "username", "password" example.pull_dir('remotepath', 'localpath') ``` +### Options + +`FtpSync::Simple#new` accepts an options hash: + +| Option | Default | Description | +|------------|---------|------------------------------------| +| `:port` | `21` | FTP port to connect to | +| `:passive` | `false` | Use passive mode | +| `:verbose` | `false` | Log progress to stderr | + +`FtpSync::Simple#pull_dir` accepts an options hash: + +| Option | Description | +|----------------|------------------------------------------------------------| +| `:since` | Skip files that already exist locally and are up to date | +| `:skip_errors` | Rescue `Net::FTPPermError` and continue instead of raising | + +> **Note:** `:since` compares size and the mtime reported by the server's +> `LIST` output, which usually has minute precision. A remote file of the +> same size that was modified within the same minute as the local copy may +> therefore be skipped. + +Passing a block to `pull_dir` invokes it with the local path of each +downloaded file: + +```ruby +example.pull_dir('remotepath', 'localpath', since: true) do |file| + process(file) +end +``` + ## Development -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake` to run the tests and RuboCop (or `rake test` / `rake rubocop` individually). You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +### Integration tests (requires Docker) + +The integration suite runs against a real FTP server +([garethflowers/ftp-server](https://garethflowers.dev/docker-ftp-server/), +vsftpd) in a Docker container: + + $ rake test:integration + +The container listens on `127.0.0.1:2121` (control) and +`127.0.0.1:40000-40009` (passive data). The tests are skipped automatically +if Docker is not available, and the container is created and removed for +each run. Override the credentials and control port with the +`FTPSYNC_TEST_USER`, `FTPSYNC_TEST_PASS` and `FTPSYNC_TEST_FTP_PORT` +environment variables. To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/quintasan/ftpsync. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](contributor-covenant.org) code of conduct. - +Bug reports and pull requests are welcome on GitHub at https://github.com/quintasan/ftpsync. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](https://www.contributor-covenant.org) code of conduct. ## License The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). - ## Credits -This gem is a basically stripped down version of [orlando/ftp_sync](https://github.com/orlando/ftp_sync) which I couldn't get to work. +This gem is a basically stripped down version of [orlando/ftp_sync](https://github.com/orlando/ftp_sync) which I couldn't get to work. \ No newline at end of file diff --git a/Rakefile b/Rakefile index d6c5113..d14e357 100644 --- a/Rakefile +++ b/Rakefile @@ -1,10 +1,22 @@ -require "bundler/gem_tasks" -require "rake/testtask" +# frozen_string_literal: true + +require 'bundler/gem_tasks' +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.libs << 'test' + t.libs << 'lib' + t.test_files = FileList['test/*_test.rb'] +end + +desc 'Run integration tests against a Dockerized FTP server' +Rake::TestTask.new(:'test:integration') do |t| + t.libs << 'test' + t.libs << 'lib' + t.test_files = FileList['test/integration/**/*_test.rb'] end -task :default => :test +RuboCop::RakeTask.new + +task default: %i[test rubocop] diff --git a/bin/console b/bin/console index fa5bd52..b6ccc6d 100755 --- a/bin/console +++ b/bin/console @@ -1,7 +1,8 @@ #!/usr/bin/env ruby +# frozen_string_literal: true -require "bundler/setup" -require "ftpsync" +require 'bundler/setup' +require 'ftpsync' # You can add fixtures and/or initialization code here to make experimenting # with your gem easier. You can also use a different console, if you like. @@ -10,5 +11,5 @@ require "ftpsync" # require "pry" # Pry.start -require "irb" +require 'irb' IRB.start diff --git a/ftpsync.gemspec b/ftpsync.gemspec index 0e2986b..c5c2191 100644 --- a/ftpsync.gemspec +++ b/ftpsync.gemspec @@ -1,26 +1,32 @@ -# coding: utf-8 -lib = File.expand_path('../lib', __FILE__) -$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require 'ftpsync/version' +# frozen_string_literal: true + +require_relative 'lib/ftpsync/version' Gem::Specification.new do |spec| - spec.name = "ftpsync" - spec.version = FtpSync::VERSION - spec.authors = ["Michał Zając"] - spec.email = ["rubygems.org@quintasan.pl"] + spec.name = 'ftpsync' + spec.version = FtpSync::VERSION + spec.authors = ['Michał Zając'] + spec.email = ['rubygems.org@quintasan.pl'] - spec.summary = %q{A simple library for synchronizing from/to FTP servers.} - spec.homepage = "https://github.com/Quintasan/ftpsync" - spec.license = "MIT" + spec.summary = 'A simple library for synchronizing from/to FTP servers.' + spec.description = 'Recursively pull directory trees from FTP servers, with support ' \ + 'for incremental syncs and per-file callbacks.' + spec.homepage = 'https://github.com/Quintasan/ftpsync' + spec.license = 'MIT' + spec.required_ruby_version = '>= 2.6.0' - spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } - spec.bindir = "exe" - spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } - spec.require_paths = ["lib"] + spec.metadata = { + 'source_code_uri' => spec.homepage, + 'rubygems_mfa_required' => 'true' + } - spec.add_dependency "net-ftp-list", ">= 3.2.8" + spec.files = Dir.chdir(__dir__) do + `git ls-files -z`.split("\x0").reject do |f| + f.match?(%r{^(test|spec|features)/|^\.github/}) || !File.file?(f) + end + end + spec.require_paths = ['lib'] - spec.add_development_dependency "bundler", "~> 1.10" - spec.add_development_dependency "rake", "~> 10.0" - spec.add_development_dependency "minitest" + spec.add_dependency 'net-ftp' + spec.add_dependency 'net-ftp-list', '>= 3.2.8' end diff --git a/lib/ftpsync.rb b/lib/ftpsync.rb index a2c607e..656c49f 100644 --- a/lib/ftpsync.rb +++ b/lib/ftpsync.rb @@ -1,96 +1,122 @@ -require "ftpsync/version" -require "net/ftp" -require "net/ftp/list" +# frozen_string_literal: true + require 'fileutils' +require 'ftpsync/version' +require 'net/ftp' +require 'net/ftp/list' module FtpSync + # Synchronizes directories against an FTP server. class Simple attr_accessor :server, :port, :username, :password, :passive def initialize(server, username, password, options = {}) @server = server - @port = options[:port] || 21 + @port = options.fetch(:port, 21) @username = username @password = password - @passive = options[:passive] || false - @verbose = options[:verbose] || false + @passive = options.fetch(:passive, false) + @verbose = options.fetch(:verbose, false) @connection = nil @level = 0 end + # Recursively pulls +remotepath+ into +localpath+. + # + # Options: + # :since skip files that already exist locally and are up to date + # :skip_errors rescue Net::FTPPermError and keep going + # + # If a block is given, it is invoked with the local path of each + # downloaded file. def pull_dir(remotepath, localpath, options = {}, &block) - FileUtils.mkpath(localpath) unless File.exist?(localpath) and log "Creating #{localpath} directory" + ensure_local_directory(localpath) connect! unless @connection @level += 1 - todelete = Dir.glob(File.join(localpath, '*')) - directories = [] - files = [] - - @connection.list(remotepath) do |e| - entry = Net::FTP::List.parse(e) - - paths = [ "#{remotepath}/#{entry.basename}".gsub(/\/+/, '/'), File.join(localpath, entry.basename)] + directories, files = scan_directory(remotepath, localpath, options) - if entry.dir? - if entry.name == "." or entry.name == ".." - next - else - directories << paths - end - elsif entry.file? - if options[:since] - files << paths unless File.exist?(paths[1]) and entry.mtime < File.mtime(paths[1]) and entry.filesize == File.size(paths[1]) - else - files << paths - end - end - todelete.delete paths[1] + directories.each do |remote_dir, local_dir| + pull_dir(remote_dir, local_dir, options, &block) end - directories.each do |paths| - remotedir, localdir = paths - pull_dir(remotedir, localdir, options, &block) - end - - files.each do |paths| - remotefile, localfile = paths - begin - log "#{remotefile} => #{localfile}" - @connection.get(remotefile, localfile) - if block_given? - log "Executing block" - yield(localfile) - end - rescue Net::FTPPermError - log "Error when reading #{remotefile}" - raise Net::FTPPermError unless options[:skip_errors] - end + files.each do |remote_file, local_file| + download(remote_file, local_file, options, &block) end @level -= 1 - close! if @level == 0 + close! if @level.zero? end def connect! @connection = Net::FTP.new - log "Connecting to #{@server}:#{@port} in #{@passive ? "passive" : "active"} mode" + log "Connecting to #{@server}:#{@port} in #{@passive ? 'passive' : 'active'} mode" @connection.connect(@server, @port) @connection.passive = @passive - log "Logging in as #{@username}:#{@password}" + log "Logging in as #{@username}" @connection.login(@username, @password) log "Successfully opened connection to #{@server}:#{@port}" end def close! @connection.close + @connection = nil log "Closed connection to #{@server}:#{@port}" end def log(message) - $stderr.puts message if @verbose + warn message if @verbose + end + + private + + def ensure_local_directory(localpath) + return if File.directory?(localpath) + + FileUtils.mkpath(localpath) + log "Creating #{localpath} directory" + end + + def scan_directory(remotepath, localpath, options) + directories = [] + files = [] + + @connection.list(remotepath) do |line| + entry = Net::FTP::List.parse(line) + next if %w[. ..].include?(entry.name) + + remote_child = join_remote(remotepath, entry.basename) + local_child = File.join(localpath, entry.basename) + + if entry.dir? + directories << [remote_child, local_child] + elsif entry.file? + files << [remote_child, local_child] unless skip_file?(local_child, entry, options) + end + end + + [directories, files] + end + + def skip_file?(local_path, entry, options) + return false unless options[:since] + return false unless File.file?(local_path) + + entry.mtime < File.mtime(local_path) && entry.filesize == File.size(local_path) + end + + def download(remote_file, local_file, options) + log "#{remote_file} => #{local_file}" + @connection.get(remote_file, local_file) + yield(local_file) if block_given? + rescue Net::FTPPermError => e + log "Error when reading #{remote_file}" + raise e unless options[:skip_errors] end + def join_remote(remote_dir, basename) + "#{remote_dir}/#{basename}".gsub(%r{/+}, '/') + end end end diff --git a/lib/ftpsync/version.rb b/lib/ftpsync/version.rb index 921f51d..b6f1d3d 100644 --- a/lib/ftpsync/version.rb +++ b/lib/ftpsync/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module FtpSync - VERSION = "0.0.4" + VERSION = '0.0.4' end diff --git a/test/ftpsync_test.rb b/test/ftpsync_test.rb index 6007cfe..2227f88 100644 --- a/test/ftpsync_test.rb +++ b/test/ftpsync_test.rb @@ -1,11 +1,239 @@ +# frozen_string_literal: true + require 'test_helper' +require 'minitest/mock' class FtpsyncTest < Minitest::Test + FakeEntry = Struct.new(:name, :dir, :mtime, :filesize) do + def basename + name + end + + def dir? + dir + end + + def file? + !dir + end + end + + # Stand-in for Net::FTP that serves precomputed LIST lines and records + # downloads instead of touching a real server. + class FakeFtp + attr_reader :downloaded, :connect_count + + def initialize(listings) + @listings = listings + @downloaded = [] + @connect_count = 0 + @closed = false + end + + def connect(_host, _port) + @connect_count += 1 + @closed = false + end + + def passive=(_enabled); end + + def login(_username, _password); end + + def list(remote, &block) + @listings.fetch(remote, {}).each_key { |line| block.call(line) } + end + + def get(remote, local) + raise Net::FTPPermError, 'Permission denied' if remote.include?('secret') + + @downloaded << [remote, local] + end + + def close + @closed = true + end + + def closed? + @closed + end + end + def test_that_it_has_a_version_number - refute_nil ::Ftpsync::VERSION + refute_nil ::FtpSync::VERSION + end + + def test_downloads_files_and_recurses_into_directories + with_local_dir do |base| + with_ftp( + 'root' => { + 'd subdir' => file_entry('subdir', dir: true), + 'f a.txt' => file_entry('a.txt') + }, + 'root/subdir' => { + 'f b.txt' => file_entry('b.txt') + } + ) do |ftp| + sync = build_sync + sync.pull_dir('root', base) + + # Directories are pulled before files, hence b.txt comes first. + assert_equal [ + ['root/subdir/b.txt', File.join(base, 'subdir', 'b.txt')], + ['root/a.txt', File.join(base, 'a.txt')] + ], ftp.downloaded + end + end + end + + def test_creates_missing_local_directories + with_local_dir do |base| + with_ftp('root' => {}) do + sync = build_sync + local = File.join(base, 'nested', 'deep') + + sync.pull_dir('root', local) + + assert File.directory?(local) + end + end + end + + def test_yields_block_with_local_path_of_each_downloaded_file + with_local_dir do |base| + with_ftp('root' => { 'f a.txt' => file_entry('a.txt') }) do + sync = build_sync + downloaded = [] + + sync.pull_dir('root', base) { |path| downloaded << path } + + assert_equal [File.join(base, 'a.txt')], downloaded + end + end + end + + def test_since_skips_unchanged_files + with_local_dir do |base| + time = Time.now - 3600 + local = File.join(base, 'a.txt') + File.write(local, 'data') + File.utime(time, time, local) + + with_ftp('root' => { 'f a.txt' => file_entry('a.txt', mtime: time - 100, filesize: 4) }) do |ftp| + sync = build_sync + sync.pull_dir('root', base, since: true) + + assert_empty ftp.downloaded + end + end + end + + def test_since_downloads_missing_or_changed_files + with_local_dir do |base| + time = Time.now - 3600 + local = File.join(base, 'a.txt') + File.write(local, 'data') + File.utime(time, time, local) + + with_ftp( + 'root' => { + 'f a.txt' => file_entry('a.txt', mtime: time + 100, filesize: 4), # newer than local + 'f b.txt' => file_entry('b.txt', mtime: time - 100, filesize: 4) # missing locally + } + ) do |ftp| + sync = build_sync + sync.pull_dir('root', base, since: true) + + assert_equal [ + ['root/a.txt', File.join(base, 'a.txt')], + ['root/b.txt', File.join(base, 'b.txt')] + ], ftp.downloaded + end + end + end + + def test_skip_errors_continues_after_permission_denied + with_local_dir do |base| + with_ftp( + 'root' => { + 'f secret.txt' => file_entry('secret.txt'), + 'f a.txt' => file_entry('a.txt') + } + ) do |ftp| + sync = build_sync + sync.pull_dir('root', base, skip_errors: true) + + assert_equal [['root/a.txt', File.join(base, 'a.txt')]], ftp.downloaded + end + end + end + + def test_raises_without_skip_errors + with_local_dir do |base| + with_ftp('root' => { 'f secret.txt' => file_entry('secret.txt') }) do + sync = build_sync + + error = assert_raises Net::FTPPermError do + sync.pull_dir('root', base) + end + + assert_equal 'Permission denied', error.message + end + end end - def test_it_does_something_useful - assert false + def test_reuses_connection_across_nested_directories + with_local_dir do |base| + with_ftp( + 'root' => { 'd subdir' => file_entry('subdir', dir: true) }, + 'root/subdir' => {} + ) do |ftp| + sync = build_sync + sync.pull_dir('root', base) + + assert_equal 1, ftp.connect_count + assert_predicate ftp, :closed? + end + end + end + + def test_reconnects_for_a_fresh_top_level_pull + with_local_dir do |base| + with_ftp('root' => {}) do |ftp| + sync = build_sync + sync.pull_dir('root', base) + sync.pull_dir('root', base) + + assert_equal 2, ftp.connect_count + end + end + end + + private + + def file_entry(name, dir: false, mtime: Time.now, filesize: 0) + FakeEntry.new(name, dir, mtime, filesize) + end + + def build_sync + FtpSync::Simple.new('ftp.example.com', 'user', 'pass', verbose: false) + end + + def with_local_dir(&block) + Dir.mktmpdir(&block) + end + + # Runs the block with Net::FTP.new and Net::FTP::List.parse substituted by + # fakes. +listings+ maps a remote path to a hash of raw LIST line => entry. + def with_ftp(listings) + entries = listings.each_with_object({}) do |(_path, lines), memo| + lines.each { |line, entry| memo[line] = entry } + end + + ftp = FakeFtp.new(listings) + Net::FTP.stub(:new, ftp) do + Net::FTP::List.stub(:parse, ->(line) { entries.fetch(line) }) do + yield ftp + end + end end end diff --git a/test/integration/ftp_server.rb b/test/integration/ftp_server.rb new file mode 100644 index 0000000..d3f71c0 --- /dev/null +++ b/test/integration/ftp_server.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'net/ftp' +require 'open3' +require 'tempfile' + +# Manages a garethflowers/ftp-server (vsftpd) container for integration +# tests. See https://garethflowers.dev/docker-ftp-server/ +class FtpServer + IMAGE = 'garethflowers/ftp-server' + HOST = '127.0.0.1' + FTP_PORT = Integer(ENV.fetch('FTPSYNC_TEST_FTP_PORT', '2121')) + PASV_PORTS = (40_000..40_009).freeze + USER = ENV.fetch('FTPSYNC_TEST_USER', 'testuser') + PASS = ENV.fetch('FTPSYNC_TEST_PASS', 'testpass') + CONTAINER = "ftpsync-test-#{Process.pid}" + + def self.available? + system('docker', 'version', out: File::NULL, err: File::NULL) + end + + def start + remove_container + docker_run + wait_until_ready + end + + def stop + remove_container + end + + # Deletes everything from the FTP home so tests start from a clean slate. + def reset + with_ftp { |ftp| delete_tree(ftp, '.') } + end + + # Uploads the given files (remote path => content) to the FTP server, + # creating any intermediate directories on the way. + def seed(files) + with_ftp do |ftp| + files.each do |remote_path, content| + mkdir_p(ftp, File.dirname(remote_path)) + with_tempfile(content) { |tmp| ftp.putbinaryfile(tmp.path, remote_path) } + end + end + end + + def logs + `docker logs #{CONTAINER} 2>&1`.lines.last(20).join + end + + private + + def docker_run + docker('run', '-d', '--name', CONTAINER, + '-p', "#{HOST}:#{FTP_PORT}:21", + '-p', "#{HOST}:#{PASV_PORTS.first}-#{PASV_PORTS.last}:#{PASV_PORTS.first}-#{PASV_PORTS.last}", + '-e', "FTP_USER=#{USER}", + '-e', "FTP_PASS=#{PASS}", + '-e', "PUBLIC_IP=#{HOST}", + IMAGE) + end + + def wait_until_ready(timeout: 60) + deadline = Time.now + timeout + until ftp_ready? + raise "FTP server did not become ready within #{timeout}s\n#{logs}" if Time.now > deadline + + sleep 0.5 + end + end + + def ftp_ready? + with_ftp(&:pwd) + true + rescue StandardError + false + end + + def with_ftp + ftp = Net::FTP.new + ftp.connect(HOST, FTP_PORT) + ftp.login(USER, PASS) + ftp.passive = true + yield ftp + ensure + ftp&.close + end + + def with_tempfile(content) + Tempfile.create do |tmp| + tmp.binmode + tmp.write(content) + tmp.flush + yield tmp + end + end + + def delete_tree(ftp, dir) + ftp.nlst(dir).each do |name| + next if %w[. ..].include?(name) + + begin + ftp.delete(name) + rescue Net::FTPPermError + delete_tree(ftp, name) + ftp.rmdir(name) + end + end + end + + def mkdir_p(ftp, dir) + return if dir.empty? || dir == '.' + + parts = dir.split('/') + parts.each_index do |i| + path = parts[0..i].join('/') + begin + ftp.mkdir(path) + rescue Net::FTPPermError + # Ignore: the directory already exists. + end + end + end + + def remove_container + docker('rm', '-f', CONTAINER) if container_exists? + end + + def container_exists? + system('docker', 'inspect', CONTAINER, out: File::NULL, err: File::NULL) + end + + def docker(*args) + _stdout, stderr, status = Open3.capture3('docker', *args) + raise "docker #{args.join(' ')} failed: #{stderr.strip}" unless status.success? + rescue Errno::ENOENT + raise 'Docker is not installed or not reachable' + end +end diff --git a/test/integration/ftpsync_integration_test.rb b/test/integration/ftpsync_integration_test.rb new file mode 100644 index 0000000..9a791ee --- /dev/null +++ b/test/integration/ftpsync_integration_test.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'test_helper' +require_relative 'ftp_server' + +class FtpsyncIntegrationTest < Minitest::Test + def self.server + @server ||= FtpServer.new.tap(&:start) + end + + Minitest.after_run do + @server&.stop + end + + def setup + skip 'Docker is not available' unless FtpServer.available? + server.reset + end + + def test_pulls_a_directory_tree + server.seed( + 'root/a.txt' => 'hello a', + 'root/sub/b.txt' => 'hello b' + ) + + Dir.mktmpdir do |local| + sync.pull_dir('root', local) + + assert_equal 'hello a', File.read(File.join(local, 'a.txt')) + assert_equal 'hello b', File.read(File.join(local, 'sub', 'b.txt')) + end + end + + def test_preserves_binary_file_contents + payload = Random.bytes(1_048_576) + server.seed('root/blob.bin' => payload) + + Dir.mktmpdir do |local| + sync.pull_dir('root', local) + + assert_equal payload, File.binread(File.join(local, 'blob.bin')) + end + end + + def test_creates_missing_local_directories + server.seed('root/a.txt' => 'hello') + + Dir.mktmpdir do |base| + local = File.join(base, 'nested', 'deep') + + sync.pull_dir('root', local) + + assert File.directory?(local) + assert_equal 'hello', File.read(File.join(local, 'a.txt')) + end + end + + def test_invokes_block_for_each_downloaded_file + server.seed('root/a.txt' => 'one', 'root/b.txt' => 'two') + + Dir.mktmpdir do |local| + downloaded = [] + sync.pull_dir('root', local) { |path| downloaded << path } + + assert_equal [File.join(local, 'a.txt'), File.join(local, 'b.txt')], + downloaded.sort + end + end + + def test_since_skips_unchanged_files + server.seed('root/a.txt' => 'v1') + + Dir.mktmpdir do |local| + sync.pull_dir('root', local, since: true) + + downloaded = [] + sync.pull_dir('root', local, since: true) { |path| downloaded << path } + + assert_empty downloaded + assert_equal 'v1', File.read(File.join(local, 'a.txt')) + end + end + + def test_since_refetches_changed_files + server.seed('root/a.txt' => 'version one') + + Dir.mktmpdir do |local| + sync.pull_dir('root', local, since: true) + server.seed('root/a.txt' => 'version two, longer') + + downloaded = [] + sync.pull_dir('root', local, since: true) { |path| downloaded << path } + + assert_equal [File.join(local, 'a.txt')], downloaded + assert_equal 'version two, longer', File.read(File.join(local, 'a.txt')) + end + end + + private + + def server + self.class.server + end + + def sync + FtpSync::Simple.new(FtpServer::HOST, FtpServer::USER, FtpServer::PASS, + passive: true, port: FtpServer::FTP_PORT) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 603f1ca..6a47b14 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,4 +1,6 @@ -$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) require 'ftpsync' require 'minitest/autorun'