mosquito 0.1.2 → 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG CHANGED
@@ -1,3 +1,25 @@
1
+ == 0.1.3 - The little fairy release
2
+
3
+ * You can use an absolute URL with scheme and all instead of path-only abbreviation
4
+ * You can now assign a URL-encoded payload instead of a hash when doing all requests
5
+ except of a GET. This to be nice to people building web-service backends.
6
+ * Be nice to the folks that do not use the database or sessions
7
+ * we have @assigns to access the instance variables summoned in the controller
8
+ * follow_redirect makes use of the feature below, accordingly
9
+ * You can now pass verbatim query string parameters like so
10
+ get "/blog/archive?page=2"
11
+ which will be conveniently mixed with other params (and can also be used when doing POSTs!)
12
+ * Rdoc is extremely unfriendly to pluses and stars in Unicode mode. They should be punished.
13
+ * FunctionalTest is now WebTest and UnitTest is now ModelTest, because the ruby sadists said they shall be.
14
+ * We now support proper, infinitely nested and encapsulated parameters
15
+ * for querystrings
16
+ * for postvars
17
+ * and yes, for uploads too
18
+ * On that note, added a Mosquito::MockUpload to quickly simulate an uploaded file. The file will be filled with random text, so roll your own if you need concrete file content.
19
+ * We are Camping 1.5 compatible
20
+ * You can now do 'test "should do this"' and pass a block of assertions.
21
+ * More tests for better coverage of mosquito.rb
22
+ * Cleanup of Rakefile with other options and proper CHANGELOG inclusion:CHANGELOG
1
23
 
2
24
  == 0.1.2
3
25
 
data/Manifest.txt CHANGED
@@ -1,26 +1,20 @@
1
- Rakefile
2
- README.txt
3
- MIT-LICENSE
4
1
  CHANGELOG
2
+ MIT-LICENSE
5
3
  Manifest.txt
6
- lib
4
+ README.txt
5
+ Rakefile
7
6
  lib/mosquito.rb
8
- public
9
-
10
- public/blog
7
+ public/bare.rb
11
8
  public/blog.rb
12
9
  public/blog/controllers.rb
13
10
  public/blog/models.rb
14
11
  public/blog/views.rb
15
-
16
- public/homepage.rb
17
-
18
- test
19
- test/test_blog.rb
20
-
21
- test/test_homepage.rb
22
-
23
- test/fixtures
24
12
  test/fixtures/blog_comments.yml
25
13
  test/fixtures/blog_posts.yml
26
14
  test/fixtures/blog_users.yml
15
+ test/sage_advice_cases/parsing_arrays.rb
16
+ test/test_bare.rb
17
+ test/test_blog.rb
18
+ test/test_helpers.rb
19
+ test/test_mock_request.rb
20
+ test/test_mock_upload.rb
data/README.txt CHANGED
@@ -1,9 +1,8 @@
1
- Mosquito, for Bug-Free Camping
2
- =============================
1
+ =Mosquito, for Bug-Free Camping
3
2
 
4
3
  A testing helper for those times when you go Camping.
5
-
6
- Apply on the face, neck, and any exposed areas such as your Models and Controllers.
4
+ Apply on the face, neck, and any exposed areas such as your
5
+ Models and Controllers. Scrub gently, observe the results.
7
6
 
8
7
  == Usage
9
8
 
@@ -18,51 +17,58 @@ Make a few files and directories like this:
18
17
  blog_posts.yml
19
18
  blog_users.yml
20
19
 
21
- Setup +test_blog.rb+ like this:
20
+ Setup <b>test_blog.rb</b> like this:
22
21
 
23
22
  require 'rubygems'
24
23
  require 'mosquito'
25
24
  require File.dirname(__FILE__) + "/../public/blog"
26
-
25
+
27
26
  Blog.create
28
27
  include Blog::Models
29
-
30
- class TestBlog < Camping::FunctionalTest
31
-
32
- fixtures :blog_posts, :blog_users, :blog_comments
33
28
 
29
+ class TestBlog < Camping::WebTest
30
+
31
+ fixtures :blog_posts, :blog_users, :blog_comments
32
+
34
33
  def setup
35
34
  super
36
35
  # Do other stuff here
37
36
  end
38
-
39
- def test_index
37
+
38
+ test "should get index" do
40
39
  get
41
40
  assert_response :success
42
41
  assert_match_body %r!>blog<!
43
42
  end
44
-
45
- def test_view
43
+
44
+ test "should get view" do
46
45
  get '/view/1'
47
46
  assert_response :success
47
+ assert_kind_of Article, @assigns[:article]
48
48
  assert_match_body %r!The quick fox jumped over the lazy dog!
49
49
  end
50
-
50
+
51
+ test "should change profile" do
52
+ @request['SERVER_NAME'] = 'jonh.blogs.net'
53
+ post '/change-profile', :new_photo => upload("picture.jpg")
54
+ assert_response :success
55
+ assert_match_body %r!The pic has been uploaded!
56
+ end
51
57
  end
52
58
 
53
59
  # A unit test
