rdf-ldp 0.9.2 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,152 @@
1
+ require 'rdf/ldp/spec/direct_container'
2
+
3
+ shared_examples 'an IndirectContainer' do
4
+ it_behaves_like 'a DirectContainer'
5
+
6
+ shared_context 'with a relation' do
7
+ before do
8
+ subject.create(StringIO.new(graph.dump(:ntriples)),
9
+ 'application/n-triples')
10
+ end
11
+
12
+ let(:graph) { RDF::Graph.new << inserted_content_statement }
13
+ let(:relation_predicate) { RDF::Vocab::DC.creator }
14
+
15
+ let(:inserted_content_statement) do
16
+ RDF::Statement(uri,
17
+ RDF::Vocab::LDP.insertedContentRelation,
18
+ relation_predicate)
19
+ end
20
+ end
21
+
22
+ describe '#inserted_content_relation' do
23
+ it 'returns a uri' do
24
+ subject.create(StringIO.new, 'application/n-triples')
25
+ expect(subject.inserted_content_relation).to be_a RDF::URI
26
+ end
27
+
28
+ context 'with a relation' do
29
+ include_context 'with a relation'
30
+
31
+ it 'gives the relation' do
32
+ expect(subject.inserted_content_relation).to eq relation_predicate
33
+ end
34
+
35
+ it 'raises an error when more than one exists' do
36
+ new_statement = inserted_content_statement.clone
37
+ new_statement.object = RDF::Vocab::DC.relation
38
+ subject.graph << new_statement
39
+ expect { subject.inserted_content_relation }
40
+ .to raise_error RDF::LDP::NotAcceptable
41
+ end
42
+ end
43
+ end
44
+
45
+ describe '#add' do
46
+ include_context 'with a relation'
47
+
48
+ subject { described_class.new(uri, repo) }
49
+
50
+ let(:repo) { RDF::Repository.new }
51
+ let(:resource_uri) { RDF::URI('http://example.org/too-ticky') }
52
+ let(:contained_resource) { RDF::LDP::RDFSource.new(resource_uri, repo) }
53
+
54
+ context 'when no derived URI is found' do
55
+ it 'raises NotAcceptable' do
56
+ expect { subject.add(contained_resource) }
57
+ .to raise_error RDF::LDP::NotAcceptable
58
+ end
59
+
60
+ it 'does not create the resource' do
61
+ begin; subject.add(contained_resource); rescue; end
62
+ expect(contained_resource).not_to exist
63
+ end
64
+ end
65
+
66
+ context 'with expected predicate' do
67
+ before { contained_resource.graph << statement }
68
+
69
+ let(:target_uri) { contained_resource.to_uri / '#me' }
70
+
71
+ let(:statement) do
72
+ RDF::Statement(contained_resource.to_uri,
73
+ relation_predicate,
74
+ target_uri)
75
+ end
76
+
77
+ it 'when membership resource does not exist raises NotAcceptable' do
78
+ new_resource = described_class.new(uri / 'new', repo)
79
+ expect { new_resource.add(contained_resource) }
80
+ .to raise_error RDF::LDP::NotAcceptable
81
+ end
82
+
83
+ context 'when the container exists' do
84
+ it 'adds membership triple' do
85
+ subject.add(contained_resource)
86
+ expect(subject.graph.statements)
87
+ .to include RDF::Statement(subject.to_uri,
88
+ subject.membership_predicate,
89
+ target_uri)
90
+ end
91
+
92
+ it 'for multiple predicates raises NotAcceptable' do
93
+ new_statement = statement.clone
94
+ new_statement.object = contained_resource.to_uri / '#you'
95
+ contained_resource.graph << new_statement
96
+ expect { subject.add(contained_resource) }
97
+ .to raise_error RDF::LDP::NotAcceptable
98
+ end
99
+
100
+ it 'for an LDP-NR raises NotAcceptable' do
101
+ nr_resource = RDF::LDP::NonRDFSource.new(resource_uri, repo)
102
+ expect { subject.add(nr_resource) }
103
+ .to raise_error RDF::LDP::NotAcceptable
104
+ end
105
+
106
+ context 'with membership resource' do
107
+ before do
108
+ subject.graph
109
+ .delete([uri, RDF::Vocab::LDP.membershipResource, nil])
110
+ subject.graph << RDF::Statement(uri,
111
+ RDF::Vocab::LDP.membershipResource,
112
+ membership_resource)
113
+ end
114
+
115
+ let(:membership_resource) { uri }
116
+
117
+ it 'raises error when resource does not exist' do
118
+ new_resource = described_class.new(uri / 'new', repo)
119
+ expect { new_resource.add(contained_resource) }
120
+ .to raise_error RDF::LDP::NotAcceptable
121
+ end
122
+
123
+ context 'when the membership resource is not in the server' do
124
+ let(:membership_resource) { uri / '#me' }
125
+
126
+ it 'adds membership triple to container' do
127
+ contained_resource.create(StringIO.new, 'application/n-triples')
128
+ subject.add(contained_resource)
129
+
130
+ expect(subject.graph.statements)
131
+ .to include RDF::Statement(membership_resource,
132
+ subject.membership_predicate,
133
+ target_uri)
134
+ end
135
+
136
+ it 'removes membership triple to container' do
137
+ contained_resource.create(StringIO.new, 'application/n-triples')
138
+
139
+ subject.add(contained_resource)
140
+ subject.remove(contained_resource)
141
+
142
+ expect(subject.graph.statements)
143
+ .not_to include RDF::Statement(membership_resource,
144
+ subject.membership_predicate,
145
+ target_uri)
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,138 @@
1
+ require 'rdf/ldp/spec/resource'
2
+
3
+ shared_examples 'a NonRDFSource' do
4
+ it_behaves_like 'a Resource'
5
+
6
+ subject { described_class.new(uri) }
7
+ let(:uri) { RDF::URI 'http://example.org/moomin' }
8
+
9
+ let(:contents) { StringIO.new('mummi') }
10
+
11
+ after { subject.destroy }
12
+
13
+ describe '#non_rdf_source?' do
14
+ it { is_expected.to be_non_rdf_source }
15
+ end
16
+
17
+ describe '#create' do
18
+ it 'writes the input to body' do
19
+ subject.create(contents, 'text/plain')
20
+ contents.rewind
21
+ expect(subject.to_response.each.to_a).to eq contents.each.to_a
22
+ end
23
+
24
+ it 'sets #content_type' do
25
+ expect { subject.create(StringIO.new(''), 'text/plain') }
26
+ .to change { subject.content_type }.to('text/plain')
27
+ end
28
+
29
+ it 'persists to resource' do
30
+ repo = RDF::Repository.new
31
+ saved = described_class.new(uri, repo)
32
+
33
+ saved.create(contents, 'text/plain')
34
+ contents.rewind
35
+
36
+ loaded = RDF::LDP::Resource.find(uri, repo)
37
+ expect(loaded.to_response.each.to_a).to eq contents.each.to_a
38
+ end
39
+
40
+ it 'creates an LDP::RDFSource' do
41
+ repo = RDF::Repository.new
42
+ saved = described_class.new(uri, repo)
43
+ description = RDF::LDP::RDFSource.new(subject.description_uri, repo)
44
+
45
+ expect { saved.create(contents, 'text/plain') }
46
+ .to change { description.exists? }.from(false).to(true)
47
+ end
48
+ end
49
+
50
+ describe '#update' do
51
+ before { subject.create(contents, 'text/plain') }
52
+
53
+ it 'writes the input to body' do
54
+ new_contents = StringIO.new('snorkmaiden')
55
+ expect { subject.update(new_contents, 'text/plain') }
56
+ .to change { subject.to_response.to_a }
57
+ .from(a_collection_containing_exactly('mummi'))
58
+ .to(a_collection_containing_exactly('snorkmaiden'))
59
+ end
60
+
61
+ it 'updates #content_type' do
62
+ expect { subject.update(contents, 'text/prs.moomin') }
63
+ .to change { subject.content_type }
64
+ .from('text/plain').to('text/prs.moomin')
65
+ end
66
+ end
67
+
68
+ describe '#description' do
69
+ it 'is not found' do
70
+ expect { subject.description }.to raise_error RDF::LDP::NotFound
71
+ end
72
+
73
+ context 'when it exists' do
74
+ before { subject.create(StringIO.new(''), 'text/plain') }
75
+
76
+ it 'is an RDFSource' do
77
+ expect(subject.description).to be_rdf_source
78
+ end
79
+
80
+ it 'is the description uri' do
81
+ expect(subject.description.to_uri).to eq subject.description_uri
82
+ end
83
+ end
84
+ end
85
+
86
+ describe '#description_uri' do
87
+ it 'is a uri' do
88
+ expect(subject.description_uri).to be_a RDF::URI
89
+ end
90
+ end
91
+
92
+ describe '#storage' do
93
+ it 'sets a default storage adapter' do
94
+ expect(subject.storage).to be_a RDF::LDP::NonRDFSource::FileStorageAdapter
95
+ end
96
+
97
+ it 'explicitly sets a storage adapter' do
98
+ class DummyAdapter < RDF::LDP::NonRDFSource::FileStorageAdapter
99
+ end
100
+
101
+ dummy_subject = described_class.new(uri, nil, DummyAdapter)
102
+ expect(dummy_subject.storage).to be_a DummyAdapter
103
+ end
104
+ end
105
+
106
+ describe '#to_response' do
107
+ it 'gives an empty response if it is new' do
108
+ expect(subject.to_response.to_a).to eq []
109
+ end
110
+
111
+ it 'does not create a non-existant file' do
112
+ subject.to_response
113
+ expect(subject.storage.send(:file_exists?)).to be false
114
+ end
115
+ end
116
+
117
+ describe '#destroy' do
118
+ before { subject.create(contents, 'text/plain') }
119
+
120
+ it 'deletes the content' do
121
+ expect { subject.destroy }
122
+ .to change { subject.to_response.to_a }
123
+ .from(a_collection_containing_exactly('mummi')).to([])
124
+ end
125
+
126
+ it 'marks resource as destroyed' do
127
+ expect { subject.destroy }
128
+ .to change { subject.destroyed? }.from(false).to(true)
129
+ end
130
+ end
131
+
132
+ describe '#content_type' do
133
+ it 'sets and gets a content_type' do
134
+ expect { subject.content_type = 'text/plain' }
135
+ .to change { subject.content_type }.to('text/plain')
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,370 @@
1
+ require 'rdf/ldp/spec/resource'
2
+
3
+ shared_examples 'an RDFSource' do
4
+ it_behaves_like 'a Resource'
5
+
6
+ let(:uri) { RDF::URI('http://ex.org/moomin') }
7
+ subject { described_class.new('http://ex.org/moomin') }
8
+ it { is_expected.to be_rdf_source }
9
+ it { is_expected.not_to be_non_rdf_source }
10
+
11
+ describe '#parse_graph' do
12
+ it 'raises UnsupportedMediaType if no reader found' do
13
+ expect { subject.send(:parse_graph, StringIO.new('graph'), 'text/fake') }
14
+ .to raise_error RDF::LDP::UnsupportedMediaType
15
+ end
16
+
17
+ it 'raises BadRequest if graph cannot be parsed' do
18
+ expect do
19
+ subject.send(:parse_graph,
20
+ StringIO.new('graph'),
21
+ 'application/n-triples')
22
+ end.to raise_error RDF::LDP::BadRequest
23
+ end
24
+
25
+ describe 'parsing the graph' do
26
+ let(:graph) { RDF::Repository.new }
27
+
28
+ before do
29
+ graph << RDF::Statement(RDF::URI('http://ex.org/moomin'),
30
+ RDF.type,
31
+ RDF::Vocab::FOAF.Person,
32
+ graph_name: subject.subject_uri)
33
+
34
+ 10.times do
35
+ graph << RDF::Statement(RDF::Node.new,
36
+ RDF::Vocab::DC.creator,
37
+ RDF::Node.new,
38
+ graph_name: subject.subject_uri)
39
+ end
40
+ end
41
+
42
+ it 'parses turtle' do
43
+ expect(subject.send(:parse_graph,
44
+ StringIO.new(graph.dump(:ttl)),
45
+ 'text/turtle'))
46
+ .to be_isomorphic_with graph
47
+ end
48
+
49
+ it 'parses ntriples' do
50
+ expect(subject.send(:parse_graph,
51
+ StringIO.new(graph.dump(:ntriples)),
52
+ 'application/n-triples'))
53
+ .to be_isomorphic_with graph
54
+ end
55
+ end
56
+ end
57
+
58
+ describe '#etag' do
59
+ before do
60
+ subject.graph << statement
61
+ other.graph << statement
62
+ end
63
+
64
+ let(:other) { described_class.new(RDF::URI('http://ex.org/blah')) }
65
+
66
+ let(:statement) do
67
+ RDF::Statement(RDF::URI('http://ex.org/m'),
68
+ RDF::Vocab::DC.title,
69
+ 'moomin')
70
+ end
71
+
72
+ it 'is the same for equal graphs' do
73
+ expect(subject.etag).to eq other.etag
74
+ end
75
+
76
+ xit 'is different for different graphs' do
77
+ subject.graph <<
78
+ RDF::Statement(RDF::Node.new, RDF::Vocab::DC.title, 'mymble')
79
+ expect(subject.etag).not_to eq other.etag
80
+ end
81
+ end
82
+
83
+ describe '#create' do
84
+ let(:subject) { described_class.new(RDF::URI('http://ex.org/m')) }
85
+ let(:graph) { RDF::Graph.new }
86
+
87
+ it 'does not create when graph fails to parse' do
88
+ begin
89
+ subject.create(StringIO.new(graph.dump(:ttl)), 'text/moomin')
90
+ rescue; end
91
+
92
+ expect(subject).not_to exist
93
+ end
94
+
95
+ it 'returns itself' do
96
+ expect(subject.create(StringIO.new(graph.dump(:ttl)), 'text/turtle'))
97
+ .to eq subject
98
+ end
99
+
100
+ it 'yields a transaction' do
101
+ expect do |b|
102
+ subject.create(StringIO.new(graph.dump(:ttl)), 'text/turtle', &b)
103
+ end.to yield_with_args(be_kind_of(RDF::Transaction))
104
+ end
105
+
106
+ it 'interprets NULL URI as this resource' do
107
+ graph << RDF::Statement(RDF::URI.new, RDF::Vocab::DC.title, 'moomin')
108
+
109
+ created =
110
+ subject.create(StringIO.new(graph.dump(:ttl)), 'text/turtle').graph
111
+
112
+ expect(created)
113
+ .to have_statement RDF::Statement(subject.subject_uri,
114
+ RDF::Vocab::DC.title,
115
+ 'moomin')
116
+ end
117
+
118
+ it 'interprets Relative URIs as this based on this resource' do
119
+ graph << RDF::Statement(subject.subject_uri,
120
+ RDF::Vocab::DC.isPartOf,
121
+ RDF::URI('#moomin'))
122
+
123
+ created =
124
+ subject.create(StringIO.new(graph.dump(:ttl)), 'text/turtle').graph
125
+
126
+ expect(created)
127
+ .to have_statement RDF::Statement(subject.subject_uri,
128
+ RDF::Vocab::DC.isPartOf,
129
+ subject.subject_uri / '#moomin')
130
+ end
131
+ end
132
+
133
+ describe '#update' do
134
+ let(:statement) do
135
+ RDF::Statement(subject.subject_uri, RDF::Vocab::DC.title, 'moomin')
136
+ end
137
+
138
+ let(:graph) { RDF::Graph.new << statement }
139
+ let(:content_type) { 'text/turtle' }
140
+
141
+ shared_examples 'updating rdf_sources' do
142
+ it 'changes the response' do
143
+ expect { subject.update(StringIO.new(graph.dump(:ttl)), content_type) }
144
+ .to change { subject.to_response }
145
+ end
146
+
147
+ it 'changes etag' do
148
+ expect { subject.update(StringIO.new(graph.dump(:ttl)), content_type) }
149
+ .to change { subject.etag }
150
+ end
151
+
152
+ it 'yields a transaction' do
153
+ expect do |b|
154
+ subject.update(StringIO.new(graph.dump(:ttl)), content_type, &b)
155
+ end.to yield_with_args(be_kind_of(RDF::Transaction))
156
+ end
157
+
158
+ context 'with bad media type' do
159
+ it 'raises UnsupportedMediaType' do
160
+ graph_io = StringIO.new(graph.dump(:ttl))
161
+
162
+ expect { subject.update(graph_io, 'text/moomin') }
163
+ .to raise_error RDF::LDP::UnsupportedMediaType
164
+ end
165
+
166
+ it 'does not update #last_modified' do
167
+ modified = subject.last_modified
168
+ begin
169
+ subject.update(StringIO.new(graph.dump(:ttl)), 'text/moomin')
170
+ rescue; end
171
+
172
+ expect(subject.last_modified).to eq modified
173
+ end
174
+ end
175
+ end
176
+
177
+ include_examples 'updating rdf_sources'
178
+
179
+ context 'when it exists' do
180
+ before { subject.create(StringIO.new, 'application/n-triples') }
181
+
182
+ include_examples 'updating rdf_sources'
183
+ end
184
+ end
185
+
186
+ describe '#patch' do
187
+ it 'raises UnsupportedMediaType when no media type is given' do
188
+ expect { subject.request(:patch, 200, {}, {}) }
189
+ .to raise_error RDF::LDP::UnsupportedMediaType
190
+ end
191
+
192
+ it 'gives PreconditionFailed when trying to update with wrong Etag' do
193
+ env = { 'HTTP_IF_MATCH' => 'not an Etag' }
194
+ expect { subject.request(:PATCH, 200, { 'abc' => 'def' }, env) }
195
+ .to raise_error RDF::LDP::PreconditionFailed
196
+ end
197
+
198
+ context 'ldpatch' do
199
+ it 'raises BadRequest when invalid document' do
200
+ env = { 'CONTENT_TYPE' => 'text/ldpatch',
201
+ 'rack.input' => StringIO.new('---invalid---') }
202
+ expect { subject.request(:patch, 200, {}, env) }
203
+ .to raise_error RDF::LDP::BadRequest
204
+ end
205
+
206
+ it 'handles patch' do
207
+ statement =
208
+ RDF::Statement(subject.subject_uri, RDF::Vocab::FOAF.name, 'Moomin')
209
+ patch = '@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .' \
210
+ "\n\nAdd { #{statement.subject.to_base} " \
211
+ "#{statement.predicate.to_base} #{statement.object.to_base} } ."
212
+ env = { 'CONTENT_TYPE' => 'text/ldpatch',
213
+ 'rack.input' => StringIO.new(patch) }
214
+
215
+ expect { subject.request(:patch, 200, {}, env) }
216
+ .to change { subject.graph.statements.to_a }
217
+ .to(contain_exactly(statement))
218
+ end
219
+ end
220
+
221
+ context 'sparql update' do
222
+ it 'raises BadRequest when invalid document' do
223
+ env = { 'CONTENT_TYPE' => 'application/sparql-update',
224
+ 'rack.input' => StringIO.new('---invalid---') }
225
+
226
+ expect { subject.request(:patch, 200, {}, env) }
227
+ .to raise_error RDF::LDP::BadRequest
228
+ end
229
+
230
+ it 'runs sparql update' do
231
+ update = "INSERT DATA { #{subject.subject_uri.to_base} "\
232
+ "#{RDF::Vocab::DC.title.to_base} 'moomin' . }"
233
+
234
+ env = { 'CONTENT_TYPE' => 'application/sparql-update',
235
+ 'rack.input' => StringIO.new(update) }
236
+
237
+ expect { subject.request(:patch, 200, {}, env) }
238
+ .to change { subject.graph.count }.from(0).to(1)
239
+ end
240
+ end
241
+ end
242
+
243
+ describe '#graph' do
244
+ it 'has a graph' do
245
+ expect(subject.graph).to be_a RDF::Enumerable
246
+ end
247
+ end
248
+
249
+ describe '#subject_uri' do
250
+ let(:uri) { RDF::URI('http://ex.org/moomin') }
251
+
252
+ it 'has a uri getter' do
253
+ expect(subject.subject_uri).to eq uri
254
+ end
255
+
256
+ it 'aliases to #to_uri' do
257
+ expect(subject.to_uri).to eq uri
258
+ end
259
+ end
260
+
261
+ describe '#to_response' do
262
+ it 'gives the graph minus context' do
263
+ expect(subject.to_response.graph_name).to eq nil
264
+ end
265
+ end
266
+
267
+ describe '#request' do
268
+ context 'with :GET' do
269
+ it 'gives the subject' do
270
+ expect(subject.request(:GET, 200, { 'abc' => 'def' }, {}))
271
+ .to contain_exactly(200, a_hash_including('abc' => 'def'), subject)
272
+ end
273
+
274
+ it 'does not call the graph' do
275
+ expect(subject).not_to receive(:graph)
276
+ subject.request(:GET, 200, { 'abc' => 'def' }, {})
277
+ end
278
+
279
+ it 'returns 410 GONE when destroyed' do
280
+ allow(subject).to receive(:destroyed?).and_return true
281
+ expect { subject.request(:GET, 200, { 'abc' => 'def' }, {}) }
282
+ .to raise_error RDF::LDP::Gone
283
+ end
284
+ end
285
+
286
+ context 'with :DELETE' do
287
+ before { subject.create(StringIO.new, 'application/n-triples') }
288
+
289
+ it 'returns 204' do
290
+ expect(subject.request(:DELETE, 200, {}, {}).first).to eq 204
291
+ end
292
+
293
+ it 'returns an empty body' do
294
+ expect(subject.request(:DELETE, 200, {}, {}).last)
295
+ .to be_empty
296
+ end
297
+
298
+ it 'marks resource as destroyed' do
299
+ expect { subject.request(:DELETE, 200, {}, {}) }
300
+ .to change { subject.destroyed? }.from(false).to(true)
301
+ end
302
+ end
303
+
304
+ context 'with :PUT',
305
+ if: described_class.private_method_defined?(:put) do
306
+ let(:graph) { RDF::Graph.new }
307
+ let(:env) do
308
+ { 'rack.input' => StringIO.new(graph.dump(:ntriples)),
309
+ 'CONTENT_TYPE' => 'application/n-triples' }
310
+ end
311
+
312
+ it 'creates the resource' do
313
+ expect { subject.request(:PUT, 200, { 'abc' => 'def' }, env) }
314
+ .to change { subject.exists? }.from(false).to(true)
315
+ end
316
+
317
+ it 'responds 201' do
318
+ expect(subject.request(:PUT, 200, { 'abc' => 'def' }, env).first)
319
+ .to eq 201
320
+ end
321
+
322
+ it 'returns the etag' do
323
+ expect(subject.request(:PUT, 200, { 'abc' => 'def' }, env)[1]['ETag'])
324
+ .to eq subject.etag
325
+ end
326
+
327
+ context 'when subject exists' do
328
+ before { subject.create(StringIO.new, 'application/n-triples') }
329
+
330
+ it 'responds 200' do
331
+ expect(subject.request(:PUT, 200, { 'abc' => 'def' }, env))
332
+ .to contain_exactly(200, a_hash_including('abc' => 'def'), subject)
333
+ end
334
+
335
+ it 'replaces the graph with the input' do
336
+ graph <<
337
+ RDF::Statement(subject.subject_uri, RDF::Vocab::DC.title, 'moomin')
338
+ expect { subject.request(:PUT, 200, { 'abc' => 'def' }, env) }
339
+ .to change { subject.graph.statements.count }.to(1)
340
+ end
341
+
342
+ it 'updates the etag' do
343
+ graph <<
344
+ RDF::Statement(subject.subject_uri, RDF::Vocab::DC.title, 'moomin')
345
+ expect { subject.request(:PUT, 200, { 'abc' => 'def' }, env) }
346
+ .to change { subject.etag }
347
+ end
348
+
349
+ it 'returns the etag' do
350
+ expect(subject.request(:PUT, 200, { 'abc' => 'def' }, env)[1]['ETag'])
351
+ .to eq subject.etag
352
+ end
353
+
354
+ it 'gives PreconditionFailed when trying to update with wrong Etag' do
355
+ env['HTTP_IF_MATCH'] = 'not an Etag'
356
+ expect { subject.request(:PUT, 200, { 'abc' => 'def' }, env) }
357
+ .to raise_error RDF::LDP::PreconditionFailed
358
+ end
359
+
360
+ it 'succeeds when giving correct Etag' do
361
+ graph <<
362
+ RDF::Statement(subject.subject_uri, RDF::Vocab::DC.title, 'moomin')
363
+ env['HTTP_IF_MATCH'] = subject.etag
364
+ expect { subject.request(:PUT, 200, { 'abc' => 'def' }, env) }
365
+ .to change { subject.graph.statements.count }
366
+ end
367
+ end
368
+ end
369
+ end
370
+ end