ape 1.0.0 → 1.5.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.
- data/LICENSE +1 -1
- data/README +22 -8
- data/Rakefile +66 -0
- data/bin/ape_server +3 -3
- data/lib/ape.rb +131 -937
- data/lib/ape/atomURI.rb +1 -1
- data/lib/ape/authent.rb +11 -17
- data/lib/ape/categories.rb +8 -7
- data/lib/ape/collection.rb +3 -7
- data/lib/ape/crumbs.rb +1 -1
- data/lib/ape/entry.rb +1 -1
- data/lib/ape/escaper.rb +1 -1
- data/lib/ape/feed.rb +26 -14
- data/lib/ape/handler.rb +8 -3
- data/lib/ape/html.rb +1 -1
- data/lib/ape/invoker.rb +1 -1
- data/lib/ape/invokers/deleter.rb +1 -1
- data/lib/ape/invokers/getter.rb +1 -1
- data/lib/ape/invokers/poster.rb +1 -1
- data/lib/ape/invokers/putter.rb +1 -1
- data/lib/ape/names.rb +1 -1
- data/lib/ape/print_writer.rb +4 -6
- data/lib/ape/reporter.rb +156 -0
- data/lib/ape/reporters/atom_reporter.rb +51 -0
- data/lib/ape/reporters/atom_template.eruby +38 -0
- data/lib/ape/reporters/html_reporter.rb +53 -0
- data/lib/ape/reporters/html_template.eruby +62 -0
- data/lib/ape/reporters/text_reporter.rb +37 -0
- data/lib/ape/samples.rb +30 -51
- data/lib/ape/server.rb +16 -4
- data/lib/ape/service.rb +1 -1
- data/lib/ape/util.rb +67 -0
- data/lib/ape/validator.rb +85 -57
- data/lib/ape/validator_dsl.rb +40 -0
- data/lib/ape/validators/entry_posts_validator.rb +226 -0
- data/lib/ape/validators/media_linkage_validator.rb +78 -0
- data/lib/ape/validators/media_posts_validator.rb +104 -0
- data/lib/ape/validators/sanitization_validator.rb +57 -0
- data/lib/ape/validators/schema_validator.rb +57 -0
- data/lib/ape/validators/service_document_validator.rb +64 -0
- data/lib/ape/validators/sorting_validator.rb +87 -0
- data/lib/ape/version.rb +1 -1
- data/{lib/ape/samples → samples}/atom_schema.txt +0 -0
- data/{lib/ape/samples → samples}/basic_entry.eruby +4 -4
- data/{lib/ape/samples → samples}/categories_schema.txt +0 -0
- data/{lib/ape/samples → samples}/mini_entry.eruby +0 -0
- data/{lib/ape/samples → samples}/service_schema.txt +0 -0
- data/{lib/ape/samples → samples}/unclean_xhtml_entry.eruby +0 -0
- data/test/test_helper.rb +33 -1
- data/test/unit/ape_test.rb +92 -0
- data/test/unit/authent_test.rb +2 -2
- data/test/unit/reporter_test.rb +102 -0
- data/test/unit/samples_test.rb +2 -2
- data/test/unit/validators_test.rb +50 -0
- data/{lib/ape/layout → web}/ape.css +5 -1
- data/{lib/ape/layout → web}/ape_logo.png +0 -0
- data/{lib/ape/layout → web}/index.html +2 -5
- data/{lib/ape/layout → web}/info.png +0 -0
- metadata +108 -56
- data/CHANGELOG +0 -1
- data/Manifest +0 -45
- data/ape.gemspec +0 -57
- data/scripts/go.rb +0 -29
@@ -0,0 +1,104 @@
|
|
1
|
+
module Ape
|
2
|
+
class MediaPostsValidator < Validator
|
3
|
+
disabled
|
4
|
+
requires_presence_of :media_collection
|
5
|
+
|
6
|
+
def validate(opts = {})
|
7
|
+
reporter.info(self, "TESTING: Posting to media collection.")
|
8
|
+
media_collection = opts[:media_collection]
|
9
|
+
reporter.info(self, "Will use collection '#{media_collection.title}' for media creation.")
|
10
|
+
|
11
|
+
# * Post a picture to the media collection
|
12
|
+
#
|
13
|
+
poster = Poster.new(media_collection.href, @authent)
|
14
|
+
if poster.last_error
|
15
|
+
reporter.error(self, "Unacceptable URI for '#{media_coll.title}' collection: " +
|
16
|
+
poster.last_error)
|
17
|
+
return
|
18
|
+
end
|
19
|
+
|
20
|
+
name = 'Post image to media collection'
|
21
|
+
|
22
|
+
# ask it to use this in the URI
|
23
|
+
slug_num = rand(100000)
|
24
|
+
slug = "apix-#{slug_num}"
|
25
|
+
slug_re = %r{apix.?#{slug_num}}
|
26
|
+
poster.set_header('Slug', slug)
|
27
|
+
|
28
|
+
#poster.set_header('Slug', slug)
|
29
|
+
worked = poster.post('image/jpeg', Samples.picture)
|
30
|
+
reporter.save_dialog(name, poster)
|
31
|
+
if !worked
|
32
|
+
reporter.error(self, "Can't POST picture to media collection: #{poster.last_error}",
|
33
|
+
name)
|
34
|
+
return
|
35
|
+
end
|
36
|
+
|
37
|
+
reporter.success(self, "Post of image file reported success, media link location: " +
|
38
|
+
"#{poster.header('Location')}", name)
|
39
|
+
|
40
|
+
# * Retrieve the media link entry
|
41
|
+
mle_uri = poster.header('Location')
|
42
|
+
|
43
|
+
media_link_entry = check_resource(mle_uri, 'Retrieval of media link entry', Names::AtomMediaType)
|
44
|
+
return unless media_link_entry
|
45
|
+
|
46
|
+
if media_link_entry.last_error
|
47
|
+
reporter.error(self, "Can't proceed with media-post testing.")
|
48
|
+
return
|
49
|
+
end
|
50
|
+
|
51
|
+
# * See if the <content src= is there and usable
|
52
|
+
begin
|
53
|
+
media_link_entry = Entry.new(media_link_entry.body, mle_uri)
|
54
|
+
rescue REXML::ParseException
|
55
|
+
prob = $!.to_s.gsub(/\n/, '<br/>')
|
56
|
+
reporter.error(self, "Media link entry is not well-formed: #{prob}")
|
57
|
+
return
|
58
|
+
end
|
59
|
+
content_src = media_link_entry.content_src
|
60
|
+
if (!content_src) || (content_src == "")
|
61
|
+
reporter.error(self, "Media link entry has no content@src pointer to media resource.")
|
62
|
+
return
|
63
|
+
end
|
64
|
+
|
65
|
+
# see if slug was used in media URI
|
66
|
+
if content_src =~ slug_re
|
67
|
+
reporter.success(self, "Client-provided slug '#{slug}' was used in Media Resource URI.")
|
68
|
+
else
|
69
|
+
reporter.warning(self, "Client-provided slug '#{slug}' not used in Media Resource URI.")
|
70
|
+
end
|
71
|
+
|
72
|
+
media_link_id = media_link_entry.child_content('id')
|
73
|
+
|
74
|
+
name = 'Retrieval of media resource'
|
75
|
+
picture = check_resource(content_src, name, 'image/jpeg')
|
76
|
+
return unless picture
|
77
|
+
|
78
|
+
if picture.body == Samples.picture
|
79
|
+
reporter.success(self, "Media resource was apparently stored and retrieved properly.")
|
80
|
+
else
|
81
|
+
reporter.warning(self, "Media resource differs from posted picture")
|
82
|
+
end
|
83
|
+
|
84
|
+
# * Delete the media link entry
|
85
|
+
return unless delete_entry(media_link_entry, 'Deletion of media link entry')
|
86
|
+
|
87
|
+
# * media link entry still in feed?
|
88
|
+
still_there = find_entry(media_collection.href, "media collection", media_link_id)
|
89
|
+
if still_there.class != String
|
90
|
+
reporter.error(self, "Media link entry is still in collection post-deletion.")
|
91
|
+
else
|
92
|
+
reporter.success(self, "Media link entry no longer in feed.")
|
93
|
+
end
|
94
|
+
|
95
|
+
# is the resource there any more?
|
96
|
+
name = 'Check Media Resource deletion'
|
97
|
+
if check_resource(content_src, name, 'image/jpeg', false)
|
98
|
+
reporter.error(self, "Media resource still there after media link entry deletion.")
|
99
|
+
else
|
100
|
+
reporter.success(self, "Media resource no longer fetchable.")
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module Ape
|
2
|
+
class SanitizationValidator < Validator
|
3
|
+
disabled
|
4
|
+
requires_presence_of :entry_collection
|
5
|
+
|
6
|
+
def validate(opts = {})
|
7
|
+
reporter.info(self, "TESTING: Content sanitization")
|
8
|
+
coll = opts[:entry_collection]
|
9
|
+
|
10
|
+
poster = Poster.new(coll.href, @authent)
|
11
|
+
name = 'Posting unclean XHTML'
|
12
|
+
worked = poster.post(Names::AtomEntryMediaType, Samples.unclean_xhtml_entry)
|
13
|
+
if !worked
|
14
|
+
reporter.save_dialog(name, poster)
|
15
|
+
reporter.error(self, "Can't POST unclean XHTML: #{poster.last_error}", name)
|
16
|
+
return
|
17
|
+
end
|
18
|
+
|
19
|
+
location = poster.header('Location')
|
20
|
+
name = "Retrieval of unclean XHTML entry"
|
21
|
+
entry = check_resource(location, name, Names::AtomMediaType)
|
22
|
+
return unless entry
|
23
|
+
|
24
|
+
begin
|
25
|
+
entry = Entry.new(entry.body, location)
|
26
|
+
rescue REXML::ParseException
|
27
|
+
prob = $!.to_s.gsub(/\n/, '<br/>')
|
28
|
+
reporter.error(self, "New entry is not well-formed: #{prob}")
|
29
|
+
return
|
30
|
+
end
|
31
|
+
|
32
|
+
no_problem = true
|
33
|
+
patterns = {
|
34
|
+
'//xhtml:script' => "Published entry retains xhtml:script element.",
|
35
|
+
'//*[@background]' => "Published entry retains 'background' attribute.",
|
36
|
+
'//*[@style]' => "Published entry retains 'style' attribute.",
|
37
|
+
|
38
|
+
}
|
39
|
+
patterns.each { |xp, message|
|
40
|
+
reporter.warning(self, message) unless entry.xpath_match(xp).empty?
|
41
|
+
}
|
42
|
+
|
43
|
+
entry.xpath_match('//xhtml:a').each do |a|
|
44
|
+
if a.attributes['href'] =~ /^([a-zA-Z]+):/
|
45
|
+
if $1 != 'http'
|
46
|
+
no_problem = false
|
47
|
+
reporter.warning(self, "Published entry retains dangerous hyperlink: '#{a.attributes['href']}'.")
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
delete_entry(entry)
|
53
|
+
|
54
|
+
reporter.success(self, "Published entry appears to be sanitized.") if no_problem
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
if RUBY_PLATFORM =~ /java/
|
2
|
+
require 'java'
|
3
|
+
CompactSchemaReader = com.thaiopensource.validate.rng.CompactSchemaReader
|
4
|
+
ValidationDriver = com.thaiopensource.validate.ValidationDriver
|
5
|
+
StringReader = java.io.StringReader
|
6
|
+
StringWriter = java.io.StringWriter
|
7
|
+
InputSource = org.xml.sax.InputSource
|
8
|
+
ErrorHandlerImpl = com.thaiopensource.xml.sax.ErrorHandlerImpl
|
9
|
+
PropertyMapBuilder = com.thaiopensource.util.PropertyMapBuilder
|
10
|
+
ValidateProperty = com.thaiopensource.validate.ValidateProperty
|
11
|
+
end
|
12
|
+
|
13
|
+
module Ape
|
14
|
+
class SchemaValidator < Validator
|
15
|
+
disabled
|
16
|
+
def validate(opts = {})
|
17
|
+
if RUBY_PLATFORM =~ /java/
|
18
|
+
rcn_validate(opts[:schema], opts[:doc].body, opts[:title])
|
19
|
+
else
|
20
|
+
reporter.add(self, :info, "Schema validation is just available building the ape with jruby.")
|
21
|
+
true
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def rnc_validate(schema, text, name, ape)
|
26
|
+
schemaError = StringWriter.new
|
27
|
+
schemaEH = ErrorHandlerImpl.new(schemaError)
|
28
|
+
properties = PropertyMapBuilder.new
|
29
|
+
properties.put(ValidateProperty::ERROR_HANDLER, schemaEH)
|
30
|
+
error = nil
|
31
|
+
driver = ValidationDriver.new(properties.toPropertyMap, CompactSchemaReader.getInstance)
|
32
|
+
if driver.loadSchema(InputSource.new(StringReader.new(schema)))
|
33
|
+
begin
|
34
|
+
if !driver.validate(InputSource.new(StringReader.new(text)))
|
35
|
+
error = schemaError.toString
|
36
|
+
end
|
37
|
+
rescue org.xml.sax.SAXParseException
|
38
|
+
error = $!.to_s.sub(/\n.*$/, '')
|
39
|
+
end
|
40
|
+
else
|
41
|
+
error = schemaError.toString
|
42
|
+
end
|
43
|
+
|
44
|
+
if !error
|
45
|
+
reporter.add(self, :success, "#{name} passed schema validation.")
|
46
|
+
true
|
47
|
+
else
|
48
|
+
# this kind of sucks, but I spent a looong time lost in a maze of twisty
|
49
|
+
# little passages without being able to figure out how to
|
50
|
+
# tell jing what name I'd like to call the InputSource
|
51
|
+
reporter.add(self, :error, "#{name} failed schema validation:\n" + error.gsub('(unknown file):', 'Line '))
|
52
|
+
false
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module Ape
|
2
|
+
class ServiceDocumentValidator < Validator
|
3
|
+
disabled
|
4
|
+
deterministic
|
5
|
+
attr_reader :service_document, :entry_collections, :media_collections
|
6
|
+
|
7
|
+
def validate(opts = {})
|
8
|
+
init_service_document(opts[:uri])
|
9
|
+
raise ValidationError, "service document not found in: #{opts[uri]}" unless @service
|
10
|
+
init_service_collections(opts[:uri])
|
11
|
+
raise ValidationError unless @entry_collections && @media_collections
|
12
|
+
end
|
13
|
+
|
14
|
+
def init_service_document(uri)
|
15
|
+
reporter.info(self, "TESTING: Service document and collections.")
|
16
|
+
name = 'Retrieval of Service Document'
|
17
|
+
service = check_resource(uri, name, Names::AppMediaType)
|
18
|
+
return unless service
|
19
|
+
|
20
|
+
# * XML-parse the service doc
|
21
|
+
text = service.body
|
22
|
+
begin
|
23
|
+
@service = REXML::Document.new(text, { :raw => nil })
|
24
|
+
rescue REXML::ParseException
|
25
|
+
prob = $!.to_s.gsub(/\n/, '<br/>')
|
26
|
+
reporter.error(self, "Service document not well-formed: #{prob}")
|
27
|
+
return
|
28
|
+
end
|
29
|
+
|
30
|
+
# RNC-validate the service doc
|
31
|
+
Validator.instance(:schema, @reporter).validate(:schema => Samples.service_RNC,
|
32
|
+
:title => 'Service doc', :doc => @service)
|
33
|
+
end
|
34
|
+
|
35
|
+
def init_service_collections(uri)
|
36
|
+
# * Do we have collections we can post an entry and a picture to?
|
37
|
+
# the requested_* arguments are the requested collection titles; if
|
38
|
+
# provided, try to match them, otherwise just pick the first listed
|
39
|
+
#
|
40
|
+
begin
|
41
|
+
@service_collections = Service.collections(@service, uri)
|
42
|
+
rescue Exception
|
43
|
+
reporter.error(self, "Couldn't read collections from service doc: #{$!}")
|
44
|
+
return
|
45
|
+
end
|
46
|
+
|
47
|
+
if @service_collections.length > 0
|
48
|
+
reporter.start_list(self, "Found these collections")
|
49
|
+
@service_collections.each do |collection|
|
50
|
+
reporter.list_item("'#{collection.title}' " +
|
51
|
+
"accepts #{collection.accept.join(', ')}")
|
52
|
+
|
53
|
+
if (collection.accept.index(Names::AtomEntryMediaType))
|
54
|
+
@entry_collections ||= []
|
55
|
+
@entry_collections << collection
|
56
|
+
else
|
57
|
+
@media_collections ||= []
|
58
|
+
@media_collections << collection
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
module Ape
|
2
|
+
class SortingValidator < Validator
|
3
|
+
disabled
|
4
|
+
requires_presence_of :entry_collection
|
5
|
+
|
6
|
+
def validate(opts = {})
|
7
|
+
coll = opts[:entry_collection]
|
8
|
+
reporter.info(self, "TESTING: Collection re-ordering after PUT.")
|
9
|
+
|
10
|
+
# We'll post three mini entries to the collection
|
11
|
+
poster = Poster.new(coll.href, @authent)
|
12
|
+
['One', 'Two', 'Three'].each do |num|
|
13
|
+
sleep 2
|
14
|
+
text = Samples.mini_entry.gsub('Mini-1', "Mini #{num}")
|
15
|
+
name = "Posting Mini #{num}"
|
16
|
+
worked = poster.post(Names::AtomEntryMediaType, text)
|
17
|
+
reporter.save_dialog(name, poster)
|
18
|
+
if !worked
|
19
|
+
reporter.error(self, "Can't POST Mini #{name}: #{poster.last_error}", name)
|
20
|
+
return
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# now let's grab the collection & check the order
|
25
|
+
wanted = ['Mini One', 'Mini Two', 'Mini Three']
|
26
|
+
two = nil
|
27
|
+
entries = Feed.read(coll.href, 'Entries with multi-post', reporter)
|
28
|
+
entries.each do |from_feed|
|
29
|
+
want = wanted.pop
|
30
|
+
unless from_feed.child_content('title').index(want)
|
31
|
+
reporter.error(self, "Entries feed out of order after multi-post.")
|
32
|
+
return
|
33
|
+
end
|
34
|
+
two = from_feed if want == 'Mini Two'
|
35
|
+
break if wanted.empty?
|
36
|
+
end
|
37
|
+
reporter.success(self, "Entries correctly ordered after multi-post.")
|
38
|
+
|
39
|
+
# let's update one of them; have to fetch it first to get the ETag
|
40
|
+
link = two.link('edit', self)
|
41
|
+
unless link
|
42
|
+
reporter.error(self, "Can't check entry without edit link, entry id: #{two.get_child('id/text()')}")
|
43
|
+
return
|
44
|
+
end
|
45
|
+
two_resp = check_resource(link, 'fetch two', Names::AtomMediaType, false)
|
46
|
+
|
47
|
+
correctly_ordered = false
|
48
|
+
if two_resp
|
49
|
+
etag = two_resp.header 'etag'
|
50
|
+
|
51
|
+
putter = Putter.new(link, @authent)
|
52
|
+
putter.set_header('If-Match', etag)
|
53
|
+
|
54
|
+
name = 'Updating mini-entry with PUT'
|
55
|
+
sleep 2
|
56
|
+
updated = two_resp.body.gsub('Mini Two', 'Mini-4')
|
57
|
+
unless putter.put(Names::AtomEntryMediaType, updated)
|
58
|
+
reporter.save_dialog(name, putter)
|
59
|
+
reporter.error(self, "Can't update mini-entry at #{link}", name)
|
60
|
+
return
|
61
|
+
end
|
62
|
+
# now the order should have changed
|
63
|
+
wanted = ['Mini One', 'Mini Three', 'Mini-4']
|
64
|
+
correctly_ordered = true
|
65
|
+
else
|
66
|
+
reporter.error(self, "Mini Two entry not received. Can't assure the correct order after update.")
|
67
|
+
wanted = ['Mini One', 'Mini Two', 'Mini Three']
|
68
|
+
end
|
69
|
+
|
70
|
+
entries = Feed.read(coll.href, 'Entries post-update', reporter)
|
71
|
+
entries.each do |from_feed|
|
72
|
+
want = wanted.pop
|
73
|
+
unless from_feed.child_content('title').index(want)
|
74
|
+
reporter.error(self, "Entries feed out of order after update of multi-post.")
|
75
|
+
return
|
76
|
+
end
|
77
|
+
|
78
|
+
# next to godliness
|
79
|
+
delete_entry(from_feed)
|
80
|
+
|
81
|
+
break if wanted.empty?
|
82
|
+
end
|
83
|
+
reporter.success(self, "Entries correctly ordered after update of multi-post.") if correctly_ordered
|
84
|
+
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
data/lib/ape/version.rb
CHANGED
File without changes
|
@@ -1,16 +1,16 @@
|
|
1
1
|
<?xml version="1.0" ?>
|
2
2
|
<entry xmlns="http://www.w3.org/2005/Atom">
|
3
3
|
<id><%= id %></id>
|
4
|
-
<title><%= title %></title>
|
4
|
+
<title><%= @title %></title>
|
5
5
|
<author><name>The Atom Protocol Exerciser</name></author>
|
6
6
|
<updated><%= now %></updated>
|
7
7
|
<link href='http://www.tbray.org/ape'/>
|
8
|
-
<summary type='html'><%= summary %></summary>
|
8
|
+
<summary type='html'><%= @summary %></summary>
|
9
9
|
<content type='xhtml'><div xmlns='http://www.w3.org/1999/xhtml'>
|
10
|
-
<p>A test post from the <APE> at
|
10
|
+
<p>A test post from the <APE> at <%= now %></p>
|
11
11
|
<p>If you see this in an entry, it's probably a left-over from an
|
12
12
|
unsuccessful Ape run; feel free to delete it.</p>
|
13
13
|
</div>
|
14
14
|
</content>
|
15
|
-
<dc:subject xmlns:dc='<%=subject%>'>Simians</dc:subject>
|
15
|
+
<dc:subject xmlns:dc='<%= @subject%>'>Simians</dc:subject>
|
16
16
|
</entry>
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
data/test/test_helper.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
$:.unshift File.dirname(__FILE__) + '/../lib'
|
2
2
|
require 'test/unit'
|
3
3
|
require 'ape'
|
4
|
+
require 'mocha'
|
4
5
|
|
5
6
|
def load_test_dir(dir)
|
6
7
|
Dir[File.join(File.dirname(__FILE__), dir, "*.rb")].each do |file|
|
@@ -13,5 +14,36 @@ module Writer
|
|
13
14
|
@response = response
|
14
15
|
end
|
15
16
|
end
|
16
|
-
|
17
17
|
Ape::Invoker.send(:include, Writer)
|
18
|
+
|
19
|
+
module ApeAccessors
|
20
|
+
def service=(service)
|
21
|
+
@service = service
|
22
|
+
end
|
23
|
+
|
24
|
+
def entry_collections=(colls)
|
25
|
+
@entry_collections = colls
|
26
|
+
end
|
27
|
+
|
28
|
+
def media_collections=(colls)
|
29
|
+
@media_collections = colls
|
30
|
+
end
|
31
|
+
|
32
|
+
def service
|
33
|
+
@service
|
34
|
+
end
|
35
|
+
|
36
|
+
def entry_collections
|
37
|
+
@entry_collections
|
38
|
+
end
|
39
|
+
|
40
|
+
def media_collections
|
41
|
+
@media_collections
|
42
|
+
end
|
43
|
+
end
|
44
|
+
Ape::Ape.send(:include, ApeAccessors)
|
45
|
+
|
46
|
+
class ValidatorMock < Ape::Validator
|
47
|
+
deterministic
|
48
|
+
requires_presence_of :entry_collection
|
49
|
+
end
|