54
- class TestPost < Camping::UnitTest
60
+ class TestPost < Camping::ModelTest
55
61
 
56
62
  fixtures :blog_posts, :blog_users, :blog_comments
57
63
 
58
- def test_create
64
+ test "should create" do
59
65
  post = Post.create( :user_id => 1,
60
66
  :title => "Title",
61
67
  :body => "Body")
62
68
  assert post.valid?
63
69
  end
64
70
 
65
- def test_assoc
71
+ test "should be associated with User" do
66
72
  post = Post.find :first
67
73
  assert_kind_of User, post.user
68
74
  assert_equal 1, post.user.id
@@ -70,35 +76,54 @@ Setup +test_blog.rb+ like this:
70
76
 
71
77
  end
72
78
 
79
+ You can also use old-school methods like <tt>def test_create</tt>, but we think this way is much more natural.
80
+
81
+ Mosquito includes Jay Fields' <tt>dust</tt> gem for the nice <tt>test</tt> method which allows more descriptive test names and has the added benefit of detecting those times when you try to write two tests with the same name. Ruby will otherwise silently overwrite duplicate test names without warning, which can give a false sense of security.
73
82
 
74
83
  == Details
75
84
 
76
- Inherit from +Camping::FunctionalTest+ or +Camping::UnitTest+. If you define +setup+, be sure to call +super+ so the parent class can do its thing.
85
+ Inherit from Camping::WebTest or Camping::ModelTest. If you define <tt>setup</tt>,
86
+ be sure to call <tt>super</tt> so the parent class can do its thing.
77
87
 
78
- You should also call the +MyApp.create+ method if you have one. You will also need to +include MyApp::Models+ at the top of your test file if you want to use Models in your assertions.
88
+ You should also call the <tt>MyApp.create</tt> method if you have one, <b>yourself</b>. You will also
89
+ need to <tt>include MyApp::Models</tt> at the top of your test file if you want to use
90
+ Models in your assertions directly (without going through MyApp::Models::SomeModel).
79
91
 
80
- Make fixtures in +test/fixtures+. Remember that Camping models use the name of the mount plus the model name: +blog_posts+ for the +Post+ model.
92
+ Make fixtures in <b>test/fixtures</b>. Remember that Camping models use the name of
93
+ the mount plus the model name: <b>blog_posts</b> for the <b>Post</b> model.
81
94
 
82
- See +blog_test.rb+ for an example of both Functional and Unit tests.
95
+ See <b>blog_test.rb</b> for an example of both Web and Model tests.
83
96
 
84
- == Warning: You are Camping, not Rail-riding
97
+ Mosquito is one file, just like your app (right?), so feel free to ship it included with the app itself
98
+ to simplify testing.
85
99
 
86
- Test files start with +test_+ (test_blog.rb). Test classes start with +Test+ (TestBlog).
100
+ == Warning: You are Camping, not Rail-riding
87
101
 
88
- Model and Controller test classes can both go in the same file.
102
+ These directives are highly recommended when using Mosquito:
89
103
 
90
- A Sqlite3 :memory: database is automatically used for tests that require a database.
104
+ * Test files start with <b>test_</b> (test_blog.rb). Test classes start with <b>Test</b> (TestBlog).
105
+ * Model and Controller test classes can both go in the same file.
106
+ * The popular automated test runner <tt>autotest</tt> ships with a handler for Mosquito. Install the ZenTest gem and run the <tt>autotest</tt> command in the same folder as the <tt>public</tt> and <tt>test</tt> directories.
107
+ * A Sqlite3 :memory: database is automatically used for tests that require a database.
91
108
 
92
109
  You can run your tests by executing the test file with Ruby or by running the autotest command with no arguments (from the ZenTest gem).
93
110
 
94
111
  ruby test/test_blog.rb
95
112
 
96
- or
113
+ or
97
114
 
98
115
  autotest
99
116
 
117
+ == RSpec
118
+
119
+ Do you prefer RSpec syntax? You can get halfway there by putting this include in your test file:
120
+
121
+ require 'spec/test_case_adapter'
100
122
 
101
- == Author
123
+ Then you can use <tt>should</tt> and <tt>should_not</tt> on objects inside your tests.
102
124
 
103
- Geoffrey Grosenbach http://nubyonrails.com
125
+ == Authors
104
126
 
127
+ Geoffrey Grosenbach http://topfunky.com, with a supporting act
128
+ from the little fairy http://julik.nl and the evil multipart generator
129
+ conceived by http://maxidoors.ru.
data/Rakefile CHANGED
@@ -1,13 +1,40 @@
1
+ $: << 'lib'
2
+
1
3
  require 'rubygems'
2
4
  require 'hoe'
3
- require 'lib/mosquito'
5
+ require './lib/mosquito'
6
+
7
+ # Disable spurious warnings when running tests, ActiveMagic cannot stand -w
8
+ Hoe::RUBY_FLAGS.replace ENV['RUBY_FLAGS'] || "-I#{%w(lib test).join(File::PATH_SEPARATOR)}" +
9
+ (Hoe::RUBY_DEBUG ? " #{RUBY_DEBUG}" : '')
4
10
 
