Changeset 559
- Timestamp:
- 06/19/08 07:32:26 (2 months ago)
- Files:
-
- thingfish/branches/mp3-jukebox/lib/thingfish/daemon.rb (modified) (1 diff)
- thingfish/branches/mp3-jukebox/lib/thingfish/exceptions.rb (modified) (3 diffs)
- thingfish/branches/mp3-jukebox/lib/thingfish/handler/default.rb (modified) (1 diff)
- thingfish/branches/mp3-jukebox/lib/thingfish/mixins.rb (modified) (1 diff)
- thingfish/branches/mp3-jukebox/lib/thingfish/request.rb (modified) (9 diffs)
- thingfish/branches/mp3-jukebox/spec/thingfish/handler/default_spec.rb (modified) (3 diffs)
- thingfish/branches/mp3-jukebox/spec/thingfish/request_spec.rb (modified) (11 diffs)
Legend:
- Unmodified
- Added
- Removed
- Modified
- Copied
- Moved
thingfish/branches/mp3-jukebox/lib/thingfish/daemon.rb
r558 r559 508 508 handlers.each do |handler| 509 509 response.handlers << handler 510 request.check_body_ios 510 511 handler.process( request, response ) 511 request.check_body_ios512 512 break if response.is_handled? || client.closed? 513 513 end thingfish/branches/mp3-jukebox/lib/thingfish/exceptions.rb
r466 r559 56 56 class ClientError < ThingFish::Error; end 57 57 58 # Something was wrong with a request 58 # 500: The server was unable to handle the request even though it was valid 59 class ServerError < ThingFish::Error 60 include ThingFish::Constants 61 62 def initialize( *args ) 63 super 64 @status = HTTP::SERVER_ERROR 65 end 66 67 attr_reader :status 68 end 69 70 # 501: We received a request that we don't quite know how to handle. 71 class NotImplementedError < ThingFish::ServerError 72 include ThingFish::Constants 73 74 def initialize( *args ) 75 super 76 @status = HTTP::NOT_IMPLEMENTED 77 end 78 79 attr_reader :status 80 end 81 82 # 400: Something was wrong with a request 59 83 class RequestError < ThingFish::Error 60 84 include ThingFish::Constants … … 68 92 end 69 93 70 # Upload exceeded quota94 # 413: Upload exceeded quota 71 95 class RequestEntityTooLargeError < ThingFish::RequestError 72 96 include ThingFish::Constants … … 78 102 end 79 103 80 # Client requested a mimetype we don't know how to convert to104 # 406: Client requested a mimetype we don't know how to convert to 81 105 class RequestNotAcceptableError < ThingFish::RequestError 82 106 include ThingFish::Constants thingfish/branches/mp3-jukebox/lib/thingfish/handler/default.rb
r558 r559 242 242 ### data (POST to /) 243 243 def handle_create_request( request, response ) 244 245 if request.entity_bodies.length > 1 246 self.log.error "Can't handle multipart request (%p)" % [ request.entity_bodies ] 247 raise ThingFish::NotImplementedError, "multipart upload not implemented" 248 end 244 249 245 250 uuid = nil 246 251 247 252 # Store the primary resource 248 body, metadata = request. get_body_and_metadata253 body, metadata = request.entity_bodies.to_a.flatten 249 254 uuid = self.daemon.store_resource( body, metadata ) 250 255 thingfish/branches/mp3-jukebox/lib/thingfish/mixins.rb
r487 r559 348 348 syms.each do |sym| 349 349 define_method( sym ) { 350 raise NotImplementedError,350 raise ::NotImplementedError, 351 351 "%p does not provide an implementation of #%s" % [ self.class, sym ], 352 352 caller(1) thingfish/branches/mp3-jukebox/lib/thingfish/request.rb
r558 r559 85 85 @authed_user = nil 86 86 87 @body_key_mapping = {}88 87 @related_resources = Hash.new {|h,k| h[k] = {} } 89 88 @mongrel_request = mongrel_request … … 262 261 263 262 264 ### Get the body IO and the merged hash of metadata265 def get_body_and_metadata266 raise ArgumentError, "Can't return a single body for a multipart request" if267 self.has_multipart_body?268 269 default_metadata = {270 :useragent => self.headers[ :user_agent ],271 :uploadaddress => self.remote_addr272 }273 274 # Read title out of the content-disposition275 if self.headers[:content_disposition] &&276 self.headers[:content_disposition] =~ /filename="(?:.*\\)?(.+?)"/i277 default_metadata[ :title ] = $1278 end279 280 extracted_metadata = self.metadata[ @mongrel_request.body ] || {}281 282 # Content metadata is determined from http headers283 merged = extracted_metadata.merge({284 :format => self.content_type,285 :extent => self.headers[ :content_length ],286 })287 merged.update( default_metadata )288 289 return @mongrel_request.body, merged290 end291 292 293 263 ### Attach additional body and metadata information to the primary 294 264 ### body, that will be stored with related_to metakeys. … … 300 270 ### +related_metadata+:: 301 271 ### The metadata to attach to the new resource, as a Hash. 302 def append_related_resource( body, related_body, related_metadata={} ) 303 # Convert the body to the key of the related resources hash 304 bodykey = self.make_body_key( body ) 305 306 unless original_body = @body_key_mapping[ bodykey ] 307 errmsg = "Cannot append a resource related to %p: %p isn't one of %p" % [ 308 body, 309 bodykey, 310 @body_key_mapping.keys, 272 def append_related_resource( resource, related_resource, related_metadata={} ) 273 274 unless @entity_bodies.key?( resource ) || @related_resources.key?( resource ) 275 errmsg = "Cannot append %p related to %p: it is not a part of the request" % [ 276 related_resource, 277 resource, 311 278 ] 312 279 self.log.error( errmsg ) … … 314 281 end 315 282 316 related_bodykey = self.make_body_key( related_body )317 @body_key_mapping[ related_bodykey ] = related_body318 319 283 related_metadata[:relation] ||= 'appended' 320 self.related_resources[ original_body ][ related_body ] = related_metadata 321 end 322 323 284 self.related_resources[ resource ][ related_resource ] = related_metadata 285 286 # Add the related_resource as a key so future checks are aware that 287 # it is part of this request 288 self.related_resources[ related_resource ] = {} 289 end 290 291 324 292 ### Append the specified additional +metadata+ for the given +resource+, which should be one 325 293 ### of the entity bodies yielded by #each_body 326 ###327 ### TODO: Do we need this method after bodykey removal?328 ###329 294 def append_metadata_for( resource, metadata ) 330 # Convert the body to the key of the related resources hash 331 bodykey = self.make_body_key( resource ) 332 333 unless original_body = @body_key_mapping[ bodykey ] 334 errmsg = "Cannot append metadata related to %p(%p): %p isn't one of %p" % [ 335 body, 336 bodykey, 295 296 unless @entity_bodies.key?( resource ) || @related_resources.key?( resource ) 297 errmsg = "Cannot append metadata related to %p: it is not a part of the request" % [ 337 298 resource, 338 @body_key_mapping.keys,339 299 ] 340 300 self.log.error( errmsg ) … … 342 302 end 343 303 344 self.metadata[ original_body ].merge!( metadata ) 345 end 346 347 348 ### Generate a key based on the body object that will be the same even after duplication. This 349 ### is used to work around our workaround for StringIO's behavior when #dup'ed. 350 def make_body_key( body ) 351 if body.respond_to?( :string ) 352 return Digest::MD5.hexdigest( body.string ) 353 else 354 return "%s:%d" % [ body.path, body.object_id * 2 ] 355 end 356 end 357 358 304 self.metadata[ resource ].merge!( metadata ) 305 end 306 307 308 ### Returns the entity bodies of the request along with any related metadata as 309 ### a Hash: 310 ### { 311 ### <body io> => { <body metadata> }, 312 ### ... 313 ### } 314 def entity_bodies 315 # Parse the request's body parts if they aren't already 316 unless @entity_bodies 317 if self.has_multipart_body? 318 self.log.debug "Parsing multiple entity bodies." 319 @entity_bodies, @form_metadata = self.parse_multipart_body 320 else 321 self.log.debug "Parsing single entity body." 322 body, metadata = self.get_body_and_metadata 323 324 @entity_bodies = { body => metadata } 325 @form_metadata = {} 326 end 327 328 self.log.debug "Parsed %d bodies and %d form_metadata (%p)" % 329 [@entity_bodies.length, @form_metadata.length, @form_metadata.keys] 330 end 331 332 return @entity_bodies 333 end 334 335 359 336 ### Call the provided block once for each entity body of the request, which may 360 337 ### be multiple times in the case of a multipart request. If +include_appended_resources+ … … 385 362 ### Check the body IO objects to ensure they're still open. 386 363 def check_body_ios 387 [ self.entity_bodies, self.related_resources ].each do |hash| 388 hash.each do |body, _| 389 if body.closed? 390 391 # TODO asap: :) 392 # substitute body for a fresh and clean filehandle, 393 # since filehandle closure has been such a poopy problem 394 # in the past. this will remove the need for bodykeys, as well. 395 396 self.log.warn "Entity body closed: %p" % [ body ] 397 body.open 364 self.each_body do |body,_| 365 if body.closed? 366 self.log.warn "Body IO unexpectedly closed -- reopening a new handle" 367 368 # Create a new IO based on what the original type was 369 clone = case body 370 when StringIO 371 StringIO.new( body.string ) 372 else 373 File.open( body.path, 'r' ) 374 end 375 376 # Retain the original IO's metadata 377 @entity_bodies[ clone ] = @entity_bodies.delete( body ) if @entity_bodies.key?( body ) 378 @related_resources[ clone ] = @related_resources.delete( body ) if @related_resources.key?( body ) 379 @related_resources.each do |_,hash| 380 hash[ clone ] = hash.delete( body ) if hash.key?( body ) 398 381 end 399 382 383 self.log.debug "Body %p (%d) replaced with %p (%d)" % [ 384 body, body.object_id, clone, clone.object_id 385 ] 386 else 400 387 body.rewind 401 388 end … … 512 499 ######### 513 500 514 ### Returns the entity bodies of the request along with any related metadata as 515 ### a Hash: 516 ### { 517 ### <body io> => { <body metadata> }, 518 ### ... 519 ### } 520 def entity_bodies 521 # Parse the request's body parts if they aren't already 522 unless @entity_bodies 523 if self.has_multipart_body? 524 self.log.debug "Parsing multiple entity bodies." 525 @entity_bodies, @form_metadata = self.parse_multipart_body 526 else 527 self.log.debug "Parsing single entity body." 528 body, metadata = self.get_body_and_metadata 529 530 @entity_bodies = { body => metadata } 531 @form_metadata = {} 532 end 533 534 # Generate keys for each body that can be used to map IO copies given to filters 535 # back to the original body. 536 @entity_bodies.each do |body, _| 537 bodykey = self.make_body_key( body ) 538 @body_key_mapping[ bodykey ] = body 539 self.log.debug "Made body key %p from body %p" % [ bodykey, body ] 540 end 541 542 self.log.debug "Parsed %d bodies and %d form_metadata (%p)" % 543 [@entity_bodies.length, @form_metadata.length, @form_metadata.keys] 544 end 545 546 return @entity_bodies 547 end 548 549 501 ### Get the body IO and the merged hash of metadata 502 def get_body_and_metadata 503 raise ArgumentError, "Can't return a single body for a multipart request" if 504 self.has_multipart_body? 505 506 default_metadata = { 507 :useragent => self.headers[ :user_agent ], 508 :uploadaddress => self.remote_addr 509 } 510 511 # Read title out of the content-disposition 512 if self.headers[:content_disposition] && 513 self.headers[:content_disposition] =~ /filename="(?:.*\\)?(.+?)"/i 514 default_metadata[ :title ] = $1 515 end 516 517 extracted_metadata = self.metadata[ @mongrel_request.body ] || {} 518 519 # Content metadata is determined from http headers 520 merged = extracted_metadata.merge({ 521 :format => self.content_type, 522 :extent => self.headers[ :content_length ], 523 }) 524 merged.update( default_metadata ) 525 526 return @mongrel_request.body, merged 527 end 528 529 550 530 ### For each resource => metadata pair returned by the current +iterator+, merge the 551 531 ### toplevel metadata with the resource-specific metadata and pass both to the … … 556 536 # Call the block for every resource 557 537 iterator.each do |body, body_metadata| 558 self.log.debug "Prepping %s for yield with metadata: %p" %559 [ body, body_metadata ]560 538 body_metadata[ :format ] ||= DEFAULT_CONTENT_TYPE 561 539 extracted_metadata = self.metadata[body] || {} … … 566 544 merged.update( immutable_metadata ) 567 545 568 # We have to explicitly case this because StringIO doesn't behave like a 569 # real IO when #dup'ed; closing the original also closes the copy. 570 clone = case body 571 when StringIO 572 StringIO.new( body.string ) 573 else 574 body.dup 575 end 576 577 @body_key_mapping[ self.make_body_key(clone) ] = body 578 block.call( clone, merged ) 546 block.call( body, merged ) 579 547 580 548 # Recurse if the appended resources should be included thingfish/branches/mp3-jukebox/spec/thingfish/handler/default_spec.rb
r558 r559 109 109 110 110 full_metadata = mock( "metadata fetched from the store", :null_object => true ) 111 @metastore.should_receive( :get_properties ). 112 and_return( full_metadata ) 113 114 @request.should_receive( :get_body_and_metadata ).and_return([ body, metadata ]) 111 @metastore.should_receive( :get_properties ).and_return( full_metadata ) 112 113 @request.should_receive( :entity_bodies ).twice.and_return({ body => metadata }) 115 114 116 115 @response_headers.should_receive( :[]= ). … … 137 136 md = stub( "metadata hash" ) 138 137 139 @request.should_receive( : get_body_and_metadata ).and_return([ body, md ])138 @request.should_receive( :entity_bodies ).twice.and_return({ body => md }) 140 139 @daemon.should_receive( :store_resource ). 141 140 with( body, md ). … … 175 174 end 176 175 176 177 it "sends a NOT_IMPLEMENTED response for multipart POST to /" do 178 uri = URI.parse( "http://thingfish.laika.com:3474/" ) 179 @request.should_receive( :uri ).at_least( :once ).and_return( uri ) 180 181 @request.should_receive( :entity_bodies ).twice.and_return({ :body1 => :md1, :body2 => :md2 }) 182 183 lambda { 184 @handler.handle_post_request( @request, @response ) 185 }.should raise_error( ThingFish::NotImplementedError, /not implemented/ ) 186 end 177 187 178 188 thingfish/branches/mp3-jukebox/spec/thingfish/request_spec.rb
r554 r559 220 220 221 221 upload = mock( "Mock Upload Tempfile" ) 222 upload.should_receive( :path ).and_return( TEMPFILE_PATH )223 duped_upload = mock( "Mock Upload Tempfile duplicate" )224 duped_upload.should_receive( :path ).at_least( :once ).and_return( TEMPFILE_PATH )225 226 upload.should_receive( :dup ).and_return( duped_upload )227 228 222 @mongrel_request.stub!( :params ).and_return( params ) 229 223 @mongrel_request.stub!( :body ).and_return( upload ) … … 266 260 request.each_body do |body, metadata| 267 261 request.append_related_resource( body, generated_resource, metadata ) 262 ThingFish.logger.debug "Request related resources is now: %p" % [ request.related_resources ] 268 263 request.append_related_resource( generated_resource, sub_generated_resource, sub_metadata ) 269 264 end … … 656 651 "of the resource iterator" do 657 652 io1 = mock( "filehandle 1" ) 658 io1_dup = mock( "duplicated filehandle 1" )659 660 653 io2 = mock( "filehandle 2" ) 661 io2_dup = mock( "duplicated filehandle 2" )662 654 663 655 resource1 = mock( "extracted body 1" ) 664 resource1_dup = mock( "duplicated extracted body 1" )665 656 666 657 parser = mock( "multipart parser", :null_object => true ) … … 681 672 and_return([ entity_bodies, form_metadata ]) 682 673 683 io1.should_receive( :dup ).at_least(:once).and_return( io1_dup )684 io1.stub!( :path ).and_return( :a_path )685 io1_dup.stub!( :path ).and_return( :a_path )686 io2.should_receive( :dup ).at_least(:once).and_return( io2_dup )687 io2.stub!( :path ).and_return( :another_path )688 io2_dup.stub!( :path ).and_return( :another_path )689 resource1.should_receive( :dup ).at_least(:once).and_return( resource1_dup )690 resource1.stub!( :path ).and_return( :a_third_path )691 resource1_dup.stub!( :path ).and_return( :a_third_path )692 693 674 yielded_pairs = {} 694 675 @request.each_body( true ) do |res, parsed_metadata| 695 if res == io1 _dup676 if res == io1 696 677 thumb_metadata = { 697 678 :relation => 'thumbnail', … … 699 680 :title => 'filename1_thumb.jpg', 700 681 } 701 @request.append_related_resource( io1 _dup, resource1, thumb_metadata )682 @request.append_related_resource( io1, resource1, thumb_metadata ) 702 683 end 703 684 … … 706 687 707 688 yielded_pairs.keys.should have(3).members 708 yielded_pairs.keys.should include( io1_dup ) 709 yielded_pairs.keys.should include( io2_dup ) 710 yielded_pairs.keys.should include( resource1_dup ) 711 712 yielded_pairs[ io1_dup ][ :title ].should == 'filename1' 713 yielded_pairs[ io1_dup ][ :format ].should == 'format1' 714 yielded_pairs[ io1_dup ][ :useragent ].should == "Hotdogs" 715 yielded_pairs[ io1_dup ][ :uploadaddress ].should == IPAddr.new( '127.0.0.1' ) 716 717 yielded_pairs[ io2_dup ][ :title ].should == 'filename2' 718 yielded_pairs[ io2_dup ][ :format ].should == "format2" 719 yielded_pairs[ io2_dup ][ :useragent ].should == "Hotdogs" 720 yielded_pairs[ io2_dup ][ :uploadaddress ].should == IPAddr.new( '127.0.0.1' ) 721 722 yielded_pairs[ resource1_dup ][ :title ].should == 'filename1_thumb.jpg' 723 yielded_pairs[ resource1_dup ][ :format ].should == 'image/jpeg' 724 yielded_pairs[ resource1_dup ][ :useragent ].should == "Hotdogs" 725 yielded_pairs[ resource1_dup ][ :uploadaddress ].should == IPAddr.new( '127.0.0.1' ) 689 yielded_pairs.keys.should include( io1, io2, resource1 ) 690 691 yielded_pairs[ io1 ][ :title ].should == 'filename1' 692 yielded_pairs[ io1 ][ :format ].should == 'format1' 693 yielded_pairs[ io1 ][ :useragent ].should == "Hotdogs" 694 yielded_pairs[ io1 ][ :uploadaddress ].should == IPAddr.new( '127.0.0.1' ) 695 696 yielded_pairs[ io2 ][ :title ].should == 'filename2' 697 yielded_pairs[ io2 ][ :format ].should == "format2" 698 yielded_pairs[ io2 ][ :useragent ].should == "Hotdogs" 699 yielded_pairs[ io2 ][ :uploadaddress ].should == IPAddr.new( '127.0.0.1' ) 700 701 yielded_pairs[ resource1 ][ :title ].should == 'filename1_thumb.jpg' 702 yielded_pairs[ resource1 ][ :format ].should == 'image/jpeg' 703 yielded_pairs[ resource1 ][ :useragent ].should == "Hotdogs" 704 yielded_pairs[ resource1 ][ :uploadaddress ].should == IPAddr.new( '127.0.0.1' ) 726 705 end 727 706 … … 729 708 "the block of the body iterator" do 730 709 io1 = mock( "filehandle 1" ) 731 io1_dup = mock( "duplicated filehandle 1" )732 733 710 io2 = mock( "filehandle 2" ) 734 io2_dup = mock( "duplicated filehandle 2" )735 711 736 712 parser = mock( "multipart parser", :null_object => true ) … … 751 727 and_return([ entity_bodies, form_metadata ]) 752 728 753 io1.should_receive( :dup ).and_return( io1_dup )754 io1.stub!( :path ).and_return( :a_path )755 io1_dup.stub!( :path ).and_return( :another_path )756 io2.should_receive( :dup ).and_return( io2_dup )757 io2.stub!( :path ).and_return( :another_path )758 io2_dup.stub!( :path ).and_return( :another_path )759 760 729 yielded_pairs = {} 761 730 @request.each_body do |body, parsed_metadata| … … 764 733 765 734 yielded_pairs.keys.should have(2).members 766 yielded_pairs.keys.should include( io1_dup ) 767 yielded_pairs.keys.should include( io2_dup ) 768 769 yielded_pairs[ io1_dup ][ :title ].should == 'filename1' 770 yielded_pairs[ io1_dup ][ :format ].should == 'format1' 771 yielded_pairs[ io1_dup ][ :useragent ].should == "Hotdogs" 772 yielded_pairs[ io1_dup ][ :uploadaddress ].should == IPAddr.new( '127.0.0.1' ) 773 774 yielded_pairs[ io2_dup ][ :title ].should == 'filename2' 775 yielded_pairs[ io2_dup ][ :format ].should == "format2" 776 yielded_pairs[ io2_dup ][ :useragent ].should == "Hotdogs" 777 yielded_pairs[ io2_dup ][ :uploadaddress ].should == IPAddr.new( '127.0.0.1' ) 778 end 779 780 781 it "creates distinct duplicates for StringIO bodies" do 782 io1 = StringIO.new("foom!") 783 io2 = StringIO.new("DOOOOOM") 784 785 parser = mock( "multipart parser", :null_object => true ) 786 entity_bodies = { 787 io1 => {:title => "filename1",:format => "format1",:extent => 100292}, 788 io2 => {:title => "filename2",:format => "format2",:extent => 100234} 789 } 790 form_metadata = { 791 'foo' => 1, 792 :title => "a bogus filename", 793 :useragent => 'Clumpy the Clown', 794 } 795 796 ThingFish::MultipartMimeParser.stub!( :new ).and_return( parser ) 797 @mongrel_request.should_receive( :body ).once.and_return( :body ) 798 parser.should_receive( :parse ).once. 799 with( :body, 'greatgoatsofgerta' ). 800 and_return([ entity_bodies, form_metadata ]) 801 802 @request.each_body do |body, parsed_metadata| 803 body.read # modify the pointer on the duped StringIO 804 end 805 806 io1.pos.should == 0 807 io2.pos.should == 0 808 end 809 810 735 yielded_pairs.keys.should include( io1, io2 ) 736 737 yielded_pairs[ io1 ][ :title ].should == 'filename1' 738 yielded_pairs[ io1 ][ :format ].should == 'format1' 739 yielded_pairs[ io1 ][ :useragent ].should == "Hotdogs" 740 yielded_pairs[ io1 ][ :uploadaddress ].should == IPAddr.new( '127.0.0.1' ) 741 742 yielded_pairs[ io2 ][ :title ].should == 'filename2' 743 yielded_pairs[ io2 ][ :format ].should == "format2" 744 yielded_pairs[ io2 ][ :useragent ].should == "Hotdogs" 745 yielded_pairs[ io2 ][ :uploadaddress ].should == IPAddr.new( '127.0.0.1' ) 746 end 747 748 811 749 it "ensures each part sent to the body has the default content-type " + 812 750 "if none is explicitly provided by the request" do 813 751 io1 = mock( "filehandle 1" ) 814 io1_dup = mock( "duplicated filehandle 1" )815 752 io2 = mock( "filehandle 2" ) 816 io2_dup = mock( "duplicated filehandle 2" )817 753 818 754 parser = mock( "multipart parser", :null_object => true ) … … 833 769 and_return([ entity_bodies, form_metadata ]) 834 770 835 io1.should_receive( :dup ).and_return( io1_dup )836 io1.stub!( :path ).and_return( :a_path )837 io1_dup.stub!( :path ).and_return( :a_path )838 io2.should_receive( :dup ).and_return( io2_dup )839 io2.stub!( :path ).and_return( :another_path )840 io2_dup.stub!( :path ).and_return( :another_path )841 842 771 yielded_pairs = {} 843 772 @request.each_body do |body, parsed_metadata| … … 846 775 847 776 yielded_pairs.keys.should have(2).members 848 yielded_pairs.keys.should include( io1_dup ) 849 yielded_pairs.keys.should include( io2_dup ) 850 851 yielded_pairs[ io1_dup ][ :title ].should == 'filename1' 852 yielded_pairs[ io1_dup ][ :format ].should == DEFAULT_CONTENT_TYPE 853 yielded_pairs[ io1_dup ][ :uploadaddress ].should == IPAddr.new( '127.0.0.1' ) 854 yielded_pairs[ io2_dup ][ :title ].should == 'filename2' 855 yielded_pairs[ io2_dup ][ :format ].should == DEFAULT_CONTENT_TYPE 856 yielded_pairs[ io2_dup ][ :useragent ].should == "Hotdogs" 857 yielded_pairs[ io2_dup ][ :uploadaddress ].should == IPAddr.new( '127.0.0.1' ) 777 yielded_pairs.keys.should include( io1, io2 ) 778 779 yielded_pairs[ io1 ][ :title ].should == 'filename1' 780 yielded_pairs[ io1 ][ :format ].should == DEFAULT_CONTENT_TYPE 781 yielded_pairs[ io1 ][ :uploadaddress ].should == IPAddr.new( '127.0.0.1' ) 782 yielded_pairs[ io2 ][ :title ].should == 'filename2' 783 yielded_pairs[ io2 ][ :format ].should == DEFAULT_CONTENT_TYPE 784 yielded_pairs[ io2 ][ :useragent ].should == "Hotdogs" 785 yielded_pairs[ io2 ][ :uploadaddress ].should == IPAddr.new( '127.0.0.1' ) 858 786 end 859 787
