rets 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ module Rets
2
+ module Parser
3
+ class Compact
4
+ TAB = /\t/
5
+
6
+ INCLUDE_NULL_FIELDS = -1
7
+
8
+ InvalidDelimiter = Class.new(ArgumentError)
9
+
10
+ def self.parse_document(xml)
11
+ doc = Nokogiri.parse(xml.to_s)
12
+
13
+ delimiter = doc.at("//DELIMITER")
14
+ delimiter = delimiter ? Regexp.new(Regexp.escape(delimiter.attr(:value).to_i.chr)) : TAB
15
+
16
+ if delimiter == // || delimiter == /,/
17
+ raise InvalidDelimiter, "Empty or invalid delimiter found, unable to parse."
18
+ end
19
+
20
+ columns = doc.at("//COLUMNS").text
21
+ rows = doc.xpath("//DATA")
22
+
23
+ rows.map do |data|
24
+ self.parse(columns, data.text, delimiter)
25
+ end
26
+ end
27
+
28
+ # Parses a single row of RETS-COMPACT data.
29
+ #
30
+ # Delimiter must be a regexp because String#split behaves differently when
31
+ # given a string pattern. (It removes leading spaces).
32
+ #
33
+ def self.parse(columns, data, delimiter = TAB)
34
+ raise ArgumentError, "Delimiter must be a regular expression" unless Regexp === delimiter
35
+
36
+ column_names = columns.split(delimiter)
37
+ data_values = data.split(delimiter, INCLUDE_NULL_FIELDS)
38
+
39
+ zipped_key_values = column_names.zip(data_values).map { |k, v| [k, v.to_s] }
40
+
41
+ hash = Hash[*zipped_key_values.flatten]
42
+ hash.reject { |key, value| key.empty? && value.to_s.empty? }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,36 @@
1
+ module Rets
2
+ module Parser
3
+
4
+ # Inspired by Mail.
5
+ class Multipart
6
+ CRLF = "\r\n"
7
+ WSP = "\s"
8
+
9
+ HEADER_LINE = /^([!-9;-~]+:\s*.+)$/
10
+
11
+ Part = Struct.new(:headers, :body)
12
+
13
+ def self.parse(raw, boundary)
14
+ parts = []
15
+
16
+ boundary_regexp = /--#{Regexp.quote(boundary)}(--)?#{CRLF}/
17
+
18
+ raw.split(boundary_regexp).each do |chunk|
19
+
20
+ header_part, body_part = chunk.split(/#{CRLF}#{WSP}*#{CRLF}/m, 2)
21
+
22
+ if header_part =~ HEADER_LINE
23
+ headers = header_part.split(/\r\n/).map { |kv| k,v = kv.split(/:\s?/); [k.downcase, v] }
24
+ headers = Hash[*headers.flatten]
25
+
26
+ parts << Part.new(headers, body_part)
27
+ else
28
+ next # not a valid chunk.
29
+ end
30
+ end
31
+
32
+ parts
33
+ end
34
+ end
35
+ end
36
+ end
data/test/fixtures.rb ADDED
@@ -0,0 +1,142 @@
1
+ RETS_ERROR = <<-XML
2
+ <?xml version="1.0"?>
3
+ <RETS ReplyCode="123" ReplyText="Error message">
4
+ </RETS>
5
+ XML
6
+
7
+ RETS_REPLY = <<-XML
8
+ <?xml version="1.0"?>
9
+ <RETS ReplyCode="0" ReplyText="OK">
10
+ </RETS>
11
+ XML
12
+
13
+ CAPABILITIES = <<-XML
14
+ <RETS ReplyCode="0" ReplyText="OK">
15
+ <RETS-RESPONSE>
16
+
17
+ Abc=123
18
+ Def=ghi=jk
19
+
20
+ </RETS-RESPONSE>
21
+ </RETS>
22
+ XML
23
+
24
+ # 44 is the ASCII code for comma; an invalid delimiter.
25
+ INVALID_DELIMETER = <<-XML
26
+ <?xml version="1.0"?>
27
+ <METADATA-RESOURCE Version="01.72.10306" Date="2011-03-15T19:51:22">
28
+ <DELIMITER value="44" />
29
+ <COLUMNS>A\tB</COLUMNS>
30
+ <DATA>1\t2</DATA>
31
+ <DATA>4\t5</DATA>
32
+ </METADATA>
33
+ XML
34
+
35
+ COMPACT = <<-XML
36
+ <?xml version="1.0"?>
37
+ <METADATA-RESOURCE Version="01.72.10306" Date="2011-03-15T19:51:22">
38
+ <COLUMNS>A\tB</COLUMNS>
39
+ <DATA>1\t2</DATA>
40
+ <DATA>4\t5</DATA>
41
+ </METADATA>
42
+ XML
43
+
44
+ METADATA_UNKNOWN = <<-XML
45
+ <?xml version="1.0"?>
46
+ <RETS ReplyCode="0" ReplyText="Operation successful.">
47
+ <METADATA-FOO Version="01.72.10306" Date="2011-03-15T19:51:22">
48
+ <UNKNOWN />
49
+ </METADATA-FOO>
50
+ XML
51
+
52
+ METADATA_SYSTEM = <<-XML
53
+ <?xml version="1.0"?>
54
+ <RETS ReplyCode="0" ReplyText="Operation successful.">
55
+ <METADATA-SYSTEM Version="01.72.10306" Date="2011-03-15T19:51:22">
56
+ <SYSTEM />
57
+ <COMMENTS />
58
+ </METADATA-SYSTEM>
59
+ XML
60
+
61
+ METADATA_RESOURCE = <<-XML
62
+ <?xml version="1.0"?>
63
+ <RETS ReplyCode="0" ReplyText="Operation successful.">
64
+ <METADATA-RESOURCE Version="01.72.10306" Date="2011-03-15T19:51:22">
65
+ <COLUMNS> ResourceID StandardName VisibleName Description KeyField ClassCount ClassVersion ClassDate ObjectVersion ObjectDate SearchHelpVersion SearchHelpDate EditMaskVersion EditMaskDate LookupVersion LookupDate UpdateHelpVersion UpdateHelpDate ValidationExpressionVersion ValidationExpressionDate ValidationLookupVersionValidationLookupDate ValidationExternalVersion ValidationExternalDate </COLUMNS>
66
+ <DATA> ActiveAgent ActiveAgent Active Agent Search Contains information about active agents. MemberNumber 1 01.72.10304 2011-03-03T00:29:23 01.72.10000 2010-08-16T15:08:20 01.72.10305 2011-03-09T21:33:41 01.72.10284 2011-02-24T06:56:43 </DATA>
67
+ <DATA> Agent Agent Agent Search Contains information about all agents. MemberNumber 1 01.72.10303 2011-03-03T00:29:23 01.72.10000 2010-08-16T15:08:20 01.72.10305 2011-03-09T21:33:41 01.72.10284 2011-02-24T06:56:43 </DATA>
68
+ <DATA> History History History Search Contains information about accumulated changes to each listing. TransactionRid 1 01.72.10185 2010-12-02T02:02:58 01.72.10000 2010-08-16T15:08:20 01.72.10000 2010-08-16T22:08:30 01.72.10000 2010-08-16T15:08:20 </DATA>
69
+ <DATA> MemberAssociation Member Association Contains MLS member Association information. MemberAssociationKey 1 01.72.10277 2011-02-23T19:11:10 01.72.10214 2011-01-06T16:41:05 01.72.10220 2011-01-06T16:41:06 </DATA>
70
+ <DATA> Office Office Office Search Contains information about broker offices. OfficeNumber 1 01.72.10302 2011-03-03T00:29:23 01.72.10000 2010-08-16T15:08:20 01.72.10305 2011-03-09T21:33:41 01.72.10284 2011-02-24T06:56:43 </DATA>
71
+ <DATA> OfficeAssociation Office Association Contains MLS office Association information. OfficeAssociationKey 1 01.72.10306 2011-03-15T19:51:22 01.72.10245 2011-01-06T16:41:08 01.72.10251 2011-01-06T16:41:08 </DATA>
72
+ <DATA> OpenHouse OpenHouse Open House Search Contains information about public open house activities. OpenHouseRid 1 01.72.10185 2010-12-02T02:02:58 01.72.10000 2010-08-16T15:08:20 01.72.10134 2010-11-12T13:57:32 01.72.10000 2010-08-16T15:08:20 </DATA>
73
+ <DATA> Property Property Property Search Contains information about listed properties. ListingRid 8 01.72.10288 2011-02-24T06:59:11 01.72.10000 2010-08-16T15:08:20 01.72.10289 2011-02-24T06:59:19 01.72.10290 2011-03-01T11:06:31 </DATA>
74
+ <DATA> PropertyDeleted Deleted Property Search Contains information about deleted properties. ListingRid 1 01.72.10185 2010-12-02T02:02:58 01.72.10000 2010-08-16T15:08:20 01.72.10000 2010-08-16T22:08:30 01.72.10000 2010-08-16T22:08:34 </DATA>
75
+ <DATA> PropertyWithheld Withheld Property Search Contains information about withheld properties. ListingRid 8 01.72.10201 2011-01-05T19:34:36 01.72.10000 2010-08-16T15:08:20 01.72.10200 2011-01-05T19:34:34 01.72.10000 2010-08-16T22:08:34 </DATA>
76
+ <DATA> Prospect Prospect Prospect Search Contains information about sales or listing propects. ProspectRid 1 01.72.10185 2010-12-02T02:02:58 01.72.10000 2010-08-16T15:08:20 01.72.10000 2010-08-16T15:08:20 01.72.10000 2010-08-16T15:08:20 </DATA>
77
+ <DATA> Tour Tour Tour Search Contains information about private tour activities. TourRid 1 01.72.10185 2010-12-02T02:02:58 01.72.10000 2010-08-16T15:08:20 01.72.10000 2010-08-16T22:08:30 01.72.10000 2010-08-16T15:08:20 </DATA>
78
+ <DATA> VirtualMedia Virtual Media Contains information about virtual media for MLS listings. VirtualMediaRid 1 01.72.10126 2010-11-12T13:47:41 01.72.10127 2010-11-12T13:47:41 01.72.10086 2010-11-10T09:59:11 </DATA>
79
+ </METADATA-RESOURCE>
80
+ </RETS>
81
+ XML
82
+
83
+ METADATA_OBJECT = "<RETS ReplyCode=\"0\" ReplyText=\"V2.6.0 728: Success\">\r\n<METADATA-OBJECT Resource=\"Property\" Version=\"1.12.24\" Date=\"Wed, 1 Dec 2010 00:00:00 GMT\">\r\n<COLUMNS>\tMetadataEntryID\tObjectType\tStandardName\tMimeType\tVisibleName\tDescription\tObjectTimeStamp\tObjectCount\t</COLUMNS>\r\n<DATA>\t50045650619\tMedium\tMedium\timage/jpeg\tMedium\tA 320 x 240 Size Photo\tLastPhotoDate\tTotalPhotoCount\t</DATA>\r\n<DATA>\t20101753230\tDocumentPDF\tDocumentPDF\tapplication/pdf\tDocumentPDF\tDocumentPDF\t\t\t</DATA>\r\n<DATA>\t50045650620\tPhoto\tPhoto\timage/jpeg\tPhoto\tA 640 x 480 Size Photo\tLastPhotoDate\tTotalPhotoCount\t</DATA>\r\n<DATA>\t50045650621\tThumbnail\tThumbnail\timage/jpeg\tThumbnail\tA 128 x 96 Size Photo\tLastPhotoDate\tTotalPhotoCount\t</DATA>\r\n</METADATA-OBJECT>\r\n</RETS>\r\n"
84
+
85
+ MULITPART_RESPONSE = [
86
+ "--simple boundary",
87
+ "Content-Type: image/jpeg",
88
+ "Content-Length: 10",
89
+ "Content-ID: 90020062739",
90
+ "Object-ID: 1",
91
+ "",
92
+ "xxxxxxxx",
93
+ "--simple boundary",
94
+ "Content-Type: image/jpeg",
95
+ "Content-Length: 10",
96
+ "Content-ID: 90020062739",
97
+ "Object-ID: 2",
98
+ "",
99
+ "yyyyyyyy",
100
+ "--simple boundary",
101
+ ""
102
+ ].join("\r\n")
103
+
104
+
105
+ SAMPLE_COMPACT = <<XML
106
+ <RETS ReplyCode="0" ReplyText="Operation successful.">
107
+ <METADATA-TABLE Resource="ActiveAgent" Class="MEMB" Version="01.72.10236" Date="2011-03-03T00:29:23">
108
+ <COLUMNS> MetadataEntryID SystemName StandardName LongName DBName ShortName MaximumLength DataType Precision Searchable Interpretation Alignment UseSeparator EditMaskID LookupName MaxSelect Units Index Minimum Maximum Default Required SearchHelpID Unique ModTimeStamp ForeignKeyName ForeignField InKeyindex </COLUMNS>
109
+ <DATA> 7 City City City City 11 Character 0 1 Left 0 0 0 0 0 0 0 City 0 0 1 MemberNumber 0 </DATA>
110
+ <DATA> 9 ContactAddlPhoneType1 Contact Additional Phone Type 1 AddlPhTyp1 Contact Addl Ph Type 1 1 Character 0 1 Lookup Left 0 ContactAddlPhoneType 0 0 0 0 0 0 ContactAddlPhoneType 0 0 1 MemberNumber 0 </DATA>
111
+ <DATA> 11 ContactAddlPhoneType2 Contact Additional Phone Type 2 AddlPhTyp2 Contact Addl Ph Type 2 1 Character 0 1 Lookup Left 0 ContactAddlPhoneType 0 0 0 0 0 0 ContactAddlPhoneType 0 0 1 MemberNumber 0 </DATA>
112
+ <DATA> 13 ContactAddlPhoneType3 Contact Additional Phone Type 3 AddlPhTyp3 Contact Addl Ph Type 3 1 Character 0 1 Lookup Left 0 ContactAddlPhoneType 0 0 0 0 0 0 ContactAddlPhoneType 0 0 1 MemberNumber 0 </DATA>
113
+ <DATA> 15 ContactPhoneAreaCode1 Contact Phone Area Code 1 ContPhAC1 Contact Phone AC 1 3 Character 0 1 Left 0 0 0 0 0 0 0 ContactPhoneAreaCode 0 0 1 MemberNumber 0 </DATA>
114
+ <DATA> 17 ContactPhoneAreaCode2 Contact Phone Area Code 2 ContPhAC2 Contact Phone AC 2 3 Character 0 1 Left 0 0 0 0 0 0 0 ContactPhoneAreaCode 0 0 1 MemberNumber 0 </DATA>
115
+ <DATA> 19 ContactPhoneAreaCode3 Contact Phone Area Code 3 ContPhAC3 Contact Phone AC 3 3 Character 0 1 Left 0 0 0 0 0 0 0 ContactPhoneAreaCode 0 0 1 MemberNumber 0 </DATA>
116
+ </METADATA-TABLE>
117
+ </RETS>
118
+ XML
119
+
120
+ SAMPLE_COMPACT_2 = <<XML
121
+ <?xml version="1.0" encoding="utf-8"?>
122
+ <RETS ReplyCode="0" ReplyText="Success">
123
+ <METADATA-TABLE Class="15" Date="2010-10-28T05:41:31Z" Resource="Office" Version="26.27.62891">
124
+ <COLUMNS> ModTimeStamp MetadataEntryID SystemName StandardName LongName DBName ShortName MaximumLength DataType Precision Searchable Interpretation Alignment UseSeparator EditMaskID LookupName MaxSelect Units Index Minimum Maximum Default Required SearchHelpID Unique ForeignKeyName ForeignField InKeyIndex </COLUMNS>
125
+ <DATA> sysid15 sysid sysid sysid sysid 10 Int 0 1 0 0 1 0 0 1 1 </DATA>
126
+ <DATA> 15n1155 OfficePhone_f1155 OfficePhone Phone Offic_1155 Phone 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
127
+ <DATA> 15n1158 AccessFlag_f1158 Office Status Acces_1158 Status 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
128
+ <DATA> 15n1163 MODIFIED_f1163 Modified MODIF_1163 Modified 20 DateTime 0 1 0 0 0 0 0 0 0 </DATA>
129
+ <DATA> 15n1165 DESREALTOR_f1165 DesRealtor DESRE_1165 DesRealtor 75 Character 0 1 0 0 0 0 0 0 0 </DATA>
130
+ <DATA> 15n1166 DESREALTORUID_f1166 Designated Realtor Uid DESRE_1166 RealtorUid 20 Character 0 1 0 0 0 0 0 0 0 </DATA>
131
+ <DATA> 15n1167 INT_NO_f1167 Internet Syndication (Y/N) INT_N_1167 Int.Syn. 1 Character 0 1 Lookup 0 YESNO 1 0 0 0 0 0 </DATA>
132
+ <DATA> 15n1168 STATE_f1168 State STATE_1168 State 50 Character 0 1 Lookup 0 1_523 1 0 0 0 0 0 </DATA>
133
+ <DATA> 15n1169 CITY_f1169 City CITY_1169 City 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
134
+ <DATA> 15n1170 IDX_NO_f1170 IDX (Y/N) IDX_N_1170 IDX 1 Character 0 1 0 0 0 0 0 0 0 </DATA>
135
+ <DATA> 15n1172 ZipCode_f1172 Zip ZipCo_1172 Zip 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
136
+ <DATA> 15n1177 ADDRESS1_f1177 Address Line 1 ADDRE_1177 Address1 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
137
+ <DATA> 15n1182 MLSYN_f1182 MLS Y/N MLSYN_1182 MLSYN 1 Character 0 1 0 0 0 0 0 0 0 </DATA>
138
+ <DATA> 15n1184 OFFICENAME_f1184 Name Office Name OFFIC_1184 Office Name 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
139
+ <DATA> 15n1193 OfficeCode_f1193 OfficeID Office Code Offic_1193 Office Code 12 Character 0 1 0 0 0 0 0 0 1 </DATA>
140
+ </METADATA-TABLE>
141
+ </RETS>
142
+ XML
data/test/helper.rb ADDED
@@ -0,0 +1,6 @@
1
+ require "test/unit"
2
+ require "mocha"
3
+
4
+ require "rets"
5
+
6
+ require "fixtures"
@@ -0,0 +1,571 @@
1
+ require "helper"
2
+
3
+ class TestClient < Test::Unit::TestCase
4
+
5
+ def setup
6
+ @client = Rets::Client.new(:login_url => "http://example.com/login")
7
+ end
8
+
9
+ def test_initialize_adds_escaped_username_to_uri
10
+ client = Rets::Client.new(
11
+ :login_url => "http://example.com",
12
+ :username => "bob@example.com")
13
+
14
+ assert_equal CGI.escape("bob@example.com"), client.uri.user
15
+ assert_nil client.uri.password
16
+ end
17
+
18
+ def test_initialize_adds_escaped_password_to_uri
19
+ client = Rets::Client.new(
20
+ :login_url => "http://example.com",
21
+ :username => "bob",
22
+ :password => "secret@2!")
23
+
24
+ assert_equal CGI.escape("secret@2!"), client.uri.password
25
+ end
26
+
27
+ def test_initialize_merges_default_options
28
+ client = Rets::Client.new(:login_url => "http://example.com", :foo => true)
29
+
30
+ assert client.options.include?(:foo)
31
+ end
32
+
33
+ def test_initialize_allows_default_options_to_be_overridden
34
+ assert Rets::Client::DEFAULT_OPTIONS.include?(:persistent)
35
+
36
+ client = Rets::Client.new(:login_url => "http://example.com")
37
+ assert_equal true, client.options[:persistent]
38
+
39
+ client = Rets::Client.new(:login_url => "http://example.com", :persistent => false)
40
+ assert_equal false, client.options[:persistent]
41
+ end
42
+
43
+
44
+ def test_connection_uses_persistent
45
+ assert_kind_of Net::HTTP::Persistent, @client.connection
46
+ end
47
+
48
+ def test_connection_uses_net_http
49
+ client = Rets::Client.new(:login_url => "http://example.com", :persistent => false)
50
+
51
+ assert_kind_of Net::HTTP, client.connection
52
+ assert_equal "example.com", client.connection.address
53
+ assert_equal 80, client.connection.port
54
+ end
55
+
56
+
57
+ def test_request
58
+ post = mock()
59
+ post.expects(:body=).with("fake body")
60
+
61
+ headers = @client.build_headers
62
+
63
+ Net::HTTP::Post.expects(:new).with("/foo", headers).returns(post)
64
+
65
+ @client.connection.expects(:request).with(@client.uri, post).returns(stub_everything)
66
+
67
+ @client.expects(:handle_cookies)
68
+ @client.expects(:handle_response)
69
+
70
+ @client.stubs(:format_headers)
71
+
72
+ @client.request("/foo", "fake body")
73
+ end
74
+
75
+ def test_request_with_block
76
+ # TODO
77
+ end
78
+
79
+ def test_request_passes_correct_arguments_to_persistent_connection
80
+ @client.connection.expects(:request).with(@client.uri, instance_of(Net::HTTP::Post)).returns(stub_everything)
81
+
82
+ @client.stubs(:handle_cookies)
83
+ @client.stubs(:handle_response)
84
+ @client.stubs(:format_headers)
85
+
86
+ @client.request("/foo")
87
+ end
88
+
89
+ def test_request_passes_correct_arguments_to_net_http_connection
90
+ client = Rets::Client.new(:login_url => "http://example.com", :persistent => false)
91
+
92
+ client.connection.expects(:request).with(instance_of(Net::HTTP::Post)).returns(stub_everything)
93
+
94
+ client.stubs(:handle_cookies)
95
+ client.stubs(:handle_response)
96
+ client.stubs(:format_headers)
97
+
98
+ client.request("/foo")
99
+ end
100
+
101
+
102
+ def test_handle_response_instigates_login_process
103
+ response = Net::HTTPUnauthorized.new("","","")
104
+
105
+ @client.expects(:handle_unauthorized_response)
106
+
107
+ assert_equal response, @client.handle_response(response)
108
+ end
109
+
110
+ def test_handle_response_handles_rets_errors
111
+ response = Net::HTTPSuccess.new("", "", "")
112
+ response.stubs(:body => RETS_ERROR)
113
+
114
+ assert_raise Rets::InvalidRequest do
115
+ @client.handle_response(response)
116
+ end
117
+ end
118
+
119
+ def test_handle_response_handles_rets_valid_response
120
+ response = Net::HTTPSuccess.new("", "", "")
121
+ response.stubs(:body => RETS_REPLY)
122
+
123
+ assert_equal response, @client.handle_response(response)
124
+ end
125
+
126
+ def test_handle_response_handles_empty_responses
127
+ response = Net::HTTPSuccess.new("", "", "")
128
+ response.stubs(:body => "")
129
+
130
+ assert_equal response, @client.handle_response(response)
131
+ end
132
+
133
+ def test_handle_response_handles_non_xml_responses
134
+ response = Net::HTTPSuccess.new("", "", "")
135
+ response.stubs(:body => "<notxml")
136
+
137
+ assert_equal response, @client.handle_response(response)
138
+ end
139
+
140
+ def test_handle_response_raises_on_unknown_response_code
141
+ response = Net::HTTPServerError.new("", "", "")
142
+
143
+ assert_raise Rets::UnknownResponse do
144
+ assert_equal response, @client.handle_response(response)
145
+ end
146
+ end
147
+
148
+
149
+ def test_handle_unauthorized_response_sets_capabilities_on_success
150
+ response = Net::HTTPSuccess.new("","","")
151
+ response.stubs(:body => CAPABILITIES, :get_fields => ["xxx"])
152
+
153
+ @client.stubs(:build_auth)
154
+ @client.expects(:raw_request).with("/login").returns(response)
155
+
156
+ @client.handle_unauthorized_response(response)
157
+
158
+ capabilities = {"abc" => "123", "def" => "ghi=jk"}
159
+
160
+ assert_equal capabilities, @client.capabilities
161
+ end
162
+
163
+ def test_handle_unauthorized_response_raises_on_auth_failure
164
+ response = Net::HTTPUnauthorized.new("","","")
165
+ response.stubs(:body => "", :get_fields => ["xxx"])
166
+
167
+ @client.stubs(:build_auth)
168
+ @client.expects(:raw_request).with("/login").returns(response)
169
+
170
+ assert_raise Rets::AuthorizationFailure do
171
+ @client.handle_unauthorized_response(response)
172
+ end
173
+ end
174
+
175
+
176
+
177
+ def test_extract_capabilities
178
+ assert_equal(
179
+ {"abc" => "123", "def" => "ghi=jk"},
180
+ @client.extract_capabilities(Nokogiri.parse(CAPABILITIES))
181
+ )
182
+ end
183
+
184
+ def test_capability_url_returns_parsed_url
185
+ @client.capabilities = { "foo" => "http://example.com" }
186
+
187
+ assert_equal URI.parse("http://example.com"), @client.capability_url("foo")
188
+ end
189
+
190
+ def test_capability_url_raises_on_malformed_url
191
+ @client.capabilities = { "foo" => "http://e$^&#$&xample.com" }
192
+
193
+ assert_raise Rets::MalformedResponse do
194
+ @client.capability_url("foo")
195
+ end
196
+ end
197
+
198
+ def test_capabilities_calls_login_when_nil
199
+ @client.expects(:login)
200
+ @client.capabilities
201
+ end
202
+
203
+
204
+ def test_cookies?
205
+ assert @client.cookies?({"set-cookie" => "FavoriteFruit=Plum;"})
206
+ assert !@client.cookies?({})
207
+ end
208
+
209
+ def test_cookies=
210
+ @client.cookies = ["abc=123; path=/; HttpOnly", "def=456;", "ghi=789"]
211
+
212
+ assert_equal(
213
+ {"abc" => "123", "def" => "456", "ghi" => "789"},
214
+ @client.instance_variable_get("@cookies")
215
+ )
216
+
217
+ @client.cookies = ["abc=111; blah", "zzz=123"]
218
+
219
+ assert_equal(
220
+ {"abc" => "111", "def" => "456", "ghi" => "789", "zzz" => "123"},
221
+ @client.instance_variable_get("@cookies")
222
+ )
223
+ end
224
+
225
+ def test_cookies
226
+ # Set an array instead of hash for predictable iteration and string construction
227
+ @client.instance_variable_set("@cookies", [%w(abc 123), %w(def 456)])
228
+
229
+ assert_equal "abc=123; def=456", @client.cookies
230
+ end
231
+
232
+
233
+ def test_build_headers_provides_basic_headers
234
+ assert_equal({
235
+ "User-Agent" => "Client/1.0",
236
+ "Host" => "example.com:80",
237
+ "RETS-Version" => "RETS/1.7.2"},
238
+ @client.build_headers)
239
+ end
240
+
241
+ def test_build_headers_provides_authorization
242
+ @client.authorization = "Just trust me"
243
+
244
+ assert_equal({
245
+ "Authorization" => "Just trust me",
246
+ "User-Agent" => "Client/1.0",
247
+ "Host" => "example.com:80",
248
+ "RETS-Version" => "RETS/1.7.2"},
249
+ @client.build_headers)
250
+ end
251
+
252
+ def test_build_headers_provides_cookies
253
+ @client.cookies = ["Allowed=totally"]
254
+
255
+ assert_equal({
256
+ "Cookie" => "Allowed=totally",
257
+ "User-Agent" => "Client/1.0",
258
+ "Host" => "example.com:80",
259
+ "RETS-Version" => "RETS/1.7.2"},
260
+ @client.build_headers)
261
+ end
262
+
263
+
264
+ def test_tries_increments_with_each_call
265
+ assert_equal 1, @client.tries
266
+ assert_equal 2, @client.tries
267
+ end
268
+
269
+ def test_build_auth
270
+ www_authenticate =
271
+ %q(Digest realm="EXAMPLE", nonce="aec306b318feef4c360bc986e06d0a71", opaque="4211001cd29d5a65b3ed99f766a896b0", qop="auth")
272
+
273
+ uri = URI.parse("http://bob:secret@example.com/login")
274
+
275
+ Digest::MD5.stubs(:hexdigest => "heeheehee")
276
+
277
+ expected = <<-DIGEST.gsub(/\n/, "")
278
+ Digest username="bob", realm="EXAMPLE", qop="auth", uri="/login", nonce="aec306b318feef4c360bc986e06d0a71",
279
+ nc=00000000, cnonce="heeheehee", response="heeheehee", opaque="4211001cd29d5a65b3ed99f766a896b0"
280
+ DIGEST
281
+
282
+ assert_equal expected, @client.build_auth(www_authenticate, uri)
283
+ end
284
+
285
+ def test_calculate_digest_with_qop
286
+ Digest::MD5.expects(:hexdigest).with("bob:example:secret").returns("a1")
287
+ Digest::MD5.expects(:hexdigest).with("POST:/login").returns("a2")
288
+
289
+ Digest::MD5.expects(:hexdigest).with("a1:nonce:00000001:cnonce:qop:a2")
290
+
291
+ @client.calculate_digest("bob", "secret", "example", "nonce", "POST", URI.parse("/login"), "qop", "cnonce", 1)
292
+ end
293
+
294
+ def test_calculate_digest_without_qop
295
+ Digest::MD5.expects(:hexdigest).with("bob:example:secret").returns("a1")
296
+ Digest::MD5.expects(:hexdigest).with("POST:/login").returns("a2")
297
+
298
+ Digest::MD5.expects(:hexdigest).with("a1:nonce:a2").returns("hash")
299
+
300
+ assert_equal "hash",
301
+ @client.calculate_digest("bob", "secret", "example", "nonce", "POST", URI.parse("/login"), nil, "cnonce", 1)
302
+ end
303
+
304
+ def test_calculate_user_agent_digest
305
+ Digest::MD5.expects(:hexdigest).with("agent:secret").returns("a1")
306
+ Digest::MD5.expects(:hexdigest).with("a1::session:version").returns("hash")
307
+
308
+ assert_equal "hash",
309
+ @client.calculate_user_agent_digest("agent", "secret", "session", "version")
310
+ end
311
+
312
+
313
+ def test_session_restores_state
314
+ session = Rets::Session.new("Digest auth", {"Foo" => "/foo"}, "sessionid=123")
315
+
316
+ @client.session = session
317
+
318
+ assert_equal("Digest auth", @client.authorization)
319
+ assert_equal({"Foo" => "/foo"}, @client.capabilities)
320
+ assert_equal("sessionid=123", @client.cookies)
321
+ end
322
+
323
+ def test_session_dumps_state
324
+ @client.authorization = "Digest auth"
325
+ @client.capabilities = {"Foo" => "/foo"}
326
+ @client.cookies = "session-id=123"
327
+
328
+ session = @client.session
329
+
330
+ assert_equal("Digest auth", session.authorization)
331
+ assert_equal({"Foo" => "/foo"}, session.capabilities)
332
+ assert_equal("session-id=123", session.cookies)
333
+ end
334
+
335
+ def test_initialize_with_session_restores_state
336
+ session = Rets::Session.new("Digest auth", {"Foo" => "/foo"}, "sessionid=123")
337
+
338
+ client = Rets::Client.new(:login_url => "http://example.com", :session => session)
339
+
340
+ assert_equal("Digest auth", client.authorization)
341
+ assert_equal({"Foo" => "/foo"}, client.capabilities)
342
+ assert_equal("sessionid=123", client.cookies)
343
+ end
344
+
345
+ def test_metadata_when_not_initialized_with_metadata
346
+ client = Rets::Client.new(:login_url => "http://example.com")
347
+ Rets::Metadata::Root.expects(:new)
348
+ client.metadata
349
+ end
350
+
351
+ def test_initialize_with_old_metadata_cached_gets_new_metadata
352
+ metadata = stub(:current? => false)
353
+ new_metadata = stub(:current? => false)
354
+ client = Rets::Client.new(:login_url => "http://example.com", :metadata => metadata)
355
+ client.stubs(:capabilities => {})
356
+ Rets::Metadata::Root.expects(:new => new_metadata).once
357
+
358
+ assert_same new_metadata, client.metadata
359
+ # This second call ensures the expectations on Root are met
360
+ client.metadata
361
+ end
362
+
363
+ def test_initialize_with_current_metadata_cached_return_cached_metadata
364
+ metadata = stub(:current? => true)
365
+ client = Rets::Client.new(:login_url => "http://example.com", :metadata => metadata)
366
+ client.stubs(:capabilities => {})
367
+
368
+ assert_same metadata, client.metadata
369
+ end
370
+
371
+ def test_initialize_takes_logger
372
+ logger = Object.new
373
+
374
+ client = Rets::Client.new(:login_url => "http://example.com", :logger => logger)
375
+
376
+ assert_equal logger, client.logger
377
+ end
378
+
379
+ def test_default_logger_returns_api_compatible_silent_logger
380
+ logger = @client.logger
381
+
382
+ assert_nothing_raised do
383
+ logger.fatal "foo"
384
+ logger.error "foo"
385
+ logger.warn "foo"
386
+ logger.info "foo"
387
+ logger.debug "foo"
388
+ end
389
+ end
390
+
391
+
392
+ def test_find_first_calls_find_every_with_limit_one
393
+ @client.expects(:find_every).with(:limit => 1, :foo => :bar).returns([1,2,3])
394
+
395
+ assert_equal 1, @client.find(:first, :foo => :bar, :limit => 5), "User-specified limit should be ignored"
396
+ end
397
+
398
+ def test_find_all_calls_find_every
399
+ @client.expects(:find_every).with(:limit => 5, :foo => :bar).returns([1,2,3])
400
+
401
+ assert_equal [1,2,3], @client.find(:all, :limit => 5, :foo => :bar)
402
+ end
403
+
404
+ def test_find_raises_on_unknown_quantity
405
+ assert_raise ArgumentError do
406
+ @client.find(:incorrect, :foo => :bar)
407
+ end
408
+ end
409
+
410
+ def test_find_provides_default_values
411
+ @client.expects(:build_key_values).
412
+ with("QueryType" => "DMQL2", "Format" => "COMPACT", "Query" => "x", "Foo" => "bar").
413
+ returns("xxx")
414
+
415
+ @client.stubs(:capability_url => URI.parse("/example"))
416
+ @client.stubs(:request_with_compact_response)
417
+
418
+ @client.find(:all, :query => "x", :foo => "bar")
419
+ end
420
+
421
+ def test_find_allows_defaults_to_be_overridden
422
+ @client.expects(:build_key_values).
423
+ with("QueryType" => "DMQL3000", "Format" => "COMPACT", "Query" => "x", "Foo" => "bar").
424
+ returns("xxx")
425
+
426
+ @client.stubs(:capability_url => URI.parse("/example"))
427
+ @client.stubs(:request_with_compact_response)
428
+
429
+ @client.find(:all, :query => "x", :foo => "bar", :query_type => "DMQL3000")
430
+ end
431
+
432
+ def test_find_returns_undecorated_results
433
+ @client.stubs(:capability_url => URI.parse("/example"))
434
+
435
+ @client.expects(:request_with_compact_response).
436
+ with("/example", instance_of(String), instance_of(Hash)).
437
+ returns([["foo", "bar"]])
438
+
439
+ results = @client.find(:all, :search_type => "Property", :class => "Res", :query => "x", :foo => "bar")
440
+
441
+ assert_equal [["foo", "bar"]], results
442
+ end
443
+
444
+ def test_find_returns_decorated_results
445
+ @client.stubs(:capability_url => URI.parse("/example"))
446
+
447
+ @client.expects(:request_with_compact_response).
448
+ with("/example", instance_of(String), instance_of(Hash)).
449
+ returns([["foo", "bar"]])
450
+
451
+ fake_rets_class = stub(:rets_class)
452
+ fake_result = stub(:result)
453
+
454
+ @client.expects(:find_rets_class).with("Property", "Res").returns(fake_rets_class)
455
+ @client.expects(:decorate_results).with([["foo", "bar"]], fake_rets_class).returns(fake_result)
456
+
457
+ results = @client.find(:all, :search_type => "Property", :class => "Res", :query => "x", :foo => "bar", :resolve => true)
458
+
459
+ assert_equal fake_result, results
460
+ end
461
+
462
+ def test_fixup_keys
463
+ assert_equal({ "Foo" => "bar" }, @client.fixup_keys(:foo => "bar"))
464
+ assert_equal({ "FooFoo" => "bar" }, @client.fixup_keys(:foo_foo => "bar"))
465
+ end
466
+
467
+ def test_all_objects_calls_objects
468
+ @client.expects(:objects).with("*", :foo => :bar)
469
+
470
+ @client.all_objects(:foo => :bar)
471
+ end
472
+
473
+ def test_objects_handles_string_argument
474
+ @client.expects(:fetch_object).with("*", :foo => :bar)
475
+ @client.stubs(:create_parts_from_response)
476
+
477
+ @client.objects("*", :foo => :bar)
478
+ end
479
+
480
+ def test_objects_handle_array_argument
481
+ @client.expects(:fetch_object).with("1,2", :foo => :bar)
482
+ @client.stubs(:create_parts_from_response)
483
+
484
+ @client.objects([1,2], :foo => :bar)
485
+ end
486
+
487
+ def test_objects_raises_on_other_arguments
488
+ assert_raise ArgumentError do
489
+ @client.objects(Object.new, :foo => :bar)
490
+ end
491
+ end
492
+
493
+ def test_create_parts_from_response_returns_multiple_parts_when_multipart_response
494
+ response = {"content-type" => 'multipart; boundary="simple boundary"'}
495
+ response.stubs(:body => MULITPART_RESPONSE)
496
+
497
+ Rets::Parser::Multipart.expects(:parse).
498
+ with(MULITPART_RESPONSE, "simple boundary").
499
+ returns([])
500
+
501
+ @client.create_parts_from_response(response)
502
+ end
503
+
504
+ def test_create_parts_from_response_returns_a_single_part_when_not_multipart_response
505
+ response = {"content-type" => "text/plain"}
506
+ response.stubs(:body => "fakebody")
507
+
508
+ parts = @client.create_parts_from_response(response)
509
+
510
+ assert_equal 1, parts.size
511
+
512
+ part = parts.first
513
+
514
+ assert_equal response, part.headers
515
+ assert_equal "fakebody", part.body
516
+ end
517
+
518
+ def test_object_calls_fetch_object
519
+ response = stub(:body => "foo")
520
+
521
+ @client.expects(:fetch_object).with("1", :foo => :bar).returns(response)
522
+
523
+ assert_equal "foo", @client.object("1", :foo => :bar)
524
+ end
525
+
526
+ def test_fetch_object
527
+ @client.expects(:capability_url).with("GetObject").returns(URI.parse("/obj"))
528
+
529
+ @client.expects(:build_key_values => "fakebody").with(
530
+ "Resource" => "Property",
531
+ "Type" => "Image",
532
+ "ID" => "123:*",
533
+ "Location" => 0
534
+ )
535
+
536
+ @client.expects(:request).with("/obj", "fakebody",
537
+ has_entries(
538
+ "Accept" => "image/jpeg, image/png;q=0.5, image/gif;q=0.1",
539
+ "Content-Type" => "application/x-www-form-urlencoded",
540
+ "Content-Length" => "8")
541
+ )
542
+
543
+ @client.fetch_object("*", :resource => "Property", :object_type => "Image", :resource_id => "123")
544
+ end
545
+
546
+ def test_metadata_caches
547
+ metadata = stub(:current? => true)
548
+ @client.metadata = metadata
549
+ @client.stubs(:capabilities => {})
550
+
551
+ assert_same metadata, @client.metadata, "Should be memoized"
552
+ end
553
+
554
+ def test_retrieve_metadata_type
555
+ @client.expects(:capability_url).with("GetMetadata").returns(URI.parse("/meta"))
556
+
557
+ @client.expects(:build_key_values => "fakebody").with(
558
+ "Format" => "COMPACT",
559
+ "Type" => "METADATA-FOO",
560
+ "ID" => "0"
561
+ )
562
+
563
+ @client.expects(:request => stub(:body => "response")).with("/meta", "fakebody", has_entries(
564
+ "Content-Type" => "application/x-www-form-urlencoded",
565
+ "Content-Length" => "8"
566
+ ))
567
+
568
+ assert_equal "response", @client.retrieve_metadata_type("FOO")
569
+ end
570
+
571
+ end