5
11
  Hoe.new('Mosquito', Mosquito::VERSION) do |p|
6
12
  p.name = "mosquito"
7
- p.author = "Geoffrey Grosenbach"
13
+ p.author = ["Geoffrey Grosenbach"] # TODO Add Julik ...
8
14
  p.description = "A library for writing tests for your Camping app."
9
15
  p.email = 'boss@topfunky.com'
10
16
  p.summary = "A Camping test library."
17
+ p.changes = p.paragraphs_of('CHANGELOG', 0..1).join("\n\n")
11
18
  p.url = "http://mosquito.rubyforge.org"
19
+ p.rdoc_pattern = /README|CHANGELOG|mosquito/
20
+ p.clean_globs = ['**.log', 'coverage', 'coverage.data', 'test/test.log', 'email.txt']
12
21
  p.extra_deps = ['activerecord', 'activesupport', 'camping']
13
22
  end
23
+
24
+ begin
25
+ require 'rcov/rcovtask'
26
+ desc "just rcov minus html output"
27
+ Rcov::RcovTask.new do |t|
28
+ t.test_files = FileList["test/test_*.rb"]
29
+ t.verbose = true
30
+ end
31
+
32
+ desc 'Aggregate code coverage for unit, functional and integration tests'
33
+ Rcov::RcovTask.new("coverage") do |t|
34
+ t.test_files = FileList["test/test_*.rb"]
35
+ t.output_dir = "coverage"
36
+ t.verbose = true
37
+ t.rcov_opts << '--aggregate coverage.data'
38
+ end
39
+ rescue LoadError
40
+ end
data/lib/mosquito.rb CHANGED
@@ -3,26 +3,60 @@ rubygems
3
3
  test/unit
4
4
  active_record
5
5
  active_record/fixtures
6
- active_support/binding_of_caller
7
6
  camping
8
7
  camping/session
9
8
  fileutils
9
+ tempfile
10
10
  stringio
11
- cgi
12
11
  ).each { |lib| require lib }
13
12
 
14
13
  module Mosquito
15
- VERSION = '0.1.2'
14
+ VERSION = '0.1.3'
15
+
16
+ # For various methods that need to generate random text
17
+ def self.garbage(amount) #:nodoc:
18
+ fills = ("a".."z").to_a
19
+ str = (0...amount).map do
20
+ v = fills[rand(fills.length)]
21
+ (rand(2).zero? ? v.upcase : v)
22
+ end
23
+ str.join
24
+ end
25
+
26
+ # Will be raised if you try to test for something Camping does not support.
27
+ # Kind of a safeguard in the deep ocean of metaified Ruby goodness.
28
+ class SageAdvice < RuntimeError; end
29
+
30
+ # Will be raised if you try to call an absolute, canonical URL (with scheme and server).
31
+ # and the server does not match the specified request.
32
+ class NonLocalRequest < RuntimeError; end
33
+
34
+ def self.stash(something) #:nodoc:
35
+ @stashed = something
36
+ end
37
+
38
+ def self.unstash #:nodoc:
39
+ x, @stashed = @stashed, nil; x
40
+ end
16
41
  end
17
42
 
18
43
  ActiveRecord::Base.establish_connection(:adapter => 'sqlite3', :database => ":memory:")
19
- ActiveRecord::Base.logger = Logger.new("test/test.log")
20
- Camping::Models::Session.create_schema
44
+ ActiveRecord::Base.logger = Logger.new("test/test.log") rescue Logger.new("test.log")
21
45
 
46
+ # This needs to be set relative to the file where the test comes from, NOT relative to the
47
+ # mosquito itself
22
48
  Test::Unit::TestCase.fixture_path = "test/fixtures/"
23
49
 
24
50
  class Test::Unit::TestCase #:nodoc:
25
51
  def create_fixtures(*table_names)
52
+ if block_given?
53
+ self.class.fixtures(*table_names) { |*anything| yield(*anything) }
54
+ else
55
+ self.class.fixtures(*table_names)
56
+ end
57
+ end
58
+
59
+ def self.fixtures(*table_names)
26
60
  if block_given?
27
61
  Fixtures.create_fixtures(Test::Unit::TestCase.fixture_path, table_names) { yield }
28
62
  else
@@ -30,76 +64,313 @@ class Test::Unit::TestCase #:nodoc:
30
64
  end
31
65
  end
32
66
 
67
+ ##
68
+ # From Jay Fields.
69
+ #
70
+ # Allows tests to be specified as a block.
71
+ #
72
+ # test "should do this and that" do
73
+ # ...
74
+ # end
75
+
76
+ def self.test(name, &block)
77
+ test_name = :"test_#{name.gsub(' ','_')}"
78
+ raise ArgumentError, "#{test_name} is already defined" if self.instance_methods.include? test_name.to_s
79
+ define_method test_name, &block
80
+ end
81
+
33
82
  # Turn off transactional fixtures if you're working with MyISAM tables in MySQL
34
83
  self.use_transactional_fixtures = true
35
84
  # Instantiated fixtures are slow, but give you @david where you otherwise would need people(:david)
