dm-couchdb-adapter 0.10.2
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.
- data/Gemfile +1 -0
- data/History.txt +33 -0
- data/LICENSE +20 -0
- data/Manifest.txt +21 -0
- data/README.rdoc +68 -0
- data/Rakefile +65 -0
- data/TODO +0 -0
- data/VERSION +1 -0
- data/lib/couchdb_adapter/adapter.rb +165 -0
- data/lib/couchdb_adapter/attachments.rb +121 -0
- data/lib/couchdb_adapter/collection.rb +7 -0
- data/lib/couchdb_adapter/conditions.rb +190 -0
- data/lib/couchdb_adapter/couch_resource.rb +45 -0
- data/lib/couchdb_adapter/design.rb +5 -0
- data/lib/couchdb_adapter/json_object.rb +23 -0
- data/lib/couchdb_adapter/migrations.rb +25 -0
- data/lib/couchdb_adapter/model.rb +9 -0
- data/lib/couchdb_adapter/query.rb +7 -0
- data/lib/couchdb_adapter/resource.rb +19 -0
- data/lib/couchdb_adapter/version.rb +5 -0
- data/lib/couchdb_adapter/view.rb +41 -0
- data/lib/couchdb_adapter.rb +31 -0
- data/spec/integration/couchdb_adapter_spec.rb +291 -0
- data/spec/integration/couchdb_attachments_spec.rb +116 -0
- data/spec/integration/couchdb_view_spec.rb +47 -0
- data/spec/integration_spec.rb +76 -0
- data/spec/shared/adapter_shared_spec.rb +310 -0
- data/spec/spec.opts +2 -0
- data/spec/spec_helper.rb +22 -0
- data/spec/testfile.txt +1 -0
- data/spec/unit/couch_db_adapter_spec.rb +8 -0
- data/tasks/install.rb +13 -0
- data/tasks/spec.rb +25 -0
- metadata +139 -0
@@ -0,0 +1,291 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'spec_helper.rb')
|
2
|
+
|
3
|
+
if COUCHDB_AVAILABLE
|
4
|
+
class ::User
|
5
|
+
include DataMapper::Resource
|
6
|
+
|
7
|
+
# regular properties
|
8
|
+
property :name, String
|
9
|
+
property :age, Integer
|
10
|
+
property :wealth, Float
|
11
|
+
property :created_at, DateTime
|
12
|
+
property :created_on, Date
|
13
|
+
property :location, JsonObject
|
14
|
+
|
15
|
+
# creates methods for accessing stored/indexed views in the CouchDB database
|
16
|
+
view(:by_name) {{ "map" => "function(doc) { if (#{couchdb_types_condition}) { emit(doc.name, doc); } }" }}
|
17
|
+
view(:by_age) {{ "map" => "function(doc) { if (#{couchdb_types_condition}) { emit(doc.age, doc); } }" }}
|
18
|
+
view(:count) {{ "map" => "function(doc) { if (#{couchdb_types_condition}) { emit(null, 1); } }",
|
19
|
+
"reduce" => "function(keys, values) { return sum(values); }" }}
|
20
|
+
|
21
|
+
belongs_to :company
|
22
|
+
|
23
|
+
before :create do
|
24
|
+
self.created_at = DateTime.now
|
25
|
+
self.created_on = Date.today
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class ::Company
|
30
|
+
include DataMapper::Resource
|
31
|
+
|
32
|
+
# This class happens to have similar properties
|
33
|
+
property :name, String
|
34
|
+
property :age, Integer
|
35
|
+
|
36
|
+
has n, :users
|
37
|
+
end
|
38
|
+
|
39
|
+
class ::Person
|
40
|
+
include DataMapper::Resource
|
41
|
+
|
42
|
+
property :name, String
|
43
|
+
end
|
44
|
+
|
45
|
+
class ::Employee < Person
|
46
|
+
property :rank, String
|
47
|
+
end
|
48
|
+
|
49
|
+
class ::Broken
|
50
|
+
include DataMapper::Resource
|
51
|
+
|
52
|
+
property :couchdb_type, Discriminator
|
53
|
+
property :name, String
|
54
|
+
end
|
55
|
+
|
56
|
+
describe DataMapper::Adapters::CouchdbAdapter do
|
57
|
+
|
58
|
+
describe "resource functions" do
|
59
|
+
|
60
|
+
before(:each) do
|
61
|
+
@user = User.new(:name => "Jamie", :age => 67, :wealth => 11.5)
|
62
|
+
@user.save.should be_true
|
63
|
+
end
|
64
|
+
|
65
|
+
after(:each) do
|
66
|
+
@user.destroy.should be_true
|
67
|
+
end
|
68
|
+
|
69
|
+
it "should create a record with a specified id" do
|
70
|
+
user_with_id = User.new(:name => 'user with id')
|
71
|
+
user_with_id.id = 'user_id'
|
72
|
+
user_with_id.save.should == true
|
73
|
+
User.get!('user_id', :repository => :couch).should == user_with_id
|
74
|
+
user_with_id.destroy.should be_true
|
75
|
+
end
|
76
|
+
|
77
|
+
it "should get a record" do
|
78
|
+
user = User.get!(@user.id)
|
79
|
+
user.id.should_not be_nil
|
80
|
+
user.name.should == "Jamie"
|
81
|
+
user.age.should == 67
|
82
|
+
end
|
83
|
+
|
84
|
+
it "should not get records of the wrong type by id" do
|
85
|
+
Company.get(@user.id).should == nil
|
86
|
+
lambda { Company.get!(@user.id) }.should raise_error(DataMapper::ObjectNotFoundError)
|
87
|
+
end
|
88
|
+
|
89
|
+
it "should update a record" do
|
90
|
+
user = User.get!(@user.id)
|
91
|
+
user.name = "Janet"
|
92
|
+
user.save
|
93
|
+
user.name.should_not == @user.name
|
94
|
+
user.rev.should_not == @user.rev
|
95
|
+
user.age.should == @user.age
|
96
|
+
user.id.should == @user.id
|
97
|
+
user.destroy.should be_true
|
98
|
+
end
|
99
|
+
|
100
|
+
it "should get all records" do
|
101
|
+
User.all.length.should == 1
|
102
|
+
end
|
103
|
+
|
104
|
+
it "should set total_rows on collection" do
|
105
|
+
User.all.total_rows.should == 1
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
describe "ad_hoc queries" do
|
110
|
+
|
111
|
+
before(:each) do
|
112
|
+
@user = User.new({ :name => "Jamie", :age => 67, :wealth => 11.5 })
|
113
|
+
@user.save.should be_true
|
114
|
+
end
|
115
|
+
|
116
|
+
after(:each) do
|
117
|
+
@user.destroy.should be_true
|
118
|
+
end
|
119
|
+
|
120
|
+
it "should get records by eql matcher" do
|
121
|
+
User.all(:name => "Jamie").size.should == 1
|
122
|
+
User.all(:age => 50).size.should == 0
|
123
|
+
end
|
124
|
+
|
125
|
+
it "should get records by not matcher" do
|
126
|
+
User.all(:age.not => 50).size.should == 1
|
127
|
+
end
|
128
|
+
|
129
|
+
it "should get records by gt matcher" do
|
130
|
+
User.all(:age.gt => 67).size.should == 0
|
131
|
+
end
|
132
|
+
|
133
|
+
it "should get records by gte matcher" do
|
134
|
+
User.all(:age.gte => 67).size.should == 1
|
135
|
+
end
|
136
|
+
|
137
|
+
it "should get records by lt matcher" do
|
138
|
+
User.all(:age.lt => 67).size.should == 0
|
139
|
+
end
|
140
|
+
|
141
|
+
it "should get records by lte matcher" do
|
142
|
+
User.all(:age.lte => 67).size.should == 1
|
143
|
+
end
|
144
|
+
|
145
|
+
it "should get records by the like matcher" do
|
146
|
+
User.all(:name.like => "Jo").size.should == 0
|
147
|
+
User.all(:name.like => "Ja%").size.should == 1
|
148
|
+
User.all(:name.like => "%J%m%").size.should == 1
|
149
|
+
User.all(:name.like => /^Jam/).size.should == 1
|
150
|
+
end
|
151
|
+
|
152
|
+
it "should get records with multiple matchers" do
|
153
|
+
User.all(:name => "Jamie", :age.lt => 80).size.should == 1
|
154
|
+
end
|
155
|
+
|
156
|
+
it "should order records" do
|
157
|
+
user = User.new(:name => "Aaron", :age => 30)
|
158
|
+
user.save
|
159
|
+
users = User.all(:order => [:age])
|
160
|
+
users[0].age.should == 30
|
161
|
+
users = User.all(:order => [:name, :age])
|
162
|
+
users[0].age.should == 30
|
163
|
+
users[1].age.should == 67
|
164
|
+
user.destroy
|
165
|
+
end
|
166
|
+
|
167
|
+
end
|
168
|
+
|
169
|
+
describe "view queries" do
|
170
|
+
|
171
|
+
before(:all) do
|
172
|
+
User.auto_migrate!
|
173
|
+
end
|
174
|
+
|
175
|
+
before(:each) do
|
176
|
+
@jamie = User.create(:name => "Jamie", :age => 67, :wealth => 11.5)
|
177
|
+
@aaron = User.create(:name => "Aaron", :age => 30, :wealth => 20)
|
178
|
+
end
|
179
|
+
|
180
|
+
after(:each) do
|
181
|
+
[@jamie, @aaron].each { |user| user.destroy }
|
182
|
+
end
|
183
|
+
|
184
|
+
it "should be able to call stored views" do
|
185
|
+
User.by_name.first.should == User.all(:order => [:name]).first
|
186
|
+
User.by_age.first.should == User.all(:order => [:age]).first
|
187
|
+
end
|
188
|
+
|
189
|
+
it "should be able to call stored views with keys" do
|
190
|
+
User.by_name("Aaron").first == User.all(:name => "Aaron").first
|
191
|
+
User.by_age(30).first == User.all(:age => 30).first
|
192
|
+
User.by_name("Aaron").first == User.by_name(:key => "Aaron").first
|
193
|
+
User.by_age(30).first == User.by_age(:key => 30).first
|
194
|
+
end
|
195
|
+
|
196
|
+
it "should return a value from a view with reduce defined" do
|
197
|
+
User.count.should == [ { "value" => User.all.length, "key" => nil } ]
|
198
|
+
end
|
199
|
+
|
200
|
+
it "should be able to perform ordered multi-key fetch on a view" do
|
201
|
+
User.by_name(:keys => ["Aaron", "Jamie"]).should == [@aaron, @jamie]
|
202
|
+
User.by_name(:keys => ["Jamie", "Aaron"]).should == [@jamie, @aaron]
|
203
|
+
end
|
204
|
+
|
205
|
+
end
|
206
|
+
|
207
|
+
describe "associations" do
|
208
|
+
before(:all) do
|
209
|
+
@company = Company.create(:name => "ExCorp")
|
210
|
+
@user = User.create(:name => 'John', :company => @company)
|
211
|
+
end
|
212
|
+
after(:all) do
|
213
|
+
@company.destroy
|
214
|
+
@user.destroy
|
215
|
+
end
|
216
|
+
|
217
|
+
it "should work with belongs_to associations" do
|
218
|
+
User.get(@user.id).company.should == @company
|
219
|
+
end
|
220
|
+
|
221
|
+
it "should work with has n associations" do
|
222
|
+
@company.users.should include(@user)
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
describe 'STI' do
|
227
|
+
|
228
|
+
before(:all) do
|
229
|
+
Person.auto_migrate!
|
230
|
+
end
|
231
|
+
|
232
|
+
it "should override default type" do
|
233
|
+
person = Person.new(:name => 'Bob')
|
234
|
+
person.save.should be_true
|
235
|
+
Person.first.couchdb_type.should == Person
|
236
|
+
person.destroy.should be_true
|
237
|
+
end
|
238
|
+
|
239
|
+
it "should load descendents on parent.all" do
|
240
|
+
employee = Employee.new(:name => 'Bob', :rank => 'Peon')
|
241
|
+
employee.save.should be_true
|
242
|
+
Person.all.include?(employee).should be_true
|
243
|
+
employee.destroy.should be_true
|
244
|
+
end
|
245
|
+
|
246
|
+
it "should be able to get children from parent.get" do
|
247
|
+
employee = Employee.new(:name => 'Bob', :rank => 'Peon')
|
248
|
+
employee.save.should be_true
|
249
|
+
Person.get(employee.id).should_not be_nil
|
250
|
+
employee.destroy.should be_true
|
251
|
+
end
|
252
|
+
|
253
|
+
it "should load descendents on parent.by_name" do
|
254
|
+
employee = Employee.new(:name => 'Bob', :rank => 'Peon')
|
255
|
+
employee.save.should be_true
|
256
|
+
Person.by_name(:key => 'Bob').include?(employee).should be_true
|
257
|
+
employee.destroy.should be_true
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
describe 'JSON serialization' do
|
262
|
+
if DMSERIAL_AVAILABLE
|
263
|
+
before(:all) do
|
264
|
+
@moe = User.create(:name => "Moe", :age => 46, :wealth => 20)
|
265
|
+
@larry = User.create(:name => "Larry", :age => 42, :wealth => 10)
|
266
|
+
@curly = User.create(:name => "Curly", :age => 44, :wealth => 1)
|
267
|
+
end
|
268
|
+
|
269
|
+
after(:all) do
|
270
|
+
[@moe, @larry, @curly].each { |stooge| stooge.destroy }
|
271
|
+
end
|
272
|
+
|
273
|
+
it "should properly serialize a single resource" do
|
274
|
+
moe_serial = JSON.parse(@moe.to_json)
|
275
|
+
moe_serial['name'].should == "Moe"
|
276
|
+
moe_serial['age'].should == 46
|
277
|
+
moe_serial['wealth'].should == 20
|
278
|
+
end
|
279
|
+
|
280
|
+
it "should properly serialize a resource collection" do
|
281
|
+
stooges = JSON.parse(User.all.to_json)
|
282
|
+
stooges.length.should == 3
|
283
|
+
stooges.each { |stooge| stooge['name'].should_not be_blank }
|
284
|
+
end
|
285
|
+
else
|
286
|
+
it "requires dm-serializer to run serialization tests"
|
287
|
+
end
|
288
|
+
end
|
289
|
+
|
290
|
+
end
|
291
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'spec_helper.rb')
|
2
|
+
|
3
|
+
if COUCHDB_AVAILABLE
|
4
|
+
require 'base64'
|
5
|
+
require 'pathname'
|
6
|
+
|
7
|
+
describe DataMapper::Model do
|
8
|
+
|
9
|
+
before do
|
10
|
+
Object.send(:remove_const, :NonCouch) if defined?(NonCouch)
|
11
|
+
class ::NonCouch
|
12
|
+
include DataMapper::Resource
|
13
|
+
|
14
|
+
property :id, Serial
|
15
|
+
end
|
16
|
+
|
17
|
+
Object.send(:remove_const, :Message) if defined?(Message)
|
18
|
+
class ::Message
|
19
|
+
include DataMapper::Resource
|
20
|
+
def self.default_repository_name
|
21
|
+
:couch
|
22
|
+
end
|
23
|
+
|
24
|
+
property :content, String
|
25
|
+
end
|
26
|
+
|
27
|
+
@file = File.open(Pathname(__FILE__).dirname.expand_path + "testfile.txt", "r")
|
28
|
+
end
|
29
|
+
|
30
|
+
after do
|
31
|
+
@file.close
|
32
|
+
end
|
33
|
+
|
34
|
+
describe "#add_attachment" do
|
35
|
+
|
36
|
+
it "should add inline attributes to new records" do
|
37
|
+
@message = Message.new
|
38
|
+
@message.add_attachment(@file, :name => 'test.txt')
|
39
|
+
@message.attachments.should == {
|
40
|
+
'test.txt' => {
|
41
|
+
'content_type' => 'text/plain',
|
42
|
+
'data' => Base64.encode64("test string\n").chomp
|
43
|
+
}
|
44
|
+
}
|
45
|
+
end
|
46
|
+
|
47
|
+
it "should upload standalone attachment for existing record" do
|
48
|
+
@message = Message.new(:content => 'test message')
|
49
|
+
@message.save.should be_true
|
50
|
+
@message.add_attachment(@file, :name => 'test.txt')
|
51
|
+
@message.attachments['test.txt']['stub'].should be_true
|
52
|
+
@message.attachments['test.txt']['content_type'].should == 'text/plain'
|
53
|
+
@message.attachments['test.txt']['data'].should be_nil
|
54
|
+
@message.destroy.should be_true
|
55
|
+
end
|
56
|
+
|
57
|
+
it "should have meta data on load" do
|
58
|
+
pending("No CouchDB connection.") if @no_connection
|
59
|
+
@message = Message.new
|
60
|
+
@message.add_attachment(@file, :name => 'test.txt')
|
61
|
+
@message.save.should be_true
|
62
|
+
@message.reload
|
63
|
+
@message.attachments['test.txt']['stub'].should be_true
|
64
|
+
@message.attachments['test.txt']['content_type'].should == 'text/plain'
|
65
|
+
@message.attachments['test.txt']['data'].should be_nil
|
66
|
+
@message.destroy.should be_true
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
70
|
+
|
71
|
+
|
72
|
+
describe "#delete_attachment" do
|
73
|
+
|
74
|
+
it "should remove unsaved attachments" do
|
75
|
+
@message = Message.new
|
76
|
+
@message.add_attachment(@file, :name => 'test.txt')
|
77
|
+
@message.delete_attachment('test.txt').should be_true
|
78
|
+
@message.attachments.should be_nil
|
79
|
+
end
|
80
|
+
|
81
|
+
it "should remove saved attachments" do
|
82
|
+
@message = Message.new
|
83
|
+
@message.add_attachment(@file, :name => 'test.txt')
|
84
|
+
@message.save.should be_true
|
85
|
+
@message.reload
|
86
|
+
@message.attachments.should_not be_nil
|
87
|
+
@message.delete_attachment('test.txt').should be_true
|
88
|
+
@message.attachments.should be_nil
|
89
|
+
@message = Message.get(@message.id)
|
90
|
+
@message.attachments.should be_nil
|
91
|
+
@message.destroy.should be_true
|
92
|
+
end
|
93
|
+
|
94
|
+
end
|
95
|
+
|
96
|
+
|
97
|
+
describe "#get_attachment" do
|
98
|
+
|
99
|
+
it "should return nil when there is not attachment" do
|
100
|
+
@message = Message.new
|
101
|
+
@message.get_attachment('test.txt').should be_nil
|
102
|
+
end
|
103
|
+
|
104
|
+
it "should return attachment data when it exists" do
|
105
|
+
@message = Message.new
|
106
|
+
@message.add_attachment(@file, :name => 'test.txt')
|
107
|
+
@message.save.should be_true
|
108
|
+
@message.reload
|
109
|
+
@message.get_attachment('test.txt').should == "test string\n"
|
110
|
+
@message.destroy.should be_true
|
111
|
+
end
|
112
|
+
|
113
|
+
end
|
114
|
+
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'spec_helper.rb')
|
2
|
+
|
3
|
+
class Viewable
|
4
|
+
include DataMapper::Resource
|
5
|
+
def self.default_repository_name
|
6
|
+
:couch
|
7
|
+
end
|
8
|
+
|
9
|
+
property :name, String
|
10
|
+
property :open, Boolean
|
11
|
+
end
|
12
|
+
|
13
|
+
describe DataMapper::Resource::View do
|
14
|
+
it "should have a view method" do
|
15
|
+
Viewable.should respond_to(:view)
|
16
|
+
end
|
17
|
+
|
18
|
+
it "should store a view when called" do
|
19
|
+
Viewable.view :by_name
|
20
|
+
Viewable.views.keys.should include(:by_name)
|
21
|
+
end
|
22
|
+
|
23
|
+
it "should initialize a new Procedure instance" do
|
24
|
+
proc = Viewable.view :by_name_desc
|
25
|
+
proc.should be_an_instance_of(DataMapper::Resource::View)
|
26
|
+
end
|
27
|
+
|
28
|
+
it "should create a getter method" do
|
29
|
+
Viewable.view :open
|
30
|
+
Viewable.should respond_to(:open)
|
31
|
+
end
|
32
|
+
|
33
|
+
describe "for inherited resources" do
|
34
|
+
before(:all) do
|
35
|
+
Person.auto_migrate!
|
36
|
+
end
|
37
|
+
|
38
|
+
it "should set the correct couchdb types" do
|
39
|
+
Person.couchdb_types.include?(Person).should be_true
|
40
|
+
Person.couchdb_types.include?(Employee).should be_true
|
41
|
+
end
|
42
|
+
|
43
|
+
it "should create views with the correct couchdb type conditions" do
|
44
|
+
Person.views[:by_name].should == {"map"=>"function(doc) { if (doc.couchdb_type == 'Person' || doc.couchdb_type == 'Employee') { emit(doc.name, doc); } }"}
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'spec_helper')
|
2
|
+
|
3
|
+
def load_driver(name, default_uri)
|
4
|
+
return false if ENV['ADAPTER'] != name.to_s
|
5
|
+
|
6
|
+
begin
|
7
|
+
DataMapper.setup(name, ENV["#{name.to_s.upcase}_SPEC_URI"] || default_uri)
|
8
|
+
DataMapper::Repository.adapters[:default] = DataMapper::Repository.adapters[name]
|
9
|
+
true
|
10
|
+
rescue LoadError => e
|
11
|
+
warn "Could not load do_#{name}: #{e}"
|
12
|
+
false
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
ENV['ADAPTER'] ||= 'sqlite3'
|
17
|
+
|
18
|
+
HAS_SQLITE3 = load_driver(:sqlite3, 'sqlite3::memory:')
|
19
|
+
HAS_MYSQL = load_driver(:mysql, 'mysql://localhost/dm_core_test')
|
20
|
+
HAS_POSTGRES = load_driver(:postgres, 'postgres://postgres@localhost/dm_core_test')
|
21
|
+
|
22
|
+
if COUCHDB_AVAILABLE && (HAS_SQLITE3 || HAS_MYSQL || HAS_POSTGRES)
|
23
|
+
class ::User
|
24
|
+
include DataMapper::Resource
|
25
|
+
|
26
|
+
property :id, Serial
|
27
|
+
property :name, String
|
28
|
+
|
29
|
+
repository(:couch) do
|
30
|
+
has n, :posts
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
|
35
|
+
class ::Post
|
36
|
+
include DataMapper::CouchResource
|
37
|
+
|
38
|
+
property :title, String
|
39
|
+
property :body, Text
|
40
|
+
|
41
|
+
def self.default_repository_name
|
42
|
+
:couch
|
43
|
+
end
|
44
|
+
|
45
|
+
repository(:default) do
|
46
|
+
belongs_to :user
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
User.auto_migrate!
|
51
|
+
|
52
|
+
describe DataMapper::Model, "working with couch resources" do
|
53
|
+
before(:all) do
|
54
|
+
@user = User.new(:name => "Jamie")
|
55
|
+
@user.save.should be_true
|
56
|
+
end
|
57
|
+
|
58
|
+
after(:all) do
|
59
|
+
@user.destroy.should be_true
|
60
|
+
Post.all.destroy!.should be_true
|
61
|
+
end
|
62
|
+
|
63
|
+
it "should create resources in couch" do
|
64
|
+
@user.posts.create(:title => "I'm a little teapot", :body => "this is my handle, this is my spout").should be_true
|
65
|
+
Post.first.title.should == "I'm a little teapot"
|
66
|
+
end
|
67
|
+
|
68
|
+
it "should find child elements" do
|
69
|
+
@post = Post.first
|
70
|
+
@user.posts.should include(@post)
|
71
|
+
@user.posts.length.should == 1
|
72
|
+
end
|
73
|
+
|
74
|
+
|
75
|
+
end
|
76
|
+
end
|