├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── LICENSE.md ├── README.md ├── Rakefile ├── bin └── fake_sqs ├── fake_sqs.gemspec ├── lib ├── fake_sqs.rb └── fake_sqs │ ├── actions │ ├── change_message_visibility.rb │ ├── change_message_visibility_batch.rb │ ├── create_queue.rb │ ├── delete_message.rb │ ├── delete_message_batch.rb │ ├── delete_queue.rb │ ├── get_queue_attributes.rb │ ├── get_queue_url.rb │ ├── list_dead_letter_source_queues.rb │ ├── list_queues.rb │ ├── purge_queue.rb │ ├── receive_message.rb │ ├── send_message.rb │ ├── send_message_batch.rb │ └── set_queue_attributes.rb │ ├── api.rb │ ├── catch_errors.rb │ ├── collection_view.rb │ ├── daemonize.rb │ ├── databases │ ├── file.rb │ └── memory.rb │ ├── error_response.rb │ ├── error_responses.yml │ ├── message.rb │ ├── queue.rb │ ├── queue_factory.rb │ ├── queues.rb │ ├── responder.rb │ ├── server.rb │ ├── show_output.rb │ ├── test_integration.rb │ ├── version.rb │ └── web_interface.rb └── spec ├── acceptance ├── message_actions_spec.rb └── queue_actions_spec.rb ├── integration_spec_helper.rb ├── spec_helper.rb └── unit ├── api_spec.rb ├── catch_errors_spec.rb ├── collection_view_spec.rb ├── error_response_spec.rb ├── message_spec.rb ├── queue_factory_spec.rb ├── queue_spec.rb ├── queues_spec.rb ├── responder_spec.rb ├── show_output_spec.rb └── web_interface_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | .DS_Store 7 | Gemfile.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --order random 4 | --require spec_helper 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.2.2 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in fake_sqs.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2012 Iain Hecker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fake SQS [![Build Status](https://api.travis-ci.org/iain/fake_sqs.svg?branch=master)](http://travis-ci.org/iain/fake_sqs) [![Gem Version](https://badge.fury.io/rb/fake_sqs.svg)](https://badge.fury.io/rb/fake_sqs) 2 | 3 | Fake SQS is a lightweight server that mocks the Amazon SQS API. 4 | 5 | It is extremely useful for testing SQS applications in a sandbox environment without actually 6 | making calls to Amazon, which not only requires a network connection, but also costs 7 | money. 8 | 9 | Many features are supported and if you miss something, open a pull. 10 | 11 | ## Installation 12 | 13 | ``` 14 | gem install fake_sqs 15 | ``` 16 | 17 | ## Running 18 | 19 | ``` 20 | fake_sqs --database /path/to/database.yml 21 | ``` 22 | 23 | ## Development 24 | 25 | ``` 26 | bundle install 27 | rake 28 | ``` 29 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | require "tempfile" 4 | require 'rspec/core/rake_task' 5 | 6 | namespace :spec do 7 | 8 | desc "Run only unit specs" 9 | RSpec::Core::RakeTask.new(:unit) do |t| 10 | t.pattern = "spec/unit" 11 | end 12 | 13 | desc "Run specs with in-memory database" 14 | RSpec::Core::RakeTask.new(:memory) do |t| 15 | ENV["SQS_DATABASE"] = ":memory:" 16 | t.pattern = "spec/acceptance" 17 | end 18 | 19 | desc "Run specs with file database" 20 | RSpec::Core::RakeTask.new(:file) do |t| 21 | file = Tempfile.new(["rspec-sqs", ".yml"], encoding: "utf-8") 22 | ENV["SQS_DATABASE"] = file.path 23 | t.pattern = "spec/acceptance" 24 | end 25 | 26 | end 27 | 28 | desc "Run spec suite with both in-memory and file" 29 | task :spec => ["spec:unit", "spec:memory", "spec:file"] 30 | 31 | task :default => :spec 32 | -------------------------------------------------------------------------------- /bin/fake_sqs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | lib = File.expand_path("../../lib", __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | 6 | require 'fake_sqs' 7 | require 'optparse' 8 | 9 | options = { 10 | :port => 4568, 11 | :host => "0.0.0.0", 12 | :verbose => false, 13 | :daemonize => false, 14 | :database => ":memory:" 15 | } 16 | 17 | parser = OptionParser.new do |o| 18 | 19 | o.on "--database DATABASE", "Where to store the database (default: #{options[:database]})" do |database| 20 | options[:database] = database 21 | end 22 | 23 | o.on "-p", "--port PORT", Integer, "Port to use (default: #{options[:port]})" do |port| 24 | options[:port] = port 25 | end 26 | 27 | o.on "-o", "--bind HOST", "Host to bind to (default: #{options[:host]})" do |host| 28 | options[:host] = host 29 | end 30 | 31 | o.on "-s", "--server SERVER", ['thin', 'mongrel', 'webrick'], "Server to use: thin, mongrel or webrick (by default Sinatra chooses the best available)" do |server| 32 | options[:server] = server 33 | end 34 | 35 | o.on "-P", "--pid PIDFILE", "Where to write the pid" do |pid| 36 | options[:pid] = pid 37 | end 38 | 39 | o.on "-d", "--[no-]daemonize", "Detaches the process" do |daemonize| 40 | options[:daemonize] = daemonize 41 | end 42 | 43 | o.on "-v", "--[no-]verbose", "Shows input parameters and output XML" do |verbose| 44 | options[:verbose] = verbose 45 | end 46 | 47 | o.on "--log FILE", "Redirect output to this logfile (default: console)" do |logfile| 48 | options[:log] = logfile 49 | end 50 | 51 | o.on_tail "--version", "Shows the version" do 52 | puts "fake_sqs version #{FakeSQS::VERSION}" 53 | exit 54 | end 55 | 56 | o.on_tail "-h", "--help", "Shows this help page" do 57 | puts o 58 | exit 59 | end 60 | 61 | end 62 | 63 | parser.parse! 64 | 65 | FakeSQS.to_rack(options).run! 66 | -------------------------------------------------------------------------------- /fake_sqs.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'fake_sqs/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = "fake_sqs" 8 | gem.version = FakeSQS::VERSION 9 | gem.authors = ["iain"] 10 | gem.email = ["iain@iain.nl"] 11 | gem.summary = %q{Provides a fake SQS server that you can run locally to test against} 12 | gem.homepage = "https://github.com/iain/fake_sqs" 13 | 14 | gem.files = `git ls-files`.split($/) 15 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 16 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 17 | gem.require_paths = ["lib"] 18 | gem.license = "MIT" 19 | 20 | 21 | gem.add_dependency "rack", "~> 2.0" 22 | gem.add_dependency "sinatra", "~> 2.0" 23 | gem.add_dependency "builder", "~> 3.2" 24 | 25 | gem.add_development_dependency "rspec", "~> 3.6" 26 | gem.add_development_dependency "rake", "~> 12.0" 27 | gem.add_development_dependency "rack-test", "~> 0.7" 28 | gem.add_development_dependency "aws-sdk", "~> 2.10" 29 | gem.add_development_dependency "thin", "~> 1.7" 30 | gem.add_development_dependency "verbose_hash_fetch", "~> 0.0" 31 | gem.add_development_dependency "activesupport", "~> 5.1" 32 | end 33 | -------------------------------------------------------------------------------- /lib/fake_sqs.rb: -------------------------------------------------------------------------------- 1 | require 'fake_sqs/api' 2 | require 'fake_sqs/catch_errors' 3 | require 'fake_sqs/collection_view' 4 | require 'fake_sqs/error_response' 5 | require 'fake_sqs/message' 6 | require 'fake_sqs/queue' 7 | require 'fake_sqs/queue_factory' 8 | require 'fake_sqs/queues' 9 | require 'fake_sqs/responder' 10 | require 'fake_sqs/server' 11 | require 'fake_sqs/version' 12 | require 'fake_sqs/databases/file' 13 | require 'fake_sqs/databases/memory' 14 | 15 | module FakeSQS 16 | 17 | def self.to_rack(options) 18 | 19 | require 'fake_sqs/web_interface' 20 | app = FakeSQS::WebInterface 21 | 22 | if (log = options[:log]) 23 | file = File.new(log, "a+") 24 | file.sync = true 25 | app.use Rack::CommonLogger, file 26 | app.set :log_file, file 27 | app.enable :logging 28 | end 29 | 30 | if options[:verbose] 31 | require 'fake_sqs/show_output' 32 | app.use FakeSQS::ShowOutput 33 | app.enable :logging 34 | end 35 | 36 | if options[:daemonize] 37 | require 'fake_sqs/daemonize' 38 | Daemonize.new(options).call 39 | end 40 | 41 | app.set :port, options[:port] if options[:port] 42 | app.set :bind, options[:host] if options[:host] 43 | app.set :server, options[:server] if options[:server] 44 | server = FakeSQS.server(port: options[:port], host: options[:host]) 45 | app.set :api, FakeSQS.api(server: server, database: options[:database]) 46 | app 47 | end 48 | 49 | def self.server(options = {}) 50 | Server.new(options) 51 | end 52 | 53 | def self.api(options = {}) 54 | db = database_for(options.fetch(:database) { ":memory:" }) 55 | API.new( 56 | server: options.fetch(:server), 57 | queues: queues(db), 58 | responder: responder 59 | ) 60 | end 61 | 62 | def self.queues(database) 63 | Queues.new(queue_factory: queue_factory, database: database) 64 | end 65 | 66 | def self.responder 67 | Responder.new 68 | end 69 | 70 | def self.queue_factory 71 | QueueFactory.new(message_factory: message_factory, queue: queue) 72 | end 73 | 74 | def self.message_factory 75 | Message 76 | end 77 | 78 | def self.queue 79 | Queue 80 | end 81 | 82 | def self.database_for(name) 83 | if name == ":memory:" 84 | MemoryDatabase.new 85 | else 86 | FileDatabase.new(name) 87 | end 88 | end 89 | 90 | end 91 | -------------------------------------------------------------------------------- /lib/fake_sqs/actions/change_message_visibility.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | module Actions 3 | class ChangeMessageVisibility 4 | 5 | def initialize(options = {}) 6 | @queues = options.fetch(:queues) 7 | @responder = options.fetch(:responder) 8 | end 9 | 10 | def call(queue_name, params) 11 | queue = @queues.get(queue_name) 12 | visibility = params.fetch("VisibilityTimeout") 13 | receipt = params.fetch("ReceiptHandle") 14 | 15 | queue.change_message_visibility(receipt, visibility.to_i) 16 | 17 | @responder.call :ChangeMessageVisibility 18 | end 19 | 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/fake_sqs/actions/change_message_visibility_batch.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | module Actions 3 | class ChangeMessageVisibilityBatch 4 | 5 | def initialize(options = {}) 6 | @queues = options.fetch(:queues) 7 | @responder = options.fetch(:responder) 8 | end 9 | 10 | def call(queue_name, params) 11 | queue = @queues.get(queue_name) 12 | 13 | keys = params.keys.map do |key| 14 | case key 15 | when /^ChangeMessageVisibilityBatchRequestEntry\.(\w+)\.Id$/ 16 | $1 17 | end 18 | end.compact 19 | 20 | ids = keys.map do |key| 21 | receipt = params.fetch("ChangeMessageVisibilityBatchRequestEntry.#{key}.ReceiptHandle") 22 | timeout = params.fetch("ChangeMessageVisibilityBatchRequestEntry.#{key}.VisibilityTimeout").to_i 23 | queue.change_message_visibility(receipt, timeout) 24 | params.fetch("ChangeMessageVisibilityBatchRequestEntry.#{key}.Id") 25 | end 26 | 27 | @responder.call :ChangeMessageVisibilityBatch do |xml| 28 | ids.each do |id| 29 | xml.ChangeMessageVisibilityBatchResultEntry do 30 | xml.Id id 31 | end 32 | end 33 | end 34 | end 35 | 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/fake_sqs/actions/create_queue.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | module Actions 3 | class CreateQueue 4 | 5 | def initialize(options = {}) 6 | @server = options.fetch(:server) 7 | @queues = options.fetch(:queues) 8 | @responder = options.fetch(:responder) 9 | @request = options.fetch(:request) 10 | end 11 | 12 | def call(params) 13 | name = params.fetch("QueueName") 14 | queue = @queues.create(name, params) 15 | @responder.call :CreateQueue do |xml| 16 | xml.QueueUrl @server.url_for(queue.name, {:host => @request.host, :port => @request.port}) 17 | end 18 | end 19 | 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/fake_sqs/actions/delete_message.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | module Actions 3 | class DeleteMessage 4 | 5 | def initialize(options = {}) 6 | @server = options.fetch(:server) 7 | @queues = options.fetch(:queues) 8 | @responder = options.fetch(:responder) 9 | end 10 | 11 | def call(queue_name, params) 12 | queue = @queues.get(queue_name) 13 | 14 | receipt = params.fetch("ReceiptHandle") 15 | queue.delete_message(receipt) 16 | @responder.call :DeleteMessage 17 | end 18 | 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/fake_sqs/actions/delete_message_batch.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | module Actions 3 | class DeleteMessageBatch 4 | 5 | def initialize(options = {}) 6 | @server = options.fetch(:server) 7 | @queues = options.fetch(:queues) 8 | @responder = options.fetch(:responder) 9 | end 10 | 11 | def call(queue_name, params) 12 | queue = @queues.get(queue_name) 13 | receipts = params.select { |k,v| k =~ /DeleteMessageBatchRequestEntry\.\d+\.ReceiptHandle/ } 14 | 15 | deleted = receipts.map { |key, value| 16 | id = key.split('.')[1] 17 | queue.delete_message(value) # Broken, can only delete in-flight messages 18 | params.fetch("DeleteMessageBatchRequestEntry.#{id}.Id") 19 | } 20 | 21 | @responder.call :DeleteMessageBatch do |xml| 22 | deleted.each do |id| 23 | xml.DeleteMessageBatchResultEntry do 24 | xml.Id id 25 | end 26 | end 27 | end 28 | end 29 | 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/fake_sqs/actions/delete_queue.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | module Actions 3 | class DeleteQueue 4 | 5 | def initialize(options = {}) 6 | @server = options.fetch(:server) 7 | @queues = options.fetch(:queues) 8 | @responder = options.fetch(:responder) 9 | end 10 | 11 | def call(queue_name, params = {}) 12 | @queues.delete(queue_name, params) 13 | @responder.call :DeleteQueue 14 | end 15 | 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/fake_sqs/actions/get_queue_attributes.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | module Actions 3 | class GetQueueAttributes 4 | 5 | def initialize(options = {}) 6 | @server = options.fetch(:server) 7 | @queues = options.fetch(:queues) 8 | @responder = options.fetch(:responder) 9 | end 10 | 11 | def call(queue_name, params) 12 | queue = @queues.get(queue_name) 13 | @responder.call :GetQueueAttributes do |xml| 14 | queue.attributes.each do |name, value| 15 | xml.Attribute do 16 | xml.Name name 17 | xml.Value value 18 | end 19 | end 20 | end 21 | end 22 | 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/fake_sqs/actions/get_queue_url.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | module Actions 3 | class GetQueueUrl 4 | 5 | def initialize(options = {}) 6 | @server = options.fetch(:server) 7 | @queues = options.fetch(:queues) 8 | @responder = options.fetch(:responder) 9 | @request = options.fetch(:request) 10 | end 11 | 12 | def call(params) 13 | name = params.fetch("QueueName") 14 | queue = @queues.get(name, params) 15 | @responder.call :GetQueueUrl do |xml| 16 | xml.QueueUrl @server.url_for(queue.name, {:host => @request.host, :port => @request.port}) 17 | end 18 | end 19 | 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/fake_sqs/actions/list_dead_letter_source_queues.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | module Actions 3 | class ListDeadLetterSourceQueues 4 | 5 | def initialize(options = {}) 6 | @server = options.fetch(:server) 7 | @queues = options.fetch(:queues) 8 | @responder = options.fetch(:responder) 9 | end 10 | 11 | def call(queue_name, params) 12 | queue_arn = @queues.get(queue_name).arn 13 | queue_urls = @queues.list.select do |queue| 14 | redrive_policy = queue.attributes.fetch("RedrivePolicy", nil) 15 | redrive_policy && redrive_policy =~ /deadLetterTargetArn\":\"#{queue_arn}/ 16 | end 17 | @responder.call :ListDeadLetterSourceQueues do |xml| 18 | queue_urls.each do |queue| 19 | xml.QueueUrl @server.url_for(queue.name) 20 | end 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/fake_sqs/actions/list_queues.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | module Actions 3 | class ListQueues 4 | 5 | def initialize(options = {}) 6 | @server = options.fetch(:server) 7 | @queues = options.fetch(:queues) 8 | @responder = options.fetch(:responder) 9 | @request = options.fetch(:request) 10 | end 11 | 12 | def call(params) 13 | found = @queues.list(params) 14 | @responder.call :ListQueues do |xml| 15 | found.each do |queue| 16 | xml.QueueUrl @server.url_for(queue.name, {:host => @request.host, :port => @request.port}) 17 | end 18 | end 19 | end 20 | 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/fake_sqs/actions/purge_queue.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | module Actions 3 | class PurgeQueue 4 | 5 | def initialize(options = {}) 6 | @server = options.fetch(:server) 7 | @queues = options.fetch(:queues) 8 | @responder = options.fetch(:responder) 9 | end 10 | 11 | def call(queue_name, params) 12 | queue = @queues.get(queue_name) 13 | queue.reset() 14 | @responder.call :PurgeQueue 15 | end 16 | 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/fake_sqs/actions/receive_message.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | module Actions 3 | class ReceiveMessage 4 | 5 | MAX_WAIT_TIME_SECONDS = 20 6 | 7 | def initialize(options = {}) 8 | @server = options.fetch(:server) 9 | @queues = options.fetch(:queues) 10 | @responder = options.fetch(:responder) 11 | @start_ts = Time.now.to_f 12 | @satisfied = false 13 | end 14 | 15 | def call(queue_name, params) 16 | queue = @queues.get(queue_name) 17 | filtered_attribute_names = [] 18 | params.select{|k,v | k =~ /AttributeName\.\d+/}.each do |key, value| 19 | filtered_attribute_names << value 20 | end 21 | messages = queue.receive_message(params.merge(queues: @queues)) 22 | @satisfied = !messages.empty? || expired?(queue, params) 23 | @responder.call :ReceiveMessage do |xml| 24 | messages.each do |receipt, message| 25 | xml.Message do 26 | xml.MessageId message.id 27 | xml.ReceiptHandle receipt 28 | xml.MD5OfBody message.md5 29 | xml.Body message.body 30 | message.attributes.each do |name, value| 31 | if filtered_attribute_names.include?("All") || filtered_attribute_names.include?(name) 32 | xml.Attribute do 33 | xml.Name name 34 | xml.Value value 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | end 42 | 43 | def satisfied? 44 | @satisfied 45 | end 46 | 47 | protected 48 | def elapsed 49 | Time.now.to_f - @start_ts 50 | end 51 | 52 | def expired?(queue, params) 53 | wait_time_seconds = Integer params.fetch("WaitTimeSeconds") { 54 | queue.queue_attributes.fetch("ReceiveMessageWaitTimeSeconds") { 0 } 55 | } 56 | wait_time_seconds <= 0 || 57 | elapsed >= wait_time_seconds || 58 | elapsed >= MAX_WAIT_TIME_SECONDS 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/fake_sqs/actions/send_message.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | module Actions 3 | class SendMessage 4 | 5 | def initialize(options = {}) 6 | @server = options.fetch(:server) 7 | @queues = options.fetch(:queues) 8 | @responder = options.fetch(:responder) 9 | end 10 | 11 | def call(queue_name, params) 12 | queue = @queues.get(queue_name) 13 | message = queue.send_message(params) 14 | @responder.call :SendMessage do |xml| 15 | xml.MD5OfMessageBody message.md5 16 | xml.MessageId message.id 17 | end 18 | end 19 | 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/fake_sqs/actions/send_message_batch.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | module Actions 3 | class SendMessageBatch 4 | 5 | def initialize(options = {}) 6 | @server = options.fetch(:server) 7 | @queues = options.fetch(:queues) 8 | @responder = options.fetch(:responder) 9 | end 10 | 11 | def call(queue_name, params) 12 | queue = @queues.get(queue_name) 13 | messages = params.select { |k,v| k =~ /SendMessageBatchRequestEntry\.\d+\.MessageBody/ } 14 | 15 | results = {} 16 | 17 | messages.each do |key, value| 18 | id = key.split('.')[1] 19 | msg_id = params.fetch("SendMessageBatchRequestEntry.#{id}.Id") 20 | delay = params["SendMessageBatchRequestEntry.#{id}.DelaySeconds"] 21 | message = queue.send_message("MessageBody" => value, "DelaySeconds" => delay) 22 | results[msg_id] = message 23 | end 24 | 25 | @responder.call :SendMessageBatch do |xml| 26 | results.each do |msg_id, message| 27 | xml.SendMessageBatchResultEntry do 28 | xml.Id msg_id 29 | xml.MessageId message.id 30 | xml.MD5OfMessageBody message.md5 31 | end 32 | end 33 | end 34 | end 35 | 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/fake_sqs/actions/set_queue_attributes.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | module Actions 3 | class SetQueueAttributes 4 | 5 | def initialize(options = {}) 6 | @server = options.fetch(:server) 7 | @queues = options.fetch(:queues) 8 | @responder = options.fetch(:responder) 9 | end 10 | 11 | def call(queue_name, params) 12 | queue = @queues.get(queue_name) 13 | results = {} 14 | params.each do |key, value| 15 | if key =~ /\AAttribute\.(\d+)\.Name\z/ 16 | results[value] = params.fetch("Attribute.#{$1}.Value") 17 | end 18 | end 19 | queue.add_queue_attributes(results) 20 | @queues.save(queue) 21 | @responder.call :SetQueueAttributes 22 | end 23 | 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/fake_sqs/api.rb: -------------------------------------------------------------------------------- 1 | require 'fake_sqs/actions/change_message_visibility' 2 | require 'fake_sqs/actions/change_message_visibility_batch' 3 | require 'fake_sqs/actions/create_queue' 4 | require 'fake_sqs/actions/delete_queue' 5 | require 'fake_sqs/actions/list_queues' 6 | require 'fake_sqs/actions/get_queue_url' 7 | require 'fake_sqs/actions/send_message' 8 | require 'fake_sqs/actions/receive_message' 9 | require 'fake_sqs/actions/delete_message' 10 | require 'fake_sqs/actions/delete_message_batch' 11 | require 'fake_sqs/actions/purge_queue' 12 | require 'fake_sqs/actions/send_message_batch' 13 | require 'fake_sqs/actions/get_queue_attributes' 14 | require 'fake_sqs/actions/set_queue_attributes' 15 | require 'fake_sqs/actions/list_dead_letter_source_queues' 16 | 17 | module FakeSQS 18 | 19 | InvalidAction = Class.new(ArgumentError) 20 | 21 | class API 22 | 23 | attr_reader :queues, :options 24 | 25 | def initialize(options = {}) 26 | @queues = options.fetch(:queues) 27 | @options = options 28 | @halt = false 29 | @timer = Thread.new do 30 | until @halt 31 | queues.timeout_messages! 32 | sleep(0.1) 33 | end 34 | end 35 | end 36 | 37 | def call(action, request, *args) 38 | if FakeSQS::Actions.const_defined?(action) 39 | action = FakeSQS::Actions.const_get(action).new(options.merge({:request => request})) 40 | if action.respond_to?(:satisfied?) 41 | result = nil 42 | until @halt 43 | result = attempt_once(action, *args) 44 | break if action.satisfied? 45 | sleep(0.1) 46 | end 47 | result 48 | else 49 | attempt_once(action, *args) 50 | end 51 | else 52 | fail InvalidAction, "Unknown (or not yet implemented) action: #{action}" 53 | end 54 | end 55 | 56 | def attempt_once(action, *args) 57 | queues.transaction do 58 | action.call(*args) 59 | end 60 | end 61 | 62 | # Fake actions 63 | 64 | def reset 65 | queues.reset 66 | end 67 | 68 | def expire 69 | queues.expire 70 | end 71 | 72 | def stop 73 | @halt = true 74 | end 75 | 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/fake_sqs/catch_errors.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | 3 | module FakeSQS 4 | class CatchErrors 5 | 6 | def initialize(app, options = {}) 7 | @app = app 8 | @response = options.fetch(:response) 9 | end 10 | 11 | def call(env) 12 | @app.call(env) 13 | rescue => error 14 | response = @response.new(error) 15 | [ response.status, {}, [ response.body ] ] 16 | end 17 | 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/fake_sqs/collection_view.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | module FakeSQS 4 | class CollectionView 5 | include Enumerable 6 | extend Forwardable 7 | def_delegators :@original, :[], :each, :empty?, :size, :length 8 | 9 | UnmodifiableObjectError = Class.new(StandardError) 10 | 11 | def initialize( original ) 12 | @original = original 13 | end 14 | 15 | def []=(key_or_index,value) 16 | raise UnmodifiableObjectError.new("This is a collection view and can not be modified - #{key_or_index} => #{value}") 17 | end 18 | 19 | end 20 | end -------------------------------------------------------------------------------- /lib/fake_sqs/daemonize.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | class Daemonize 3 | 4 | attr_reader :pid 5 | 6 | def initialize(options) 7 | @pid = options.fetch(:pid) { 8 | warn "No PID file specified while daemonizing!" 9 | exit 1 10 | } 11 | end 12 | 13 | def call 14 | Process.daemon(true, true) 15 | 16 | if File.exist?(pid) 17 | existing_pid = File.open(pid, 'r').read.chomp.to_i 18 | running = Process.getpgid(existing_pid) rescue false 19 | if running 20 | warn "Error, Process #{existing_pid} already running" 21 | exit 1 22 | else 23 | warn "Cleaning up stale pid at #{pid}" 24 | end 25 | end 26 | File.open(pid, 'w') { |f| f.write(Process.pid) } 27 | end 28 | 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/fake_sqs/databases/file.rb: -------------------------------------------------------------------------------- 1 | require "yaml/store" 2 | 3 | module FakeSQS 4 | class FileDatabase 5 | 6 | attr_reader :filename 7 | 8 | def initialize(filename) 9 | @filename = filename 10 | @queue_objects = {} 11 | unless thread_safe_store? 12 | # before ruby 2.4, YAML::Store cannot be declared thread safe 13 | # 14 | # without that declaration, attempting to have some thread B enter a 15 | # store.transaction on the store while another thread A is in one 16 | # already will raise an error unnecessarily. 17 | # 18 | # to prevent this, we'll use our own mutex around store.transaction, 19 | # so only one thread can even _try_ to enter the transaction at a 20 | # time. 21 | @store_mutex = Mutex.new 22 | @store_mutex_owner = nil 23 | end 24 | end 25 | 26 | def load 27 | transaction do 28 | store["queues"] ||= {} 29 | end 30 | end 31 | 32 | def transaction 33 | if thread_safe_store? || store_mutex_owned? 34 | # if we already own the store mutex, we can expect the next line to 35 | # raise (appropriately) when we try to nest transactions in the store. 36 | # but if we took the other branch, the # @store_mutex.synchronize call 37 | # would self-deadlock before we could raise the error. 38 | store.transaction do 39 | yield 40 | end 41 | else 42 | # we still need to use an inner store.transaction block because it does 43 | # more than just lock synchronization. it's unfortunately inefficient, 44 | # but this isn't a production-oriented library. 45 | @store_mutex.synchronize do 46 | begin 47 | # allows us to answer `store_mutex_owned?` above 48 | @store_mutex_owner = Thread.current 49 | store.transaction do 50 | yield 51 | end 52 | ensure 53 | @store_mutex_owner = nil 54 | end 55 | end 56 | end 57 | end 58 | 59 | def reset 60 | transaction do 61 | store["queues"] = {} 62 | end 63 | @queue_objects = {} 64 | end 65 | 66 | def []=(key, value) 67 | storage[key] = value.to_yaml 68 | end 69 | 70 | def [](key) 71 | value = storage[key] 72 | if value 73 | deserialize(key) 74 | else 75 | value 76 | end 77 | end 78 | 79 | def each(&block) 80 | storage.each do |key, value| 81 | yield key, deserialize(key) 82 | end 83 | end 84 | 85 | def select(&block) 86 | new_list = storage.select do |key, value| 87 | yield key, deserialize(key) 88 | end 89 | Hash[new_list.map { |key, value| [key, deserialize(key)] }] 90 | end 91 | 92 | def delete(key) 93 | @queue_objects.delete(key) 94 | storage.delete(key) 95 | end 96 | 97 | def values 98 | storage.map { |key, value| 99 | deserialize(key) 100 | } 101 | end 102 | 103 | private 104 | 105 | def deserialize(key) 106 | @queue_objects[key] ||= Queue.new(storage[key].merge(message_factory: Message)) 107 | end 108 | 109 | def storage 110 | store["queues"] 111 | end 112 | 113 | def thread_safe_store? 114 | Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.4") 115 | end 116 | 117 | def store_mutex_owned? 118 | # this could be just "@store_mutex && @store_mutex.owned?" in ruby 2.x, 119 | # but we still support 1.9.3 which doesn't have the "owned?" method 120 | @store_mutex_owner == Thread.current 121 | end 122 | 123 | def store 124 | @store ||= thread_safe_store? ? 125 | YAML::Store.new(filename, true) : 126 | YAML::Store.new(filename) 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/fake_sqs/databases/memory.rb: -------------------------------------------------------------------------------- 1 | require "forwardable" 2 | require "thread" 3 | 4 | module FakeSQS 5 | class MemoryDatabase 6 | extend Forwardable 7 | 8 | def_delegators :@queues, 9 | :[], :[]=, :delete, :each, :select, :values 10 | 11 | def initialize 12 | @semaphore = Mutex.new 13 | end 14 | 15 | def load 16 | @queues = {} 17 | end 18 | 19 | def transaction 20 | @semaphore.synchronize do 21 | yield 22 | end 23 | end 24 | 25 | def reset 26 | @queues = {} 27 | end 28 | 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/fake_sqs/error_response.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'builder' 3 | require 'securerandom' 4 | 5 | module FakeSQS 6 | class ErrorResponse 7 | 8 | attr_reader :error 9 | 10 | def initialize(error) 11 | @error = error 12 | end 13 | 14 | def status 15 | @status ||= statuses.fetch(code) 16 | end 17 | 18 | def body 19 | xml = Builder::XmlMarkup.new() 20 | xml.ErrorResponse do 21 | xml.Error do 22 | xml.Type type 23 | xml.Code code 24 | xml.Message error.to_s 25 | xml.Detail 26 | end 27 | xml.RequestId SecureRandom.uuid 28 | end 29 | end 30 | 31 | private 32 | 33 | def code 34 | code = error.class.name.sub(/^FakeSQS::/, '') 35 | if statuses.has_key?(code) 36 | code 37 | else 38 | "InternalError" 39 | end 40 | end 41 | 42 | def type 43 | if status < 500 44 | "Sender" 45 | else 46 | "Receiver" 47 | end 48 | end 49 | 50 | def statuses 51 | @statuses ||= YAML.load_file(File.expand_path('../error_responses.yml', __FILE__)) 52 | end 53 | 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/fake_sqs/error_responses.yml: -------------------------------------------------------------------------------- 1 | AccessDenied: 403 2 | AuthFailure: 401 3 | ConflictingQueryParameter: 400 4 | InternalError: 500 5 | InvalidAccessKeyId: 401 6 | InvalidAction: 400 7 | InvalidAddress: 404 8 | InvalidAttributeName: 400 9 | InvalidHttpRequest: 400 10 | InvalidMessageContents: 400 11 | InvalidParameterCombination: 400 12 | InvalidParameterValue: 400 13 | InvalidQueryParameter: 400 14 | InvalidRequest: 400 15 | InvalidSecurity: 403 16 | InvalidSecurityToken: 400 17 | MalformedVersion: 400 18 | MessageTooLong: 400 19 | MessageNotInflight: 400 20 | MissingClientTokenId: 403 21 | MissingCredentials: 401 22 | MissingParameter: 400 23 | NoSuchVersion: 400 24 | NonExistentQueue: 400 25 | NotAuthorizedToUseVersion: 401 26 | QueueDeletedRecently: 400 27 | ReadCountOutOfRange: 400 28 | ReceiptHandleIsInvalid: 400 29 | RequestExpired: 400 30 | RequestThrottled: 403 31 | ServiceUnavailable: 503 32 | X509ParseError: 400 33 | -------------------------------------------------------------------------------- /lib/fake_sqs/message.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | require 'digest/sha1' 3 | 4 | module FakeSQS 5 | class Message 6 | 7 | attr_reader :body, :id, :md5, :delay_seconds, :approximate_receive_count, 8 | :sender_id, :approximate_first_receive_timestamp, :sent_timestamp 9 | attr_accessor :visibility_timeout 10 | 11 | def initialize(options = {}) 12 | @body = options.fetch("MessageBody") 13 | @id = options.fetch("Id") { SecureRandom.uuid } 14 | @md5 = options.fetch("MD5") { Digest::MD5.hexdigest(@body) } 15 | @sender_id = options.fetch("SenderId") { SecureRandom.uuid.delete('-').upcase[0...21] } 16 | @approximate_receive_count = 0 17 | @sent_timestamp = Time.now.to_i * 1000 18 | @delay_seconds = options.fetch("DelaySeconds", 0).to_i 19 | end 20 | 21 | def expire! 22 | self.visibility_timeout = nil 23 | end 24 | 25 | def receive! 26 | @approximate_first_receive_timestamp ||= Time.now.to_i * 1000 27 | @approximate_receive_count += 1 28 | end 29 | 30 | def expired?( limit = Time.now ) 31 | self.visibility_timeout.nil? || self.visibility_timeout < limit 32 | end 33 | 34 | def expire_at(seconds) 35 | self.visibility_timeout = Time.now + seconds 36 | end 37 | 38 | def published? 39 | if self.delay_seconds && self.delay_seconds > 0 40 | elapsed_seconds = Time.now.to_i - (self.sent_timestamp.to_i / 1000) 41 | elapsed_seconds >= self.delay_seconds 42 | else 43 | true 44 | end 45 | end 46 | 47 | def attributes 48 | { 49 | "SenderId" => sender_id, 50 | "ApproximateFirstReceiveTimestamp" => approximate_first_receive_timestamp, 51 | "ApproximateReceiveCount"=> approximate_receive_count, 52 | "SentTimestamp"=> sent_timestamp 53 | } 54 | end 55 | 56 | def receipt 57 | Digest::SHA1.hexdigest self.id 58 | end 59 | 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/fake_sqs/queue.rb: -------------------------------------------------------------------------------- 1 | require 'monitor' 2 | require 'securerandom' 3 | require 'fake_sqs/collection_view' 4 | require 'json' 5 | 6 | module FakeSQS 7 | 8 | MessageNotInflight = Class.new(RuntimeError) 9 | ReadCountOutOfRange = Class.new(RuntimeError) 10 | ReceiptHandleIsInvalid = Class.new(RuntimeError) 11 | 12 | class Queue 13 | 14 | VISIBILITY_TIMEOUT = 30 15 | 16 | attr_reader :name, :message_factory, :arn, :queue_attributes 17 | 18 | def initialize(options = {}) 19 | @message_factory = options.fetch(:message_factory) 20 | 21 | @name = options.fetch("QueueName") 22 | @arn = options.fetch("Arn") { "arn:aws:sqs:us-east-1:#{SecureRandom.hex}:#{@name}" } 23 | @queue_attributes = options.fetch("Attributes") { {} } 24 | @lock = Monitor.new 25 | reset 26 | end 27 | 28 | def to_yaml 29 | { 30 | "QueueName" => name, 31 | "Arn" => arn, 32 | "Attributes" => queue_attributes, 33 | } 34 | end 35 | 36 | def add_queue_attributes(attrs) 37 | queue_attributes.merge!(attrs) 38 | end 39 | 40 | def attributes 41 | queue_attributes.merge( 42 | "QueueArn" => arn, 43 | "ApproximateNumberOfMessages" => published_size, 44 | "ApproximateNumberOfMessagesNotVisible" => @messages_in_flight.size, 45 | ) 46 | end 47 | 48 | def send_message(options = {}) 49 | with_lock do 50 | message = options.fetch(:message){ message_factory.new(options) } 51 | if message 52 | @messages[message.receipt] = message 53 | end 54 | message 55 | end 56 | end 57 | 58 | def receive_message(options = {}) 59 | amount = Integer options.fetch("MaxNumberOfMessages") { "1" } 60 | visibility_timeout = Integer options.fetch("VisibilityTimeout") { default_visibility_timeout } 61 | 62 | fail ReadCountOutOfRange, amount if amount > 10 63 | 64 | return {} if @messages.empty? 65 | 66 | result = {} 67 | 68 | with_lock do 69 | actual_amount = amount > published_size ? published_size : amount 70 | published_messages = @messages.values.select { |m| m.published? } 71 | 72 | actual_amount.times do 73 | message = published_messages.delete_at(rand(published_size)) 74 | @messages.delete(message.receipt) 75 | unless check_message_for_dlq(message, options) 76 | message.expire_at(visibility_timeout) 77 | message.receive! 78 | @messages_in_flight[message.receipt] = message 79 | result[message.receipt] = message 80 | end 81 | end 82 | end 83 | 84 | result 85 | end 86 | 87 | def default_visibility_timeout 88 | if value = attributes['VisibilityTimeout'] 89 | value.to_i 90 | else 91 | VISIBILITY_TIMEOUT 92 | end 93 | end 94 | 95 | def timeout_messages! 96 | with_lock do 97 | expired = @messages_in_flight.inject({}) do |memo,(receipt,message)| 98 | if message.expired? 99 | memo[receipt] = message 100 | end 101 | memo 102 | end 103 | expired.each do |receipt,message| 104 | message.expire! 105 | @messages[receipt] = message 106 | @messages_in_flight.delete(receipt) 107 | end 108 | end 109 | end 110 | 111 | def change_message_visibility(receipt, visibility) 112 | with_lock do 113 | message = @messages_in_flight[receipt] 114 | raise MessageNotInflight unless message 115 | 116 | if visibility == 0 117 | message.expire! 118 | @messages[receipt] = message 119 | @messages_in_flight.delete(receipt) 120 | else 121 | message.expire_at(visibility) 122 | end 123 | end 124 | end 125 | 126 | def check_message_for_dlq(message, options={}) 127 | if redrive_policy = queue_attributes["RedrivePolicy"] && JSON.parse(queue_attributes["RedrivePolicy"]) 128 | dlq = options[:queues].list.find{|queue| queue.arn == redrive_policy["deadLetterTargetArn"]} 129 | if dlq && message.approximate_receive_count >= redrive_policy["maxReceiveCount"].to_i 130 | dlq.send_message(message: message) 131 | message.expire! 132 | true 133 | end 134 | end 135 | end 136 | 137 | def delete_message(receipt) 138 | with_lock do 139 | @messages.delete(receipt) 140 | @messages_in_flight.delete(receipt) 141 | end 142 | end 143 | 144 | def reset 145 | with_lock do 146 | @messages = {} 147 | @messages_view = FakeSQS::CollectionView.new(@messages) 148 | reset_messages_in_flight 149 | end 150 | end 151 | 152 | def expire 153 | with_lock do 154 | @messages.merge!(@messages_in_flight) 155 | @messages_in_flight.clear() 156 | reset_messages_in_flight 157 | end 158 | end 159 | 160 | def reset_messages_in_flight 161 | with_lock do 162 | @messages_in_flight = {} 163 | @messages_in_flight_view = FakeSQS::CollectionView.new(@messages_in_flight) 164 | end 165 | end 166 | 167 | def messages 168 | @messages_view 169 | end 170 | 171 | def messages_in_flight 172 | @messages_in_flight_view 173 | end 174 | 175 | def size 176 | @messages.size 177 | end 178 | 179 | def published_size 180 | @messages.values.select { |m| m.published? }.size 181 | end 182 | 183 | def with_lock 184 | @lock.synchronize do 185 | yield 186 | end 187 | end 188 | 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /lib/fake_sqs/queue_factory.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | class QueueFactory 3 | 4 | attr_reader :message_factory, :queue 5 | 6 | def initialize(options = {}) 7 | @message_factory = options.fetch(:message_factory) 8 | @queue = options.fetch(:queue) 9 | end 10 | 11 | def new(options) 12 | queue.new(options.merge(:message_factory => message_factory)) 13 | end 14 | 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/fake_sqs/queues.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | 3 | NonExistentQueue = Class.new(RuntimeError) 4 | 5 | class Queues 6 | 7 | attr_reader :queue_factory, :database 8 | 9 | def initialize(options = {}) 10 | @queue_factory = options.fetch(:queue_factory) 11 | @database = options.fetch(:database) 12 | @database.load 13 | end 14 | 15 | def create(name, options = {}) 16 | return database[name] if database[name] 17 | queue = queue_factory.new(options) 18 | database[name] = queue 19 | end 20 | 21 | def delete(name, options = {}) 22 | if database[name] 23 | database.delete(name) 24 | else 25 | fail NonExistentQueue, name 26 | end 27 | end 28 | 29 | def list(options = {}) 30 | if (prefix = options["QueueNamePrefix"]) 31 | database.select { |name, queue| name.start_with?(prefix) }.values 32 | else 33 | database.values 34 | end 35 | end 36 | 37 | def get(name, options = {}) 38 | if (db = database[name]) 39 | db 40 | else 41 | fail NonExistentQueue, name 42 | end 43 | end 44 | 45 | def transaction 46 | database.transaction do 47 | yield 48 | end 49 | end 50 | 51 | def save(queue) 52 | database[queue.name] = queue 53 | end 54 | 55 | def reset 56 | database.reset 57 | end 58 | 59 | def timeout_messages! 60 | transaction do 61 | database.each { |name,queue| queue.timeout_messages! } 62 | end 63 | end 64 | 65 | def expire 66 | transaction do 67 | database.each { |name, queue| queue.expire } 68 | end 69 | end 70 | 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/fake_sqs/responder.rb: -------------------------------------------------------------------------------- 1 | require 'builder' 2 | require 'securerandom' 3 | 4 | module FakeSQS 5 | class Responder 6 | 7 | def call(name, &block) 8 | xml = Builder::XmlMarkup.new() 9 | xml.tag! "#{name}Response" do 10 | if block 11 | xml.tag! "#{name}Result" do 12 | yield xml 13 | end 14 | end 15 | xml.ResponseMetadata do 16 | xml.RequestId SecureRandom.uuid 17 | end 18 | end 19 | end 20 | 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/fake_sqs/server.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | class Server 3 | 4 | attr_reader :host, :port 5 | 6 | def initialize(options) 7 | @host = options.fetch(:host) 8 | @port = options.fetch(:port) 9 | end 10 | 11 | def url_for(queue_id, options = {}) 12 | host = options[:host] || @host 13 | port = options[:port] || @port 14 | 15 | "http://#{host}:#{port}/#{queue_id}" 16 | end 17 | 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/fake_sqs/show_output.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | require 'yaml' 3 | 4 | module FakeSQS 5 | class ShowOutput 6 | 7 | def initialize(app) 8 | @app = app 9 | end 10 | 11 | def call(env) 12 | request = Rack::Request.new(env) 13 | result = @app.call(env) 14 | puts request.params.to_yaml 15 | puts 16 | puts(*result.last) 17 | result 18 | end 19 | 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/fake_sqs/test_integration.rb: -------------------------------------------------------------------------------- 1 | require "net/http" 2 | 3 | module FakeSQS 4 | class TestIntegration 5 | 6 | attr_reader :options 7 | 8 | def initialize(options = {}) 9 | @options = options 10 | end 11 | 12 | def host 13 | option :sqs_endpoint 14 | end 15 | 16 | def port 17 | option :sqs_port 18 | end 19 | 20 | def start 21 | start! unless up? 22 | reset 23 | end 24 | 25 | def start! 26 | args = [ binfile, "-p", port.to_s, verbose, logging, "--database", database, { :out => out, :err => out } ].flatten.compact 27 | @pid = Process.spawn(*args) 28 | wait_until_up(Time.now + start_timeout) 29 | end 30 | 31 | def stop 32 | if @pid 33 | Process.kill("INT", @pid) 34 | Process.waitpid(@pid) 35 | @pid = nil 36 | else 37 | $stderr.puts "FakeSQS is not running" 38 | end 39 | end 40 | 41 | def reset 42 | connection.delete("/") 43 | end 44 | 45 | def expire 46 | connection.put("/", "") 47 | end 48 | 49 | def url 50 | "http://#{host}:#{port}" 51 | end 52 | 53 | def uri 54 | URI(url) 55 | end 56 | 57 | def up? 58 | @pid && connection.get("/ping").code.to_s == "200" 59 | rescue Errno::ECONNREFUSED 60 | false 61 | end 62 | 63 | private 64 | 65 | def option(key) 66 | options.fetch(key) 67 | end 68 | 69 | def database 70 | options.fetch(:database) 71 | end 72 | 73 | def start_timeout 74 | options[:start_timeout] || 2 75 | end 76 | 77 | def verbose 78 | if options[:verbose] 79 | "--verbose" 80 | else 81 | "--no-verbose" 82 | end 83 | end 84 | 85 | def logging 86 | if (file = ENV["SQS_LOG"] || options[:log]) 87 | [ "--log", file ] 88 | else 89 | [] 90 | end 91 | end 92 | 93 | def wait_until_up(deadline) 94 | fail "FakeSQS didn't start in time" if Time.now > deadline 95 | unless up? 96 | sleep 0.1 97 | wait_until_up(deadline) 98 | end 99 | end 100 | 101 | def binfile 102 | File.expand_path("../../../bin/fake_sqs", __FILE__) 103 | end 104 | 105 | def out 106 | if debug? 107 | :out 108 | else 109 | "/dev/null" 110 | end 111 | end 112 | 113 | def connection 114 | @connection ||= Net::HTTP.new(host, port) 115 | end 116 | 117 | def debug? 118 | ENV["DEBUG"].to_s == "true" || options[:debug] 119 | end 120 | 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/fake_sqs/version.rb: -------------------------------------------------------------------------------- 1 | module FakeSQS 2 | VERSION = "0.4.3" 3 | end 4 | -------------------------------------------------------------------------------- /lib/fake_sqs/web_interface.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | require 'fake_sqs/catch_errors' 3 | require 'fake_sqs/error_response' 4 | 5 | module FakeSQS 6 | class WebInterface < Sinatra::Base 7 | 8 | def self.handle(path, verbs, &block) 9 | verbs.each do |verb| 10 | send(verb, path, &block) 11 | end 12 | end 13 | 14 | configure do 15 | use FakeSQS::CatchErrors, response: ErrorResponse 16 | end 17 | 18 | helpers do 19 | def action 20 | params.fetch("Action") 21 | end 22 | end 23 | 24 | get "/ping" do 25 | 200 26 | end 27 | 28 | delete "/" do 29 | settings.api.reset 30 | 200 31 | end 32 | 33 | put "/" do 34 | settings.api.expire 35 | 200 36 | end 37 | 38 | handle "/", [:get, :post] do 39 | if params['QueueUrl'] 40 | uri = URI.parse(params['QueueUrl']) 41 | queue_name = uri.path.tr('/', '') 42 | return settings.api.call(action, request, queue_name, params) unless queue_name.empty? 43 | end 44 | 45 | settings.api.call(action, request, params) 46 | end 47 | 48 | handle "/:queue_name", [:get, :post] do |queue_name| 49 | settings.api.call(action, request, queue_name, params) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/acceptance/message_actions_spec.rb: -------------------------------------------------------------------------------- 1 | require "integration_spec_helper" 2 | require "securerandom" 3 | 4 | RSpec.describe "Actions for Messages", :sqs do 5 | 6 | QUEUE_NAME = "test" 7 | 8 | before do 9 | sqs.config.endpoint = $fake_sqs.uri 10 | sqs.create_queue(queue_name: QUEUE_NAME) 11 | end 12 | 13 | let(:sqs) { Aws::SQS::Client.new } 14 | let(:queue_url) { sqs.get_queue_url(queue_name: QUEUE_NAME).queue_url } 15 | 16 | specify "SendMessage" do 17 | msg = "this is my message" 18 | 19 | result = sqs.send_message( 20 | queue_url: queue_url, 21 | message_body: msg, 22 | ) 23 | 24 | expect(result.md5_of_message_body).to eq Digest::MD5.hexdigest(msg) 25 | expect(result.message_id.size).to eq 36 26 | end 27 | 28 | specify "ReceiveMessage" do 29 | body = "test 123" 30 | 31 | sqs.send_message( 32 | queue_url: queue_url, 33 | message_body: body 34 | ) 35 | 36 | response = sqs.receive_message( 37 | queue_url: queue_url 38 | ) 39 | 40 | expect(response.messages.size).to eq 1 41 | expect(response.messages.first.body).to eq body 42 | end 43 | 44 | specify "ReceiveMessage with attribute_names parameters" do 45 | body = "test 123" 46 | 47 | sqs.send_message( 48 | queue_url: queue_url, 49 | message_body: body 50 | ) 51 | 52 | sent_time = Time.now.to_i * 1000 53 | 54 | response = sqs.receive_message( 55 | queue_url: queue_url, 56 | attribute_names: ["All"] 57 | ) 58 | 59 | received_time = Time.now.to_i * 1000 60 | 61 | expect(response.messages.first.attributes.reject{|k,v| k == "SenderId"}).to eq({ 62 | "SentTimestamp" => sent_time.to_s, 63 | "ApproximateReceiveCount" => "1", 64 | "ApproximateFirstReceiveTimestamp" => received_time.to_s 65 | }) 66 | expect(response.messages.first.attributes["SenderId"]).to be_kind_of(String) 67 | expire_message(response.messages.first) 68 | 69 | response = sqs.receive_message( 70 | queue_url: queue_url 71 | ) 72 | expect(response.messages.first.attributes).to eq({}) 73 | expire_message(response.messages.first) 74 | 75 | response = sqs.receive_message( 76 | queue_url: queue_url, 77 | attribute_names: ["SentTimestamp", "ApproximateReceiveCount", "ApproximateFirstReceiveTimestamp"] 78 | ) 79 | expect(response.messages.first.attributes).to eq({ 80 | "SentTimestamp" => sent_time.to_s, 81 | "ApproximateReceiveCount" => "3", 82 | "ApproximateFirstReceiveTimestamp" => received_time.to_s 83 | }) 84 | end 85 | 86 | describe "ReceiveMessage long polling" do 87 | LONG_POLLING_QUEUE_NAME = 'test-long-polling' 88 | 89 | before do 90 | sqs.create_queue( 91 | queue_name: LONG_POLLING_QUEUE_NAME, 92 | ) 93 | sqs.set_queue_attributes( 94 | queue_url: long_polling_queue_url, 95 | attributes: { 96 | "ReceiveMessageWaitTimeSeconds" => "1" 97 | } 98 | ) 99 | end 100 | 101 | let(:long_polling_queue_url) { sqs.get_queue_url(queue_name: LONG_POLLING_QUEUE_NAME).queue_url } 102 | 103 | specify "default behavior is no long polling" do 104 | start = Time.now 105 | response = sqs.receive_message( 106 | queue_url: queue_url 107 | ) 108 | 109 | expect(response.messages.size).to eq 0 110 | expect(Time.now - start).to be < 0.5 111 | end 112 | 113 | specify "can configure long polling on queue" do 114 | start = Time.now 115 | response = sqs.receive_message( 116 | queue_url: long_polling_queue_url 117 | ) 118 | 119 | expect(response.messages.size).to eq 0 120 | expect(Time.now - start).to be > 1 121 | end 122 | 123 | specify "specifying WaitTimeSeconds overrides queue configuration" do 124 | start = Time.now 125 | response = sqs.receive_message( 126 | queue_url: queue_url, 127 | wait_time_seconds: 1, 128 | ) 129 | 130 | expect(response.messages.size).to eq 0 131 | expect(Time.now - start).to be > 1 132 | 133 | start = Time.now 134 | response = sqs.receive_message( 135 | queue_url: long_polling_queue_url, 136 | wait_time_seconds: 2, 137 | ) 138 | 139 | expect(response.messages.size).to eq 0 140 | expect(Time.now - start).to be > 2 141 | 142 | start = Time.now 143 | response = sqs.receive_message( 144 | queue_url: long_polling_queue_url, 145 | wait_time_seconds: 0, 146 | ) 147 | 148 | expect(response.messages.size).to eq 0 149 | expect(Time.now - start).to be < 0.5 150 | end 151 | 152 | specify "a non-empty result immediately returns without waiting" do 153 | body = "test 123" 154 | 155 | sqs.send_message( 156 | queue_url: queue_url, 157 | message_body: body 158 | ) 159 | 160 | start = Time.now 161 | response = sqs.receive_message( 162 | queue_url: queue_url, 163 | wait_time_seconds: 1, 164 | ) 165 | 166 | expect(response.messages.size).to eq 1 167 | expect(Time.now - start).to be < 0.5 168 | end 169 | end 170 | 171 | specify "DeleteMessage" do 172 | sqs.send_message( 173 | queue_url: queue_url, 174 | message_body: "test", 175 | ) 176 | 177 | message1 = sqs.receive_message( 178 | queue_url: queue_url, 179 | ).messages.first 180 | 181 | let_messages_in_flight_expire 182 | 183 | sqs.delete_message( 184 | queue_url: queue_url, 185 | receipt_handle: message1.receipt_handle, 186 | ) 187 | 188 | response = sqs.receive_message( 189 | queue_url: queue_url, 190 | ) 191 | expect(response.messages.size).to eq 0 192 | end 193 | 194 | specify "DeleteMessageBatch" do 195 | sqs.send_message( 196 | queue_url: queue_url, 197 | message_body: "test1" 198 | ) 199 | sqs.send_message( 200 | queue_url: queue_url, 201 | message_body: "test2" 202 | ) 203 | 204 | messages_response = sqs.receive_message( 205 | queue_url: queue_url, 206 | max_number_of_messages: 2, 207 | ) 208 | expect(messages_response.messages.size).to eq 2 209 | 210 | let_messages_in_flight_expire 211 | 212 | response = sqs.delete_message_batch( 213 | queue_url: queue_url, 214 | entries: messages_response.messages.map { |msg| 215 | { 216 | id: SecureRandom.uuid, 217 | receipt_handle: msg.receipt_handle, 218 | } 219 | }, 220 | ) 221 | expect(response.successful.size).to eq(2) 222 | 223 | messages_response = sqs.receive_message( 224 | queue_url: queue_url, 225 | max_number_of_messages: 2, 226 | ) 227 | expect(messages_response.messages.size).to eq 0 228 | end 229 | 230 | specify "PurgeQueue" do 231 | sqs.send_message( 232 | queue_url: queue_url, 233 | message_body: "test1" 234 | ) 235 | sqs.send_message( 236 | queue_url: queue_url, 237 | message_body: "test2" 238 | ) 239 | 240 | sqs.purge_queue( 241 | queue_url: queue_url, 242 | ) 243 | 244 | response = sqs.receive_message( 245 | queue_url: queue_url, 246 | ) 247 | expect(response.messages.size).to eq 0 248 | end 249 | 250 | specify "DeleteQueue" do 251 | sent_message = sqs.send_message( 252 | queue_url: queue_url, 253 | message_body: "test1" 254 | ) 255 | 256 | response = sqs.receive_message( 257 | queue_url: queue_url, 258 | ) 259 | expect(response.messages[0].message_id).to eq sent_message.message_id 260 | expect(response.messages.size).to eq 1 261 | 262 | let_messages_in_flight_expire 263 | 264 | sqs.delete_queue(queue_url: queue_url) 265 | sqs.create_queue(queue_name: QUEUE_NAME) 266 | 267 | response = sqs.receive_message( 268 | queue_url: queue_url, 269 | ) 270 | expect(response.messages.size).to eq 0 271 | end 272 | 273 | specify "SendMessageBatch" do 274 | bodies = %w(a b c) 275 | 276 | response = sqs.send_message_batch( 277 | queue_url: queue_url, 278 | entries: bodies.map { |bd| 279 | { 280 | id: SecureRandom.uuid, 281 | message_body: bd, 282 | } 283 | } 284 | ) 285 | expect(response.successful.size).to eq(3) 286 | 287 | messages_response = sqs.receive_message( 288 | queue_url: queue_url, 289 | max_number_of_messages: 3, 290 | ) 291 | expect(messages_response.messages.map(&:body)).to match_array bodies 292 | end 293 | 294 | specify "set message timeout to 0" do 295 | body = 'some-sample-message' 296 | 297 | sqs.send_message( 298 | queue_url: queue_url, 299 | message_body: body, 300 | ) 301 | 302 | message = sqs.receive_message( 303 | queue_url: queue_url, 304 | visibility_timeout: 10, 305 | ).messages.first 306 | expect(message.body).to eq body 307 | 308 | sqs.change_message_visibility( 309 | queue_url: queue_url, 310 | receipt_handle: message.receipt_handle, 311 | visibility_timeout: 0, 312 | ) 313 | 314 | same_message = sqs.receive_message( 315 | queue_url: queue_url, 316 | ).messages.first 317 | expect(same_message.body).to eq body 318 | end 319 | 320 | specify 'set message timeout and wait for message to come' do 321 | body = 'some-sample-message' 322 | 323 | sqs.send_message( 324 | queue_url: queue_url, 325 | message_body: body, 326 | ) 327 | 328 | message = sqs.receive_message( 329 | queue_url: queue_url, 330 | visibility_timeout: 10, 331 | ).messages.first 332 | expect(message.body).to eq body 333 | 334 | sqs.change_message_visibility( 335 | queue_url: queue_url, 336 | receipt_handle: message.receipt_handle, 337 | visibility_timeout: 1, 338 | ) 339 | 340 | nothing = sqs.receive_message( 341 | queue_url: queue_url, 342 | ) 343 | expect(nothing.messages.size).to eq 0 344 | 345 | sleep(2) 346 | 347 | same_message = sqs.receive_message( 348 | queue_url: queue_url, 349 | ).messages.first 350 | expect(same_message.body).to eq body 351 | end 352 | 353 | specify 'should fail if trying to update the visibility_timeout for a message that is not in flight' do 354 | response = sqs.send_message( 355 | queue_url: queue_url, 356 | message_body: 'some-sample-message', 357 | ) 358 | 359 | expect { 360 | sqs.change_message_visibility( 361 | queue_url: queue_url, 362 | receipt_handle: response.message_id, 363 | visibility_timeout: 30 364 | ) 365 | }.to raise_error(Aws::SQS::Errors::MessageNotInflight) 366 | end 367 | 368 | specify 'ChangeMessageVisibilityBatch' do 369 | bodies = (1..10).map { |n| n.to_s } 370 | response = sqs.send_message_batch( 371 | queue_url: queue_url, 372 | entries: bodies.map { |bd| 373 | { 374 | id: SecureRandom.uuid, 375 | message_body: bd, 376 | } 377 | } 378 | ) 379 | expect(response.successful.size).to eq(10) 380 | 381 | message = sqs.receive_message( 382 | queue_url: queue_url, 383 | max_number_of_messages: 10, 384 | visibility_timeout: 1, 385 | ) 386 | expect(message.messages.size).to eq(10) 387 | 388 | response = sqs.change_message_visibility_batch( 389 | queue_url: queue_url, 390 | entries: message.messages.map { |m| 391 | { 392 | id: m.message_id, 393 | receipt_handle: m.receipt_handle, 394 | visibility_timeout: 10, 395 | } 396 | } 397 | ) 398 | expect(response.successful.size).to eq(10) 399 | 400 | sleep(2) 401 | 402 | message = sqs.receive_message( 403 | queue_url: queue_url, 404 | max_number_of_messages: 10, 405 | ) 406 | expect(message.messages.size).to eq(0) 407 | end 408 | 409 | specify 'should be moved to configured DLQ after maxReceiveCount if RedrivePolicy is set' do 410 | dlq_queue_url = sqs.create_queue(queue_name: "TestSourceQueueDLQ").queue_url 411 | 412 | dlq_arn = sqs.get_queue_attributes(queue_url: dlq_queue_url).attributes.fetch("QueueArn") 413 | sqs.set_queue_attributes( 414 | queue_url: queue_url, 415 | attributes: { 416 | "RedrivePolicy" => "{\"deadLetterTargetArn\":\"#{dlq_arn}\",\"maxReceiveCount\":2}" 417 | } 418 | ) 419 | 420 | message_id = sqs.send_message( 421 | queue_url: queue_url, 422 | message_body: "test", 423 | ).message_id 424 | 425 | 426 | 2.times do 427 | message = sqs.receive_message(queue_url: queue_url) 428 | expect(message.messages.size).to eq(1) 429 | expect(message.messages.first.message_id).to eq(message_id) 430 | expire_message(message.messages.first) 431 | end 432 | 433 | expect(sqs.receive_message(queue_url: queue_url).messages.size).to eq(0) 434 | 435 | message = sqs.receive_message(queue_url: dlq_queue_url) 436 | expect(message.messages.size).to eq(1) 437 | expect(message.messages.first.message_id).to eq(message_id) 438 | end 439 | 440 | def let_messages_in_flight_expire 441 | $fake_sqs.expire 442 | end 443 | 444 | def expire_message(message) 445 | sqs.change_message_visibility( 446 | queue_url: queue_url, 447 | receipt_handle: message.receipt_handle, 448 | visibility_timeout: 0, 449 | ) 450 | end 451 | 452 | end 453 | -------------------------------------------------------------------------------- /spec/acceptance/queue_actions_spec.rb: -------------------------------------------------------------------------------- 1 | require "integration_spec_helper" 2 | 3 | RSpec.describe "Actions for Queues", :sqs do 4 | 5 | let(:sqs) { Aws::SQS::Client.new } 6 | before do 7 | sqs.config.endpoint = $fake_sqs.uri 8 | end 9 | 10 | specify "CreateQueue" do 11 | response = sqs.create_queue(queue_name: "test-create-queue") 12 | expect(response.queue_url).to eq "http://0.0.0.0:4568/test-create-queue" 13 | response2 = sqs.get_queue_attributes(queue_url: response.queue_url) 14 | expect(response2.attributes.fetch("QueueArn")).to match %r"arn:aws:sqs:us-east-1:.+:test-create-queue" 15 | end 16 | 17 | specify "GetQueueUrl" do 18 | sqs.create_queue(queue_name: "test-get-queue-url") 19 | response = sqs.get_queue_url(queue_name: "test-get-queue-url") 20 | expect(response.queue_url).to eq "http://0.0.0.0:4568/test-get-queue-url" 21 | end 22 | 23 | specify "ListQueues" do 24 | sqs.create_queue(queue_name: "test-list-1") 25 | sqs.create_queue(queue_name: "test-list-2") 26 | expect(sqs.list_queues.queue_urls).to eq [ 27 | "http://0.0.0.0:4568/test-list-1", 28 | "http://0.0.0.0:4568/test-list-2" 29 | ] 30 | end 31 | 32 | specify "ListQueues with prefix" do 33 | sqs.create_queue(queue_name: "test-list-1") 34 | sqs.create_queue(queue_name: "test-list-2") 35 | sqs.create_queue(queue_name: "other-list-3") 36 | expect(sqs.list_queues(queue_name_prefix: "test").queue_urls).to eq [ 37 | "http://0.0.0.0:4568/test-list-1", 38 | "http://0.0.0.0:4568/test-list-2", 39 | ] 40 | end 41 | 42 | specify "ListDeadLetterSourceQueues" do 43 | dlq_queue_url = sqs.create_queue(queue_name: "source-list-DLQ").queue_url 44 | dlq_arn = sqs.get_queue_attributes(queue_url: dlq_queue_url).attributes.fetch("QueueArn") 45 | source_queue_urls = [] 46 | 2.times do |time| 47 | queue_url = sqs.create_queue(queue_name: "source-list-#{time}").queue_url 48 | sqs.set_queue_attributes(queue_url: queue_url, 49 | attributes: { 50 | "RedrivePolicy" => "{\"deadLetterTargetArn\":\"#{dlq_arn}\",\"maxReceiveCount\":10}" 51 | } 52 | ) 53 | source_queue_urls << queue_url 54 | end 55 | expect(sqs.list_dead_letter_source_queues(queue_url: dlq_queue_url).queue_urls).to eq source_queue_urls 56 | end 57 | 58 | specify "DeleteQueue" do 59 | url = sqs.create_queue(queue_name: "test-delete").queue_url 60 | expect(sqs.list_queues.queue_urls.size).to eq 1 61 | sqs.delete_queue(queue_url: url) 62 | expect(sqs.list_queues.queue_urls.size).to eq 0 63 | end 64 | 65 | specify "SetQueueAttributes / GetQueueAttributes" do 66 | queue_url = sqs.create_queue(queue_name: "my-queue").queue_url 67 | 68 | 69 | sqs.set_queue_attributes( 70 | queue_url: queue_url, 71 | attributes: { 72 | "DelaySeconds" => "900" 73 | } 74 | ) 75 | 76 | response = sqs.get_queue_attributes( 77 | queue_url: queue_url, 78 | ) 79 | expect(response.attributes.fetch("DelaySeconds")).to eq "900" 80 | end 81 | 82 | end 83 | -------------------------------------------------------------------------------- /spec/integration_spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "aws-sdk" 2 | require "fake_sqs/test_integration" 3 | 4 | Aws.config.update( 5 | region: "us-east-1", 6 | credentials: Aws::Credentials.new("fake", "fake"), 7 | ) 8 | 9 | db = ENV["SQS_DATABASE"] || ":memory:" 10 | puts "\n\e[34mRunning specs with database \e[33m#{db}\e[0m" 11 | 12 | $fake_sqs = FakeSQS::TestIntegration.new( 13 | database: db, 14 | sqs_endpoint: "0.0.0.0", 15 | sqs_port: 4568, 16 | start_timeout: 2, 17 | ) 18 | 19 | RSpec.configure do |config| 20 | config.before(:each, :sqs) { $fake_sqs.start } 21 | config.before(:each, :sqs) { $fake_sqs.reset } 22 | config.after(:suite) { $fake_sqs.stop } 23 | end 24 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | config.disable_monkey_patching! 3 | end 4 | -------------------------------------------------------------------------------- /spec/unit/api_spec.rb: -------------------------------------------------------------------------------- 1 | require 'fake_sqs/api' 2 | 3 | class FakeSQS::Actions::ImmediateAction 4 | 5 | def initialize(options) 6 | @options = options 7 | end 8 | 9 | def call(params) 10 | { :options => @options, :params => params } 11 | end 12 | 13 | end 14 | 15 | class FakeSQS::Actions::PollingAction 16 | 17 | def initialize(options) 18 | @options = options 19 | @call_count = 0 20 | end 21 | 22 | def call(params) 23 | @call_count += 1 24 | end 25 | 26 | def satisfied? 27 | @call_count >= 3 28 | end 29 | end 30 | 31 | RSpec.describe FakeSQS::API do 32 | 33 | it "delegates actions to classes" do 34 | queues = double :queues 35 | allow(queues).to receive(:transaction).and_yield 36 | api = FakeSQS::API.new(:queues => queues) 37 | 38 | response = api.call("ImmediateAction", {}, {:foo => "bar"}) 39 | 40 | expect(response[:options]).to eq :queues => queues, :request => {} 41 | expect(response[:params]).to eq :foo => "bar" 42 | end 43 | 44 | it "attempts a polling action until it's satisfied" do 45 | queues = double :queues 46 | allow(queues).to receive(:transaction).and_yield 47 | api = FakeSQS::API.new(:queues => queues) 48 | 49 | call_count = api.call("PollingAction", {}, {}) 50 | expect(call_count).to eq 3 51 | end 52 | 53 | it "raises InvalidAction for unknown actions" do 54 | api = FakeSQS::API.new(:queues => []) 55 | 56 | expect { 57 | api.call("SomethingDifferentAndUnknown", {:foo => "bar"}) 58 | }.to raise_error(FakeSQS::InvalidAction) 59 | 60 | end 61 | 62 | it "resets queues" do 63 | queues = double :queues 64 | api = FakeSQS::API.new(:queues => queues) 65 | expect(queues).to receive(:reset) 66 | api.reset 67 | end 68 | 69 | it "expires messages in queues" do 70 | queues = double :queues 71 | api = FakeSQS::API.new(:queues => queues) 72 | expect(queues).to receive(:expire) 73 | api.expire 74 | end 75 | 76 | end 77 | -------------------------------------------------------------------------------- /spec/unit/catch_errors_spec.rb: -------------------------------------------------------------------------------- 1 | require 'fake_sqs/catch_errors' 2 | 3 | RSpec.describe FakeSQS::CatchErrors do 4 | 5 | let(:app) { double :app } 6 | let(:error_response) { double :error_response, :status => 500, :body => "X" } 7 | let(:response) { double :response, :new => error_response } 8 | subject(:catch_errors) { FakeSQS::CatchErrors.new(app, response: response) } 9 | 10 | context "when the app behaves normally" do 11 | 12 | let(:normal_response) { double :normal_response } 13 | before { allow(app).to receive(:call).and_return(normal_response) } 14 | 15 | it "doesn't modify normal responses" do 16 | expect(catch_errors.call({})).to eq normal_response 17 | end 18 | 19 | end 20 | 21 | context "when the app raises an exception" do 22 | 23 | let(:error) { RuntimeError.new("it went wrong") } 24 | before { allow(app).to receive(:call).and_raise(error) } 25 | 26 | it "cathes and processes errors" do 27 | expect(response).to receive(:new).with(error) 28 | catch_errors.call({}) 29 | end 30 | 31 | it "sets the status determined by the error response" do 32 | allow(error_response).to receive(:status).and_return(123) 33 | expect(catch_errors.call({}).fetch(0)).to eq 123 34 | end 35 | 36 | it "set the body determined by the error response" do 37 | allow(error_response).to receive(:body).and_return("foobar") 38 | expect(catch_errors.call({}).fetch(2)).to eq ["foobar"] 39 | end 40 | 41 | end 42 | 43 | end 44 | -------------------------------------------------------------------------------- /spec/unit/collection_view_spec.rb: -------------------------------------------------------------------------------- 1 | require 'fake_sqs/collection_view' 2 | 3 | RSpec.describe FakeSQS::CollectionView do 4 | 5 | def wrap(collection) 6 | FakeSQS::CollectionView.new(collection) 7 | end 8 | 9 | it 'should correctly wrap an array' do 10 | array = %w{one two three four} 11 | view = wrap(array) 12 | expect(view[0]).to eq 'one' 13 | expect(view[1]).to eq 'two' 14 | expect(view[2]).to eq 'three' 15 | expect(view[3]).to eq 'four' 16 | end 17 | 18 | it 'should correctly wrap a hash' do 19 | hash = { :one => 1, :two => 2, :three => 3 } 20 | view = wrap(hash) 21 | expect(view[:one]).to eq 1 22 | expect(view[:two]).to eq 2 23 | expect(view[:three]).to eq 3 24 | end 25 | 26 | it 'should respond to empty correctly' do 27 | expect(wrap([])).to be_empty 28 | expect(wrap({'one' => 1})).to_not be_empty 29 | end 30 | 31 | it 'should be enumerable' do 32 | result = wrap([1, 2, 3]).map { |i| i * i } 33 | expect(result).to eq [1, 4, 9] 34 | end 35 | 36 | it 'should respond to size/length' do 37 | expect(wrap([1, 2, 3]).size).to eq 3 38 | expect(wrap([]).size).to eq 0 39 | end 40 | 41 | end 42 | -------------------------------------------------------------------------------- /spec/unit/error_response_spec.rb: -------------------------------------------------------------------------------- 1 | require 'fake_sqs/error_response' 2 | require 'active_support/core_ext/hash' 3 | require 'verbose_hash_fetch' 4 | 5 | RSpec.describe FakeSQS::ErrorResponse do 6 | 7 | module FakeSQS 8 | MissingCredentials = Class.new(RuntimeError) 9 | end 10 | ErrorUnknownToSQS = Class.new(RuntimeError) 11 | 12 | describe "#status" do 13 | 14 | it "picks the right error status" do 15 | error = FakeSQS::MissingCredentials.new("message") 16 | response = FakeSQS::ErrorResponse.new(error) 17 | expect(response.status).to eq 401 18 | end 19 | 20 | it "uses 400 as default status" do 21 | error = ErrorUnknownToSQS.new("message") 22 | response = FakeSQS::ErrorResponse.new(error) 23 | expect(response.status).to eq 500 24 | end 25 | 26 | end 27 | 28 | describe "#body" do 29 | 30 | let(:error) { FakeSQS::MissingCredentials.new("the message") } 31 | let(:response) { FakeSQS::ErrorResponse.new(error) } 32 | let(:data) { Hash.from_xml(response.body) } 33 | 34 | it "uses the error class name as error code" do 35 | expect(data.fetch("ErrorResponse").fetch("Error").fetch("Code")).to eq "MissingCredentials" 36 | end 37 | 38 | it "uses InternalError as code for unknown errors" do 39 | error = ErrorUnknownToSQS.new("the message") 40 | response = FakeSQS::ErrorResponse.new(error) 41 | data = Hash.from_xml(response.body) 42 | expect(data.fetch("ErrorResponse").fetch("Error").fetch("Code")).to eq "InternalError" 43 | end 44 | 45 | it "uses the to_s of the error as message" do 46 | expect(data.fetch("ErrorResponse").fetch("Error").fetch("Message")).to eq "the message" 47 | end 48 | 49 | it "has a request id" do 50 | expect(data.fetch("ErrorResponse").fetch("RequestId").size).to eq 36 51 | end 52 | 53 | it "uses Sender as type for 4xx responses" do 54 | allow(response).to receive(:status).and_return(400) 55 | expect(data.fetch("ErrorResponse").fetch("Error").fetch("Type")).to eq "Sender" 56 | end 57 | 58 | it "uses Receiver as type for 5xx responses" do 59 | allow(response).to receive(:status).and_return(500) 60 | expect(data.fetch("ErrorResponse").fetch("Error").fetch("Type")).to eq "Receiver" 61 | end 62 | 63 | end 64 | 65 | end 66 | -------------------------------------------------------------------------------- /spec/unit/message_spec.rb: -------------------------------------------------------------------------------- 1 | require 'fake_sqs/message' 2 | 3 | RSpec.describe FakeSQS::Message do 4 | 5 | describe "#body" do 6 | 7 | it "is extracted from the MessageBody" do 8 | message = create_message("MessageBody" => "abc") 9 | expect(message.body).to eq "abc" 10 | end 11 | 12 | end 13 | 14 | describe "#md5" do 15 | 16 | it "is calculated from body" do 17 | message = create_message("MessageBody" => "abc") 18 | expect(message.md5).to eq "900150983cd24fb0d6963f7d28e17f72" 19 | end 20 | 21 | end 22 | 23 | describe "#id" do 24 | 25 | it "is generated" do 26 | message = create_message 27 | expect(message.id.size).to eq 36 28 | end 29 | 30 | end 31 | 32 | describe "#delay_seconds" do 33 | 34 | it "is generated" do 35 | message = create_message({"DelaySeconds" => 10}) 36 | expect(message.delay_seconds).to eq 10 37 | end 38 | 39 | end 40 | 41 | describe 'visibility_timeout' do 42 | 43 | let :message do 44 | create_message 45 | end 46 | 47 | it 'should default to nil' do 48 | expect(message.visibility_timeout).to eq nil 49 | end 50 | 51 | it 'should be expired when it is nil' do 52 | expect(message).to be_expired 53 | end 54 | 55 | it 'should be expired if set to a previous time' do 56 | message.visibility_timeout = Time.now - 1 57 | expect(message).to be_expired 58 | end 59 | 60 | it 'should not be expired at a future date' do 61 | message.visibility_timeout = Time.now + 1 62 | expect(message).not_to be_expired 63 | end 64 | 65 | it 'should not be expired when set to expire at a future date' do 66 | message.expire_at(5) 67 | expect(message.visibility_timeout).to be >=(Time.now + 4) 68 | end 69 | 70 | end 71 | 72 | def create_message(options = {}) 73 | FakeSQS::Message.new({"MessageBody" => "test"}.merge(options)) 74 | end 75 | 76 | end 77 | -------------------------------------------------------------------------------- /spec/unit/queue_factory_spec.rb: -------------------------------------------------------------------------------- 1 | require 'fake_sqs/queue_factory' 2 | 3 | RSpec.describe FakeSQS::QueueFactory do 4 | 5 | it "builds queues with a message factory" do 6 | message_factory = double :message_factory 7 | queue = double :queue 8 | queue_factory = FakeSQS::QueueFactory.new(message_factory: message_factory, queue: queue) 9 | expect(queue).to receive(:new).with(message_factory: message_factory, name: "Foo") 10 | queue_factory.new(name: "Foo") 11 | end 12 | 13 | end 14 | -------------------------------------------------------------------------------- /spec/unit/queue_spec.rb: -------------------------------------------------------------------------------- 1 | require 'fake_sqs/queue' 2 | require 'fake_sqs/message' 3 | 4 | RSpec.describe FakeSQS::Queue do 5 | 6 | class MessageFactory 7 | def new(options = {}) 8 | FakeSQS::Message.new({'MessageBody' => 'sample-body'}.merge(options)) 9 | end 10 | end 11 | 12 | let(:message_factory) { MessageFactory.new } 13 | subject(:queue) { FakeSQS::Queue.new(:message_factory => message_factory, "QueueName" => "test-queue") } 14 | 15 | describe "#send_message" do 16 | 17 | it "adds a message" do 18 | expect(queue.messages.size).to eq 0 19 | send_message 20 | expect(queue.messages.size).to eq 1 21 | end 22 | 23 | it "returns the message" do 24 | message = double.as_null_object 25 | allow(message_factory).to receive(:new).and_return(message) 26 | expect(send_message).to eq message 27 | end 28 | 29 | it "uses the message factory" do 30 | options = { "MessageBody" => "abc" } 31 | expect(message_factory).to receive(:new).with(options) 32 | send_message(options) 33 | end 34 | 35 | it "should set the message's SentTimestamp attribute" do 36 | expect(send_message.attributes["SentTimestamp"]).to eq (Time.now.to_i * 1000) 37 | end 38 | 39 | it "should set the SenderId of the sender" do 40 | sender_id = send_message.attributes["SenderId"] 41 | expect(sender_id).to be_a String 42 | expect(sender_id.length).to eq 21 43 | end 44 | end 45 | 46 | describe "#receive_message" do 47 | 48 | it "gets the message" do 49 | sent = send_message 50 | received = receive_message 51 | expect(received.values.first).to eq sent 52 | end 53 | 54 | it "gets the message with 'DelaySeconds' option" do 55 | delay_seconds = 3 56 | sent = send_message({ "DelaySeconds" => delay_seconds }) 57 | received = receive_message 58 | expect(received.values.first).to be_nil 59 | 60 | allow(Time).to receive(:now).and_return(Time.now + delay_seconds) 61 | received = receive_message 62 | expect(received.values.first).to eq sent 63 | end 64 | 65 | it "gets you a random message" do 66 | indexes = { :first => 0, :second => 0 } 67 | sample_group = 1_000 68 | half_sample_group = sample_group / 2 69 | ten_percent = half_sample_group / 0.1 70 | 71 | sample_group.times do 72 | sent_first = send_message 73 | _ = send_message 74 | message = receive_message.values.first 75 | if message == sent_first 76 | indexes[:first] += 1 77 | else 78 | indexes[:second] += 1 79 | end 80 | reset_queue 81 | end 82 | 83 | expect(indexes[:first] + indexes[:second]).to eq sample_group 84 | 85 | expect(indexes[:first]).to be_within(ten_percent).of(half_sample_group) 86 | expect(indexes[:second]).to be_within(ten_percent).of(half_sample_group) 87 | end 88 | 89 | it "cannot get received messages" do 90 | sample_group = 1_000 91 | 92 | sample_group.times do 93 | sent_first = send_message 94 | sent_second = send_message 95 | received_first = receive_message.values.first 96 | 97 | if received_first == sent_first 98 | expect(receive_message.values.first).to eq sent_second 99 | else 100 | expect(receive_message.values.first).to eq sent_first 101 | end 102 | reset_queue 103 | end 104 | end 105 | 106 | it "keeps track of sent messages" do 107 | 108 | send_message 109 | 110 | expect(queue.messages_in_flight.size).to eq 0 111 | expect(queue.attributes["ApproximateNumberOfMessagesNotVisible"]).to eq 0 112 | expect(queue.attributes["ApproximateNumberOfMessages"]).to eq 1 113 | 114 | receive_message 115 | 116 | expect(queue.messages_in_flight.size).to eq 1 117 | expect(queue.attributes["ApproximateNumberOfMessagesNotVisible"]).to eq 1 118 | expect(queue.attributes["ApproximateNumberOfMessages"]).to eq 0 119 | end 120 | 121 | it "gets multiple message" do 122 | sent_first = send_message 123 | sent_second = send_message 124 | messages = receive_message("MaxNumberOfMessages" => "2") 125 | expect(messages.size).to eq 2 126 | expect(messages.values).to match_array [ sent_first, sent_second ] 127 | end 128 | 129 | it "won't accept more than 10 message" do 130 | expect { 131 | receive_message("MaxNumberOfMessages" => "11") 132 | }.to raise_error(FakeSQS::ReadCountOutOfRange, "11") 133 | end 134 | 135 | it "won't error on empty queues" do 136 | expect(receive_message).to eq({}) 137 | end 138 | 139 | it "should increment the ApproximateReceiveCount" do 140 | sent_message = send_message 141 | expect(sent_message.attributes["ApproximateReceiveCount"]).to eq 0 142 | queue.change_message_visibility(receive_message.keys.first, 0) 143 | expect(sent_message.attributes["ApproximateReceiveCount"]).to eq 1 144 | receive_message 145 | expect(sent_message.attributes["ApproximateReceiveCount"]).to eq 2 146 | end 147 | 148 | it "should set the ApproximateFirstReceiveTimestamp only when the message is first received" do 149 | sent_message = send_message 150 | expect(sent_message.attributes["ApproximateFirstReceiveTimestamp"]).to eq nil 151 | receive_time = (Time.now.to_i * 1000) 152 | queue.change_message_visibility(receive_message.keys.first, 0) 153 | expect(sent_message.attributes["ApproximateFirstReceiveTimestamp"]).to eq receive_time 154 | sleep 1 155 | receive_message 156 | expect(sent_message.attributes["ApproximateFirstReceiveTimestamp"]).to eq receive_time 157 | end 158 | end 159 | 160 | describe "#delete_message" do 161 | 162 | it "deletes by the receipt" do 163 | send_message 164 | receipt = receive_message.keys.first 165 | 166 | expect(queue.messages_in_flight.size).to eq 1 167 | queue.delete_message(receipt) 168 | expect(queue.messages_in_flight.size).to eq 0 169 | expect(queue.messages.size).to eq 0 170 | end 171 | 172 | it "won't raise if the receipt is unknown" do 173 | queue.delete_message("abc") 174 | end 175 | 176 | end 177 | 178 | describe "#add_queue_attributes" do 179 | 180 | it "adds to it's queue attributes" do 181 | queue.add_queue_attributes("foo" => "bar") 182 | expect(queue.attributes).to eq( 183 | "foo" => "bar", 184 | "QueueArn" => queue.arn, 185 | "ApproximateNumberOfMessages" => 0, 186 | "ApproximateNumberOfMessagesNotVisible" => 0 187 | ) 188 | end 189 | 190 | end 191 | 192 | def send_message(options = {}) 193 | queue.send_message(options) 194 | end 195 | 196 | def receive_message(options = {}) 197 | queue.receive_message(options) 198 | end 199 | 200 | def reset_queue 201 | queue.reset 202 | end 203 | 204 | end 205 | -------------------------------------------------------------------------------- /spec/unit/queues_spec.rb: -------------------------------------------------------------------------------- 1 | require 'fake_sqs/queues' 2 | require 'fake_sqs/databases/memory' 3 | 4 | RSpec.describe FakeSQS::Queues do 5 | 6 | let(:fake_database) { FakeSQS::MemoryDatabase.new } 7 | let(:queue_factory) { double :queue_factory, :new => double } 8 | subject(:queues) { FakeSQS::Queues.new(queue_factory: queue_factory, database: fake_database) } 9 | 10 | describe "#create" do 11 | 12 | it "creates new queues" do 13 | expect(queues.list.size).to eq 0 14 | create_queue("test") 15 | expect(queues.list.size).to eq 1 16 | end 17 | 18 | it "uses the queue factory" do 19 | params = double :params 20 | expect(queue_factory).to receive(:new).with(params) 21 | create_queue("test", params) 22 | end 23 | 24 | it "returns the queue" do 25 | queue = double 26 | allow(queue_factory).to receive(:new).and_return(queue) 27 | expect(create_queue("test")).to eq queue 28 | end 29 | 30 | it "returns existing queue if the queue exists" do 31 | queue = create_queue("test") 32 | expect(create_queue("test")).to eq(queue) 33 | end 34 | 35 | end 36 | 37 | describe "#delete" do 38 | 39 | it "deletes an existing queue" do 40 | create_queue("test") 41 | expect(queues.list.size).to eq 1 42 | queues.delete("test") 43 | expect(queues.list.size).to eq 0 44 | end 45 | 46 | it "cannot delete an non-existing queue" do 47 | expect { 48 | queues.delete("test") 49 | }.to raise_error(FakeSQS::NonExistentQueue, "test") 50 | end 51 | 52 | end 53 | 54 | describe "#list" do 55 | 56 | it "returns all the queues" do 57 | queue1 = create_queue("test-1") 58 | queue2 = create_queue("test-2") 59 | expect(queues.list).to eq [ queue1, queue2 ] 60 | end 61 | 62 | it "can be filtered by prefix" do 63 | queue1 = create_queue("test-1") 64 | queue2 = create_queue("test-2") 65 | _ = create_queue("other-3") 66 | expect(queues.list("QueueNamePrefix" => "test")).to eq [ queue1, queue2 ] 67 | end 68 | 69 | end 70 | 71 | describe "#get" do 72 | 73 | it "finds the queue by name" do 74 | queue = create_queue("test") 75 | expect(queues.get("test")).to eq queue 76 | end 77 | 78 | it "cannot get the queue if it doesn't exist" do 79 | expect { 80 | queues.get("test") 81 | }.to raise_error(FakeSQS::NonExistentQueue, "test") 82 | end 83 | 84 | end 85 | 86 | describe "#reset" do 87 | 88 | it "clears all queues" do 89 | create_queue("foo") 90 | create_queue("bar") 91 | expect(queues.list.size).to eq 2 92 | queues.reset 93 | expect(queues.list.size).to eq 0 94 | end 95 | 96 | end 97 | 98 | def create_queue(name, options = {}) 99 | queues.create(name, options) 100 | end 101 | 102 | end 103 | -------------------------------------------------------------------------------- /spec/unit/responder_spec.rb: -------------------------------------------------------------------------------- 1 | require 'fake_sqs/responder' 2 | require 'active_support/core_ext/hash' 3 | require 'verbose_hash_fetch' 4 | 5 | RSpec.describe FakeSQS::Responder do 6 | 7 | it "yields xml" do 8 | xml = subject.call :GetQueueUrl do |x| 9 | x.QueueUrl "example.com" 10 | end 11 | 12 | data = Hash.from_xml(xml) 13 | url = data. 14 | fetch("GetQueueUrlResponse"). 15 | fetch("GetQueueUrlResult"). 16 | fetch("QueueUrl") 17 | expect(url).to eq "example.com" 18 | end 19 | 20 | it "skips result if no block is given" do 21 | xml = subject.call :DeleteQueue 22 | 23 | data = Hash.from_xml(xml) 24 | 25 | response = data.fetch("DeleteQueueResponse") 26 | expect(response).to have_key("ResponseMetadata") 27 | expect(response).not_to have_key("DeleteQueueResult") 28 | end 29 | 30 | it "has metadata" do 31 | xml = subject.call :GetQueueUrl do |x| 32 | end 33 | 34 | data = Hash.from_xml(xml) 35 | 36 | request_id = data. 37 | fetch("GetQueueUrlResponse"). 38 | fetch("ResponseMetadata"). 39 | fetch("RequestId") 40 | 41 | expect(request_id.size).to eq 36 42 | end 43 | 44 | end 45 | -------------------------------------------------------------------------------- /spec/unit/show_output_spec.rb: -------------------------------------------------------------------------------- 1 | require 'fake_sqs/show_output' 2 | 3 | RSpec.describe FakeSQS::ShowOutput do 4 | 5 | after do 6 | $stdout = STDOUT 7 | end 8 | 9 | it "outputs the result of rack app" do 10 | app = double :app 11 | $stdout = StringIO.new 12 | middleware = FakeSQS::ShowOutput.new(app) 13 | env = {"rack.input" => ""} 14 | expect(app).to receive(:call).with(env).and_return([200, {}, [""]]) 15 | 16 | middleware.call(env) 17 | 18 | $stdout.rewind 19 | expect($stdout.read).to eq "--- {}\n\n\n" 20 | end 21 | 22 | end 23 | -------------------------------------------------------------------------------- /spec/unit/web_interface_spec.rb: -------------------------------------------------------------------------------- 1 | require 'fake_sqs/web_interface' 2 | require 'rack/test' 3 | 4 | RSpec.describe FakeSQS::WebInterface do 5 | include Rack::Test::Methods 6 | 7 | def app 8 | FakeSQS::WebInterface 9 | end 10 | 11 | it "responds to GET /ping" do 12 | get "/ping" 13 | expect(last_response).to be_ok 14 | end 15 | end 16 | --------------------------------------------------------------------------------