36
85
  self.use_instantiated_fixtures = false
37
86
  end
38
87
 
39
- class MockRequest
88
+ # Mock request is used for composing the request body and headers
89
+ class Mosquito::MockRequest
90
+ # Should be a StringIO. However, you got some assignment methods that will
91
+ # stuff it with encoded parameters for you
92
+ attr_accessor :body
93
+
94
+ DEFAULT_HEADERS = {
95
+ 'SERVER_NAME' => 'test.host',
96
+ 'PATH_INFO' => '',
97
+ 'HTTP_ACCEPT_ENCODING' => 'gzip,deflate',
98
+ 'HTTP_USER_AGENT' => 'Mozilla/5.0 (Macintosh; U; PPC Mac OS X Mach-O; en-US; rv:1.8.0.1) Gecko/20060214 Camino/1.0',
99
+ 'SCRIPT_NAME' => '/',
100
+ 'SERVER_PROTOCOL' => 'HTTP/1.1',
101
+ 'HTTP_CACHE_CONTROL' => 'max-age=0',
102
+ 'HTTP_ACCEPT_LANGUAGE' => 'en,ja;q=0.9,fr;q=0.9,de;q=0.8,es;q=0.7,it;q=0.7,nl;q=0.6,sv;q=0.5,nb;q=0.5,da;q=0.4,fi;q=0.3,pt;q=0.3,zh-Hans;q=0.2,zh-Hant;q=0.1,ko;q=0.1',
103
+ 'HTTP_HOST' => 'test.host',
104
+ 'REMOTE_ADDR' => '127.0.0.1',
105
+ 'SERVER_SOFTWARE' => 'Mongrel 0.3.12.4',
106
+ 'HTTP_KEEP_ALIVE' => '300',
107
+ 'HTTP_REFERER' => 'http://localhost/',
108
+ 'HTTP_ACCEPT_CHARSET' => 'ISO-8859-1,utf-8;q=0.7,*;q=0.7',
109
+ 'HTTP_VERSION' => 'HTTP/1.1',
110
+ 'REQUEST_URI' => '/',
111
+ 'SERVER_PORT' => '80',
112
+ 'GATEWAY_INTERFACE' => 'CGI/1.2',
113
+ 'HTTP_ACCEPT' => 'text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5',
114
+ 'HTTP_CONNECTION' => 'keep-alive',
115
+ 'REQUEST_METHOD' => 'GET',
116
+ }
117
+
40
118
  def initialize
41
- @headers = {
42
- 'SERVER_NAME' => 'localhost',
43
- 'PATH_INFO' => '',
44
- 'HTTP_ACCEPT_ENCODING' => 'gzip,deflate',
45
- 'HTTP_USER_AGENT' => 'Mozilla/5.0 (Macintosh; U; PPC Mac OS X Mach-O; en-US; rv:1.8.0.1) Gecko/20060214 Camino/1.0',
46
- 'SCRIPT_NAME' => '/',
47
- 'SERVER_PROTOCOL' => 'HTTP/1.1',
48
- 'HTTP_CACHE_CONTROL' => 'max-age=0',
49
- 'HTTP_ACCEPT_LANGUAGE' => 'en,ja;q=0.9,fr;q=0.9,de;q=0.8,es;q=0.7,it;q=0.7,nl;q=0.6,sv;q=0.5,nb;q=0.5,da;q=0.4,fi;q=0.3,pt;q=0.3,zh-Hans;q=0.2,zh-Hant;q=0.1,ko;q=0.1',
50
- 'HTTP_HOST' => 'localhost',
51
- 'REMOTE_ADDR' => '127.0.0.1',
52
- 'SERVER_SOFTWARE' => 'Mongrel 0.3.12.4',
53
- 'HTTP_KEEP_ALIVE' => '300',
54
- 'HTTP_REFERER' => 'http://localhost/',
55
- 'HTTP_ACCEPT_CHARSET' => 'ISO-8859-1,utf-8;q=0.7,*;q=0.7',
56
- 'HTTP_VERSION' => 'HTTP/1.1',
57
- 'REQUEST_URI' => '/',
58
- 'SERVER_PORT' => '80',
59
- 'GATEWAY_INTERFACE' => 'CGI/1.2',
60
- 'HTTP_ACCEPT' => 'text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5',
61
- 'HTTP_CONNECTION' => 'keep-alive',
62
- 'REQUEST_METHOD' => 'GET',
63
- }
64
- end
65
-
66
- def set(key, val)
67
- @headers[key] = val
119
+ @headers = DEFAULT_HEADERS.with_indifferent_access # :-)
120
+ @body = StringIO.new('hello Camping')
68
121
  end
69
-
122
+
123
+ # Returns the hash of headers
70
124
  def to_hash
71
125
  @headers
72
126
  end
73
127
 
128
+ # Gets a header
74
129
  def [](key)
75
130
  @headers[key]
76
131
  end
77
-
132
+
133
+ # Sets a header
78
134
  def []=(key, value)
79
135
  @headers[key] = value
80
136
  end
137
+ alias_method :set, :[]=
138
+
139
+ # Retrieve a composed query string (including the eventual "?") with URL-escaped segments
140
+ def query_string
141
+ (@query_string_with_qmark.blank? ? '' : @query_string_with_qmark)
142
+ end
81
143
 
82
- ##
144
+ # Set a composed query string, should have URL-escaped segments and include the elements after the "?"
145
+ def query_string=(nqs)
146
+ @query_string_with_qmark = nqs.gsub(/^([^\?])/, '?\1')
147
+ @headers["REQUEST_URI"] = @headers["REQUEST_URI"].split(/\?/).shift + @query_string_with_qmark
148
+ if nqs.blank?
149
+ @headers.delete "QUERY_STRING"
150
+ else
151
+ @headers["QUERY_STRING"] = nqs.gsub(/^\?/, '')
152
+ end
153
+ end
154
+
155
+ # Retrieve the domain (analogous to HTTP_HOST)
156
+ def domain
157
+ server_name || http_host
158
+ end
159
+
160
+ # Set the domain (changes both HTTP_HOST and SERVER_NAME)
161
+ def domain=(nd)
162
+ self['SERVER_NAME'] = self['HTTP_HOST'] = nd
163
+ end
164
+
83
165
  # Allow getters like this:
84
- # o.REQUEST_METHOD
85
-
166
+ # o.REQUEST_METHOD or o.request_method
86
167
  def method_missing(method_name, *args)
87
- if @headers.has_key?(method_name.to_s)
88
- return @headers[method_name.to_s]
168
+ triables = [method_name.to_s, method_name.to_s.upcase, "HTTP_" + method_name.to_s.upcase]
169
+ triables.map do | possible_key |
170
+ return @headers[possible_key] if @headers.has_key?(possible_key)
171
+ end
172
+ super(method_name, args)
173
+ end
174
+
175
+ # Assign a hash of parameters that should be used for the query string
176
+ def query_string_params=(new_param_hash)
177
+ self.query_string = qs_build(new_param_hash)
178
+ end
179
+
180
+ # Append a freeform segment to the query string in the request. Useful when you
181
+ # want to quickly combine the query strings.
182
+ def append_to_query_string(piece)
183
+ new_qs = '?' + [self.query_string.gsub(/^\?/, ''), piece].reject{|e| e.blank? }.join('&')
184
+ self.query_string = new_qs
185
+ end
186
+
187
+ # Assign a hash of parameters that should be used for POST. These might include
188
+ # objects that act like a file upload (with #original_filename and all)
189
+ def post_params=(new_param_hash_or_str)
190
+ # First see if this is a body payload
191
+ if !new_param_hash_or_str.kind_of?(Hash)
192
+ compose_verbatim_payload(new_param_hash_or_str)
193
+ # then check if anything in the new param hash resembles an uplaod
194
+ elsif extract_values(new_param_hash_or_str).any?{|value| value.respond_to?(:original_filename) }
195
+ compose_multipart_params(new_param_hash_or_str)
89
196
  else
90
- super(method_name, args)
197
+ compose_urlencoded_params(new_param_hash_or_str)
198
+ end
199
+ end
200
+
201
+ # Generates a random 22-character MIME boundary (useful for composing multipart POSTs)
202
+ def generate_boundary
203
+ "msqto-" + Mosquito::garbage(16)
204
+ end
205
+
206
+ private
207
+ # Quickly URL-escape something
208
+ def esc(t); Camping.escape(t.to_s);end
209
+
210
+ # Extracts an array of values from a deeply-nested hash
211
+ def extract_values(hash_or_a)
212
+ returning([]) do | vals |
213
+ flatten_hash(hash_or_a) do | keys, value |
214
+ vals << value
215
+ end
216
+ end
217
+ end
218
+
219
+ # Configures the test request for a POST
220
+ def compose_urlencoded_params(new_param_hash)
221
+ self['REQUEST_METHOD'] = 'POST'
222
+ self['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'
223
+ @body = StringIO.new(qs_build(new_param_hash))
224
+ end
225
+
226
+ def compose_verbatim_payload(payload)
227
+ self['REQUEST_METHOD'] = 'POST'
228
+ self['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'
229
+ @body = StringIO.new(payload)
91
230
  end
231
+
232
+ # Configures the test request for a multipart POST
233
+ def compose_multipart_params(new_param_hash)
234
+ # here we check if the encoded segments contain the boundary and should generate a new one
235
+ # if something is matched
236
+ boundary = "----------#{generate_boundary}"
237
+ self['REQUEST_METHOD'] = 'POST'
238
+ self['CONTENT_TYPE'] = "multipart/form-data; boundary=#{boundary}"
239
+ @body = StringIO.new(multipart_build(new_param_hash, boundary))
240
+ end
241
+
242
+ # Return a multipart value segment from a file upload handle.
243
+ def uploaded_file_segment(key, upload_io, boundary)
244
+ <<-EOF
245
+ --#{boundary}\r
246
+ Content-Disposition: form-data; name="#{key}"; filename="#{Camping.escape(upload_io.original_filename)}"\r
247
+ Content-Type: #{upload_io.content_type}\r
248
+ Content-Length: #{upload_io.size}\r
249
+ \r
250
+ #{upload_io.read}\r
251
+ EOF
252
+ end
253
+
254
+ # Return a conventional value segment from a parameter value
255
+ def conventional_segment(key, value, boundary)
256
+ <<-EOF
257
+ --#{boundary}\r
258
+ Content-Disposition: form-data; name="#{key}"\r
259
+ \r
260
+ #{value}\r
261
+ EOF
262
+ end
263
+
264
+ # Build a multipart request body that includes both uploaded files and conventional parameters.
265
+ # To have predictable results we sort the output segments (a hash passed in will not be
266
+ # iterated over in the original definition order anyway, as a good developer should know)
267
+ def multipart_build(params, boundary)
268
+ flat = []
269
+ flatten_hash(params) do | keys, value |
270
+ if keys[-1].nil? # warn the user that Camping will never see that
271
+ raise Mosquito::SageAdvice,
272
+ "Camping will only show you the last element of the array when using multipart forms"
273
+ end
274
+
275
+ flat_key = [esc(keys.shift), keys.map{|k| "[%s]" % esc(k) }].flatten.join
276
+ if value.respond_to?(:original_filename)
277
+ flat << uploaded_file_segment(flat_key, value, boundary)
278
+ else
279
+ flat << conventional_segment(flat_key, value, boundary)
280
+ end
281
+ end
282
+ flat.sort.join("")+"--#{boundary}--\r"
283
+ end
284
+
285
+ # Build a query string. The brackets are NOT encoded. Camping is peculiar in that
286
+ # in contrast to Rails it wants item=1&item=2 to make { item=>[1,2] } to make arrays. We have
287
+ # to account for that.
288
+ def qs_build (hash)
289
+ returning([]) do | qs |
290
+ flatten_hash(hash) do | keys, value |
291
+ keys.pop if keys[-1].nil? # cater for camping array handling
292
+ if value.respond_to?(:original_filename)
293
+ raise Mosquito::SageAdvice, "Sending a file using GET won't do you any good"
294
+ end
295
+
296
+ qs << [esc(keys.shift), keys.map{|k| "[%s]" % esc(k)}, '=', esc(value)].flatten.join
297
+ end
298
+ end.sort.join('&')
299
+ end
300
+
301
+ # Will accept a hash or array of any depth, collapse it into
302
+ # pairs in the form of ([first_level_k, second_level_k, ...], value)
303
+ # and yield these pairs as it goes to the supplied block. Some
304
+ # pairs might be yieled twice because arrays create repeating keys.
305
+ def flatten_hash(hash_or_a, parent_keys = [], &blk)
306
+ if hash_or_a.is_a?(Hash)
307
+ hash_or_a.each_pair do | k, v |
308
+ flatten_hash(v, parent_keys + [k], &blk)
309
+ end
310
+ elsif hash_or_a.is_a?(Array)
311
+ hash_or_a.map do | v |
312
+ blk.call(parent_keys + [nil], v)
313
+ end
314
+ else
315
+ blk.call(parent_keys, hash_or_a)
316
+ end
317
+ end
318
+ end
319
+
320
+ # Works like a wrapper for a simulated file upload. To use:
321
+ #
322
+ # uploaded = Mosquito::MockUpload.new("beach.jpg")
323
+ #
324
+ # This will create a file with the JPEG content-type and 122 bytes of purely random data, which
325
+ # can then be submitted as a part of the test request
326
+ class Mosquito::MockUpload < StringIO
327
+ attr_reader :local_path, :original_filename, :content_type, :extension
328
+ IMAGE_TYPES = {:jpg => 'image/jpeg', :png => 'image/png', :gif => 'image/gif',
329
+ :pdf => 'application/pdf' }.stringify_keys
330
+
331
+ def initialize(filename)
332
+ tempname = "tempfile_#{Time.now.to_i}"
333
+
334
+ @temp = ::Tempfile.new(tempname)
335
+ @local_path = @temp.path
336
+ @original_filename = File.basename(filename)
337
+ @extension = File.extname(@original_filename).gsub(/^\./, '').downcase
338
+ @content_type = IMAGE_TYPES[@extension] || "application/#{@extension}"
339
+
340
+ size = 100.bytes
341
+ super("Stub file %s \n%s\n" % [@original_filename, Mosquito::garbage(size)])
342
+ end
343
+
344
+ def inspect
345
+ info = " @size='#{length}' @filename='#{original_filename}' @content_type='#{content_type}'>"
346
+ super[0..-2] + info
92
347
  end
93
348
 
94
349
  end
95
350
 
351
+ # Stealing our assigns the evil way. This should pose no problem
352
+ # for things that happen in the controller actions, but might be tricky
353
+ # if some other service upstream munges the variables.
354
+ # This service will always get included last (innermost), so it runs regardless of
355
+ # the services upstream (such as HTTP auth) that might not call super
356
+ module Mosquito::Proboscis #:nodoc:
357
+ def service(*a)
358
+ returning(super(*a)) do
359
+ a = instance_variables.inject({}) do | assigns, ivar |
360
+ assigns[ivar.gsub(/^@/, '')] = instance_variable_get(ivar); assigns
361
+ end
362
+ Mosquito.stash(::Camping::H[a])
363
+ end
364
+ end
365
+ end
96
366
 
97
367
  module Camping
98
368
 
99
369
  class Test < Test::Unit::TestCase
100
370
 
101
- def test_dummy; end
371
+ def test_dummy; end #:nodoc
102
372
 
373
+ # The reverse of the reverse of the reverse of assert(condition)
103
374
  def deny(condition, message='')
104
375
  assert !condition, message
105
376
  end
@@ -122,64 +393,110 @@ module Camping
122
393
  yield
123
394
  assert_equal initial_value + difference, object.send(method), "#{object}##{method}"
124
395
  end
396
+
397
+ # See +assert_difference+
125
398
  def assert_no_difference(object, method, &block)
126
399
  assert_difference object, method, 0, &block
127
400
  end
128
-
129
401
  end
130
-
131
- class FunctionalTest < Test
132
-
402
+
403
+ # Used to test the controllers and rendering. The test should be called <App>Test
404
+ # (BlogTest for the aplication called Blog). A number of helper instance variables
405
+ # will be created for you - @request, which will contain a Mosquito::MockRequest
406
+ # object, @response (contains the response with headers and body), @cookies (a hash)
407
+ # and @state (a hash). Request and response will be reset in each test.
408
+ class WebTest < Test
409
+
410
+ # Gives you access to the instance variables assigned by the controller
411
+ attr_reader :assigns
412
+
413
+ def test_dummy; end #:nodoc
414
+
133
415
  def setup
134
416
  @class_name_abbr = self.class.name.gsub(/^Test/, '')
135
- @request = MockRequest.new
136
- @cookies = @response = {}
417
+ @request = Mosquito::MockRequest.new
418
+ @cookies, @response, @assigns = {}, {}, {}
137
419
  end
138
-
420
+
421
+ # Send a GET request to a URL
139
422
  def get(url='/', vars={})
140
423
  send_request url, vars, 'GET'
141
424
  end
142
425
 
426
+ # Send a POST request to a URL. All requests except GET will allow
427
+ # setting verbatim URL-encoded parameters as the third argument instead
428
+ # of a hash.
143
429
  def post(url, post_vars={})
144
430
  send_request url, post_vars, 'POST'
145
431
  end
146
432
 
433
+ # Send a DELETE request to a URL. All requests except GET will allow
434
+ # setting verbatim URL-encoded parameters as the third argument instead
435
+ # of a hash.
147
436
  def delete(url, vars={})
148
437
  send_request url, vars, 'DELETE'
149
438
  end
150
439
 
440
+ # Send a PUT request to a URL. All requests except GET will allow
441
+ # setting verbatim URL-encoded parameters as the third argument instead
442
+ # of a hash.
151
443
  def put(url, vars={})
152
444
  send_request url, vars, 'PUT'
153
445
  end
154
446
 
447
+ # Send any request. We will try to guess what you meant - if there are uploads to be
448
+ # processed it's not going to be a GET, that's for sure.
155
449
  def send_request(url, post_vars, method)
450
+
451
+ if method.to_s.downcase == "get"
452
+ @request.query_string_params = post_vars
453
+ else
454
+ @request.post_params = post_vars
455
+ end
456
+
457
+ # If there is some stuff in the URL to be used as a query string, why ignore it?
458
+ url, qs_from_url = url.split(/\?/)
459
+
460
+ relativize_url!(url)
461
+
462
+ @request.append_to_query_string(qs_from_url) if qs_from_url
463
+
464
+ # We do allow the user to override that one
156
465
  @request['REQUEST_METHOD'] = method
466
+
157
467
  @request['SCRIPT_NAME'] = '/' + @class_name_abbr.downcase
158
468
  @request['PATH_INFO'] = '/' + url
159
- @request['REQUEST_URI'] = [@request.SCRIPT_NAME, @request.PATH_INFO].join('')
160
-
161
- @request['HTTP_COOKIE'] = @cookies.map {|k,v| "#{k}=#{v}" }.join('; ') if @cookies
162
-
163
- @response = eval("#{@class_name_abbr}.run StringIO.new('#{qs_build(post_vars)}'), @request")
164
-
165
- @cookies = @response.headers['Set-Cookie'].inject(@cookies||{}) do |res,header|
166
- data = header.split(';').first
167
- name, value = data.split('=')
168
- res[name] = value
169
- res
469
+
470
+ @request['REQUEST_URI'] = [@request.SCRIPT_NAME, @request.PATH_INFO].join('').squeeze('/')
471
+ unless @request['QUERY_STRING'].blank?
472
+ @request['REQUEST_URI'] += ('?' + @request['QUERY_STRING'])
170
473
  end
171
474
 
172
- @cookies = H[@cookies]
475
+ if @cookies
476
+ @request['HTTP_COOKIE'] = @cookies.map {|k,v| "#{k}=#{Camping.escape(v)}" }.join('; ')
477
+ end
478
+
479
+ # Inject the proboscis if we haven't already done so
480
+ pr = Mosquito::Proboscis
481
+ eval("#{@class_name_abbr}.send(:include, pr) unless #{@class_name_abbr}.ancestors.include?(pr)")
482
+
483
+ # Run the request
484
+ @response = eval("#{@class_name_abbr}.run @request.body, @request")
485
+ @assigns = Mosquito::unstash
486
+
487
+ # We need to restore the cookies separately so that the app
488
+ # restores our session on the next request. We retrieve cookies and
489
+ # the session in their assigned form instead of parsing the headers and
490
+ # doing a deserialization cycle
491
+ @cookies = @assigns[:cookies] || H[{}]
492
+ @state = @assigns[:state] || H[{}]
173
493
 
174
- session = Camping::Models::Session.persist @cookies
175
- app = @class_name_abbr.gsub(/^(\w+)::.+$/, '\1')
176
- @state = (session[app] ||= Camping::H[])
177
-
178
494
  if @response.headers['X-Sendfile']
179
495
  @response.body = File.read(@response.headers['X-Sendfile'])
180
496
  end
181
497
  end
182
-
498
+
499
+ # Assert a specific response (:success, :error or a freeform error code as integer)
183
500
  def assert_response(status_code)
184
501
  case status_code
185
502
  when :success
@@ -187,43 +504,83 @@ module Camping
187
504
  when :redirect
188
505
  assert_equal 302, @response.status
189
506
  when :error
190
- assert @response.status >= 500
507
+ assert @response.status >= 500,
508
+ "Response status should have been >= 500 but was #{@response.status}"
191
509
  else
192
510
  assert_equal status_code, @response.status
193
511
  end
194
512
  end
195
-
513
+
514
+ # Check that the text in the body matches a regexp
196
515
  def assert_match_body(regex, message=nil)
197
516
  assert_match regex, @response.body, message
198
517
  end
518
+
519
+ # Opposite of +assert_match_body+
199
520
  def assert_no_match_body(regex, message=nil)
200
521
  assert_no_match regex, @response.body, message
201
522
  end
202
-
523
+
524
+ # Make sure that we are redirected to a certain URL. It's not needed
525
+ # to prepend the URL with a mount (instead of "/blog/latest-news" you can use "/latest-news")
526
+ #
527
+ # Checks both the response status and the url.
203
528
  def assert_redirected_to(url, message=nil)
204
- assert_equal url, @response.headers['Location'].path.gsub(%r!/#{@class_name_abbr.downcase}!, ''), message
529
+ assert_response :redirect
530
+ assert_equal url, extract_redirection_url, message
205
531
  end
206
-
532
+
533
+ # Assert that a cookie of name matches a certain pattern
207
534
  def assert_cookie(name, pat, message=nil)
208
535
  assert_match pat, @cookies[name], message
209
536
  end
210
-
211
- def test_dummy; end
212
-
213
- private
214
-
215
- def qs_build(var_hash)
216
- var_hash.map do |k, v|
217
- [Camping.escape(k.to_s), Camping.escape(v.to_s)].join('=')
218
- end.join('&')
537
+
538
+ # Nothing is new under the sun
539
+ def follow_redirect
540
+ get extract_redirection_url
219
541
  end
220
-
542
+
543
+ # Quickly gives you a handle to a file with random content
544
+ def upload(filename)
545
+ Mosquito::MockUpload.new(filename)
546
+ end
547
+
548
+ # Checks that Camping sent us a cookie to attach a session
549
+ def assert_session_started
550
+ assert_not_nil @cookies["camping_sid"],
551
+ "The session ID cookie was empty although session should have started"
552
+ end
553
+
554
+ # The reverse of +assert_session_started+
555
+ def assert_no_session
556
+ assert_nil @cookies["camping_sid"],
557
+ "A session cookie was sent although this should not happen"
558
+ end
559
+
560
+ private
561
+ def extract_redirection_url
562
+ loc = @response.headers['Location']
563
+ path_seg = @response.headers['Location'].path.gsub(%r!/#{@class_name_abbr.downcase}!, '')
564
+ loc.query ? (path_seg + "?" + loc.query).to_s : path_seg.to_s
565
+ end
566
+
567
+ def relativize_url!(url)
568
+ return unless url =~ /^([a-z]+):\//
569
+ p = URI.parse(url)
570
+ unless p.host == @request.domain
571
+ raise ::Mosquito::NonLocalRequest,
572
+ "You tried to callout to #{p} which is outside of the test domain"
573
+ end
574
+ url.replace(p.path + (p.query.blank ? '' : "?#{p.query}"))
575
+ end
221
576
  end
222
-
223
- class UnitTest < Test
224
-
225
- def test_dummy; end
226
-
577
+
578
+ # Used to test the models - no infrastructure will be created for running the request
579
+ class ModelTest < Test
580
+ def test_dummy; end #:nodoc
227
581
  end
228
-
582
+
583
+ # Deprecated but humane
584
+ UnitTest = ModelTest
585
+ FunctionalTest = WebTest
229
586
  end