shanna-dm-sphinx-adapter 0.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,74 @@
1
+ require 'rubygems'
2
+
3
+ gem 'extlib', '~> 0.9.7'
4
+ require 'extlib'
5
+
6
+ module DataMapper
7
+ module Adapters
8
+ module Sphinx
9
+ class Config
10
+ include Extlib::Assertions
11
+ attr_reader :config, :address, :log, :port
12
+
13
+ ##
14
+ # Read a sphinx configuration file.
15
+ #
16
+ # This class just gives you access to handy searchd {} configuration options.
17
+ #
18
+ # @see http://www.sphinxsearch.com/doc.html#confgroup-searchd
19
+ def initialize(uri_or_options = {})
20
+ assert_kind_of 'uri_or_options', uri_or_options, Addressable::URI, DataObjects::URI, Hash, String, Pathname
21
+
22
+ options = normalize_options(uri_or_options)
23
+ config = parse_config("#{options[:path]}") # Pathname#to_s is broken?
24
+ @address = options[:host] || config['address'] || '0.0.0.0'
25
+ @port = options[:port] || config['port'] || 3312
26
+ @log = options[:log] || config['log'] || 'searchd.log'
27
+ @pid_file = options[:pid_file] || config['pid_file']
28
+ end
29
+
30
+ ##
31
+ # Indexer binary full path name and config argument.
32
+ def indexer_bin(use_config = true)
33
+ path = 'indexer' # TODO: Real.
34
+ path << " --config #{config}" if config
35
+ path
36
+ end
37
+
38
+ ##
39
+ # Searchd binary full path name and config argument.
40
+ def searchd_bin(use_config = true)
41
+ path = 'searchd' # TODO: Real.
42
+ path << " --config #{config}" if config
43
+ path
44
+ end
45
+
46
+ def pid_file
47
+ @pid_file or raise "Mandatory pid_file option missing from searchd configuration."
48
+ end
49
+
50
+ protected
51
+ def normalize_options(uri_or_options)
52
+ case uri_or_options
53
+ when String, Addressable::URI then DataObjects::URI.parse(uri_or_options).attributes
54
+ when DataObjects::URI then uri_or_options.attributes
55
+ when Pathname then {:path => uri_or_options}
56
+ else
57
+ uri_or_options[:path] ||= uri_or_options.delete(:config) || uri_or_options.delete(:database)
58
+ uri_or_options
59
+ end
60
+ end
61
+
62
+ def parse_config(path)
63
+ paths = []
64
+ paths.push(path, path.gsub(%r{^/}, './'), path.gsub(%r{^\./}, '/')) unless path.blank?
65
+ paths.push('/usr/local/etc/sphinx.conf', './sphinx.conf')
66
+ paths.map!{|path| Pathname.new(path).expand_path}
67
+
68
+ @config = paths.find{|path| path.readable? && `#{indexer_bin} --config #{path}` !~ /fatal|error/i}
69
+ @config ? ConfigParser.parse(@config) : {}
70
+ end
71
+ end # Config
72
+ end # Sphinx
73
+ end # Adapters
74
+ end # DataMapper
@@ -0,0 +1,67 @@
1
+ require 'rubygems'
2
+
3
+ gem 'extlib', '~> 0.9.7'
4
+ require 'extlib'
5
+
6
+ require 'pathname'
7
+ require 'strscan'
8
+
9
+ module DataMapper
10
+ module Adapters
11
+ module Sphinx
12
+ module ConfigParser
13
+ extend Extlib::Assertions
14
+
15
+ ##
16
+ # Parse a sphinx config file and return searchd options as a hash.
17
+ #
18
+ # @param [String] path Searches path, ./#{path}, /#{path}, /usr/local/etc/sphinx.conf, ./sphinx.conf in
19
+ # that order.
20
+ # @return [Hash]
21
+ def self.parse(path)
22
+ assert_kind_of 'path', path, Pathname, String
23
+
24
+ config = Pathname(path).read
25
+ config.gsub!(/\r\n|\r|\n/, "\n") # Everything in \n
26
+ config.gsub!(/\s*\\\n\s*/, ' ') # Remove unixy line wraps.
27
+
28
+ blocks(StringScanner.new(config), out = [])
29
+ out.find{|c| c['type'] =~ /searchd/i} || {}
30
+ end
31
+
32
+ protected
33
+ def self.blocks(conf, out = []) #:nodoc:
34
+ if conf.scan(/\#[^\n]*\n/) || conf.scan(/\s+/)
35
+ blocks(conf, out)
36
+ elsif conf.scan(/indexer|searchd|source|index/i)
37
+ out << group = {'type' => conf.matched}
38
+ if conf.matched =~ /^(?:index|source)$/i
39
+ conf.scan(/\s* ([\w_\-]+) (?:\s*:\s*([\w_\-]+))? \s*/x) or raise "Expected #{group[:type]} name."
40
+ group['name'] = conf[1]
41
+ group['ancestor'] = conf[2]
42
+ end
43
+ conf.scan(/\s*\{/) or raise %q{Expected '\{'.}
44
+ pairs(conf, kv = {})
45
+ group.merge!(kv)
46
+ conf.scan(/\s*\}/) or raise %q{Expected '\}'.}
47
+ blocks(conf, out)
48
+ else
49
+ raise "Unknown near: #{conf.peek(30)}" unless conf.eos?
50
+ end
51
+ end
52
+
53
+ def self.pairs(conf, out = {}) #:nodoc:
54
+ if conf.scan(/\#[^\n]*\n/) || conf.scan(/\s+/)
55
+ pairs(conf, out)
56
+ elsif conf.scan(/[\w_-]+/)
57
+ key = conf.matched
58
+ conf.scan(/\s*=/) or raise %q{Expected '='.}
59
+ out[key] = conf.scan(/[^\n]*\n/).strip
60
+ pairs(conf, out)
61
+ end
62
+ end
63
+ end # ConfigParser
64
+ end # Sphinx
65
+ end # Adapters
66
+ end # DataMapper
67
+
@@ -0,0 +1,25 @@
1
+ module DataMapper
2
+ module Adapters
3
+ module Sphinx
4
+ class Index
5
+ include Assertions
6
+
7
+ attr_reader :model, :name, :options
8
+
9
+ def initialize(model, name, options = {})
10
+ assert_kind_of 'model', model, Model
11
+ assert_kind_of 'name', name, Symbol, String
12
+ assert_kind_of 'options', options, Hash
13
+
14
+ @model = model
15
+ @name = name.to_sym
16
+ @delta = options.fetch(:delta, nil)
17
+ end
18
+
19
+ def delta?
20
+ !!@delta
21
+ end
22
+ end # Index
23
+ end # Sphinx
24
+ end # Adapters
25
+ end # DataMapper
@@ -0,0 +1,96 @@
1
+ module DataMapper
2
+ module Adapters
3
+ module Sphinx
4
+
5
+ ##
6
+ # Declare Sphinx indexes in your resource.
7
+ #
8
+ # model Items
9
+ # include DataMapper::SphinxResource
10
+ #
11
+ # # .. normal properties and such for :default
12
+ #
13
+ # repository(:search) do
14
+ # # Query some_index, some_index_delta in that order.
15
+ # index :some_index
16
+ # index :some_index_delta, :delta => true
17
+ #
18
+ # # Sortable by some attributes.
19
+ # attribute :updated_at, DateTime # sql_attr_timestamp
20
+ # attribute :age, Integer # sql_attr_uint
21
+ # attribute :deleted, Boolean # sql_attr_bool
22
+ # end
23
+ # end
24
+ module Resource
25
+ def self.included(model) #:nodoc:
26
+ model.class_eval do
27
+ include DataMapper::Resource
28
+ extend ClassMethods
29
+ end
30
+ end
31
+
32
+ module ClassMethods
33
+ def self.extended(model) #:nodoc:
34
+ model.instance_variable_set(:@sphinx_indexes, {})
35
+ model.instance_variable_set(:@sphinx_attributes, {})
36
+ end
37
+
38
+ ##
39
+ # Defines a sphinx index on the resource.
40
+ #
41
+ # Indexes are naturally ordered, with delta indexes at the end of the list so that duplicate document IDs in
42
+ # delta indexes override your main indexes.
43
+ #
44
+ # @param [Symbol] name the name of a sphinx index to search for this resource
45
+ # @param [Hash(Symbol => String)] options a hash of available options
46
+ # @see DataMapper::Adapters::Sphinx::Index
47
+ def index(name, options = {})
48
+ index = Index.new(self, name, options)
49
+ indexes = sphinx_indexes(repository_name)
50
+ indexes << index
51
+
52
+ # TODO: I'm such a Ruby nub. In the meantime I've gone back to my Perl roots.
53
+ # This is a Schwartzian transform to sort delta indexes to the bottom and natural sort by name.
54
+ mapped = indexes.map{|i| [(i.delta? ? 1 : 0), i.name, i]}
55
+ sorted = mapped.sort{|a, b| a[0] <=> b[0] || a[1] <=> b[1]}
56
+ indexes.replace(sorted.map{|i| i[2]})
57
+
58
+ index
59
+ end
60
+
61
+ ##
62
+ # List of declared sphinx indexes for this model.
63
+ def sphinx_indexes(repository_name = default_repository_name)
64
+ @sphinx_indexes[repository_name] ||= []
65
+ end
66
+
67
+ ##
68
+ # Defines a sphinx attribute on the resource.
69
+ #
70
+ # @param [Symbol] name the name of a sphinx attribute to order/restrict by for this resource
71
+ # @param [Class] type the type to define this attribute as
72
+ # @param [Hash(Symbol => String)] options a hash of available options
73
+ # @see DataMapper::Adapters::Sphinx::Attribute
74
+ def attribute(name, type, options = {})
75
+ # Attributes are just properties without a getter/setter in the model.
76
+ # This keeps DataMapper::Query happy when building queries.
77
+ attribute = Sphinx::Attribute.new(self, name, type, options)
78
+ properties(repository_name)[attribute.name] = attribute
79
+ attribute
80
+ end
81
+
82
+ ##
83
+ # List of declared sphinx attributes for this model.
84
+ def sphinx_attributes(repository_name = default_repository_name)
85
+ properties(repository_name).grep{|p| p.kind_of? Sphinx::Attribute}
86
+ end
87
+
88
+ end # ClassMethods
89
+ end # Resource
90
+ end # Sphinx
91
+ end # Adapters
92
+
93
+ # Follow DM naming convention.
94
+ SphinxResource = Adapters::Sphinx::Resource
95
+ end # DataMapper
96
+
@@ -0,0 +1,18 @@
1
+ require 'rubygems'
2
+
3
+ # TODO: Hide the shitload of dm-core warnings or at least try to?
4
+ $VERBOSE = nil
5
+ gem 'dm-core', '~> 0.9.7'
6
+ require 'dm-core'
7
+
8
+ # TODO: I think I might move everything to DataMapper::Sphinx::* and ignore the default naming convention.
9
+ require 'pathname'
10
+ dir = Pathname(__FILE__).dirname.expand_path / 'dm-sphinx-adapter'
11
+ require dir / 'config'
12
+ require dir / 'config_parser'
13
+ require dir / 'client'
14
+ require dir / 'adapter'
15
+ require dir / 'index'
16
+ require dir / 'attribute'
17
+ require dir / 'resource'
18
+
@@ -0,0 +1,21 @@
1
+ drop table if exists delta;
2
+ create table delta (
3
+ name varchar(50) not null,
4
+ updated_on datetime,
5
+ primary key (name)
6
+ ) engine=innodb default charset=utf8;
7
+
8
+ drop table if exists items;
9
+ create table items (
10
+ id int(11) not null auto_increment,
11
+ name varchar(50) not null,
12
+ likes text not null,
13
+ updated_on datetime,
14
+ primary key (id),
15
+ index (updated_on)
16
+ ) engine=innodb default charset=utf8;
17
+
18
+ insert into items (name, likes, updated_on) values
19
+ ('one', 'I really like one!', now()),
20
+ ('two', 'I really like two!', now()),
21
+ ('three', 'I really like three!', now());
@@ -0,0 +1,25 @@
1
+ require 'rubygems'
2
+ require 'dm-is-searchable'
3
+
4
+ class Explicit
5
+ include DataMapper::Resource
6
+ include DataMapper::SphinxResource
7
+
8
+ property :id, Serial
9
+ property :name, String
10
+ property :likes, Text, :lazy => false
11
+ property :updated_on, DateTime
12
+
13
+ is :searchable
14
+ repository(:search) do
15
+ properties(:search).clear
16
+ index :items
17
+ index :items_delta, :delta => true
18
+ property :name, String
19
+ attribute :updated_on, DateTime
20
+ end
21
+
22
+ def self.default_storage_name
23
+ 'item'
24
+ end
25
+ end # Explicit
@@ -0,0 +1,19 @@
1
+ require 'rubygems'
2
+ require 'dm-is-searchable'
3
+
4
+ class Resource
5
+ include DataMapper::Resource
6
+ include DataMapper::SphinxResource
7
+
8
+ property :id, Serial
9
+ property :name, String
10
+ property :likes, Text, :lazy => false
11
+ property :updated_on, DateTime
12
+
13
+ is :searchable
14
+
15
+ def self.default_storage_name
16
+ 'item'
17
+ end
18
+ end # Resource
19
+
@@ -0,0 +1,16 @@
1
+ require 'rubygems'
2
+ require 'dm-is-searchable'
3
+
4
+ class Searchable
5
+ include DataMapper::Resource
6
+ property :id, Serial
7
+ property :name, String
8
+ property :likes, Text, :lazy => false
9
+ property :updated_on, DateTime
10
+
11
+ is :searchable
12
+
13
+ def self.default_storage_name
14
+ 'item'
15
+ end
16
+ end # Searchable
@@ -0,0 +1,11 @@
1
+ class StorageName
2
+ include DataMapper::Resource
3
+ property :id, Serial
4
+ property :name, String
5
+ property :likes, Text, :lazy => false
6
+ property :updated_on, DateTime
7
+
8
+ def self.default_storage_name
9
+ 'item'
10
+ end
11
+ end # StorageName
@@ -0,0 +1,7 @@
1
+ class Vanilla
2
+ include DataMapper::Resource
3
+ property :id, Serial
4
+ property :name, String
5
+ property :likes, Text, :lazy => false
6
+ property :updated_on, DateTime
7
+ end # Vanilla
@@ -0,0 +1,78 @@
1
+ # searchd and indexer must be run from the root directory of this lib.
2
+
3
+ indexer
4
+ {
5
+ mem_limit = 64M
6
+ }
7
+
8
+ searchd
9
+ {
10
+ address = localhost
11
+ port = 3312
12
+ log = test/var/sphinx.log
13
+ query_log = test/var/sphinx.query.log
14
+ read_timeout = 5
15
+ pid_file = test/var/sphinx.pid
16
+ max_matches = 1000
17
+ }
18
+
19
+ source items
20
+ {
21
+ type = mysql
22
+ sql_host = localhost
23
+ sql_user = root
24
+ sql_pass =
25
+ sql_db = dm_sphinx_adapter_test
26
+
27
+ sql_query_pre = set names utf8
28
+ sql_query_pre = \
29
+ replace into delta (name, updated_on) ( \
30
+ select 'items', updated_on \
31
+ from items \
32
+ order by updated_on desc \
33
+ limit 1\
34
+ )
35
+
36
+ sql_query = \
37
+ select id, name, likes, unix_timestamp(updated_on) as updated_on \
38
+ from items \
39
+ where updated_on <= ( \
40
+ select updated_on \
41
+ from delta \
42
+ where name = 'items'\
43
+ )
44
+
45
+ sql_query_info = select * from items where id = $id
46
+ sql_attr_timestamp = updated_on
47
+ }
48
+
49
+ source items_delta : items
50
+ {
51
+ sql_query_pre = set names utf8
52
+ sql_query = \
53
+ select id, name, likes, unix_timestamp(updated_on) as updated_on \
54
+ from items \
55
+ where updated_on > ( \
56
+ select updated_on \
57
+ from delta \
58
+ where name = 'items'\
59
+ )
60
+ }
61
+
62
+ index items
63
+ {
64
+ source = items
65
+ path = test/var/sphinx/items
66
+ }
67
+
68
+ index items_delta : items
69
+ {
70
+ source = items_delta
71
+ path = test/var/sphinx/items_delta
72
+ }
73
+
74
+ index vanillas
75
+ {
76
+ type = distributed
77
+ local = items
78
+ }
@@ -0,0 +1,38 @@
1
+ require 'dm-sphinx-adapter'
2
+ require 'test/unit'
3
+
4
+ # DataMapper::Logger.new(STDOUT, :debug)
5
+
6
+ class TestAdapter < Test::Unit::TestCase
7
+ def setup
8
+ # TODO: A little too brutal even by my standards.
9
+ Dir.chdir(File.join(File.dirname(__FILE__), 'files')) do
10
+ system 'mysql -u root dm_sphinx_adapter_test < dm_sphinx_adapter_test.sql' \
11
+ or raise %q{Tests require the dm_sphinx_adapter_test database.}
12
+ end
13
+
14
+ DataMapper.setup(:default, 'mysql://localhost/dm_sphinx_adapter_test')
15
+
16
+ @config = Pathname.new(__FILE__).dirname.expand_path / 'files' / 'sphinx.conf'
17
+ @client = DataMapper::Adapters::Sphinx::ManagedClient.new(:config => @config)
18
+ @client.index
19
+ sleep 1
20
+ end
21
+
22
+ def test_unmanaged_setup
23
+ assert DataMapper.setup(:sphinx, :adapter => 'sphinx')
24
+ assert_kind_of DataMapper::Adapters::SphinxAdapter, repository(:sphinx).adapter
25
+ assert_kind_of DataMapper::Adapters::Sphinx::Client, repository(:sphinx).adapter.client
26
+ end
27
+
28
+ def test_managed_setup
29
+ assert DataMapper.setup(:sphinx, :adapter => 'sphinx', :config => @config, :managed => true)
30
+ assert_kind_of DataMapper::Adapters::SphinxAdapter, repository(:sphinx).adapter
31
+ assert_kind_of DataMapper::Adapters::Sphinx::ManagedClient, repository(:sphinx).adapter.client
32
+ end
33
+
34
+ def teardown
35
+ @client.stop
36
+ sleep 1
37
+ end
38
+ end
@@ -0,0 +1,48 @@
1
+ require 'test_adapter'
2
+ require 'files/resource_explicit'
3
+
4
+ class TestAdapterExplicit < TestAdapter
5
+ def setup
6
+ super
7
+ DataMapper.setup(:search, :adapter => 'sphinx', :config => @config, :managed => true)
8
+ end
9
+
10
+ def teardown
11
+ DataMapper.repository(:search).adapter.client.stop
12
+ super
13
+ end
14
+
15
+ def test_initialize
16
+ assert_nothing_raised{ Explicit.new }
17
+ end
18
+
19
+ def test_search_properties
20
+ assert_equal Explicit.all, Explicit.search
21
+ assert_equal [Explicit.first(:id => 2)], Explicit.search(:name => 'two')
22
+ end
23
+
24
+ def test_search_delta
25
+ resource = Explicit.create(:name => 'four', :likes => 'chicken', :updated_on => Time.now)
26
+ DataMapper.repository(:search).adapter.client.index('items_delta')
27
+ assert_equal [resource], Explicit.search(:name => 'four')
28
+ end
29
+
30
+ def test_search_attribute_timestamp
31
+ time = Time.now
32
+ resource = Explicit.create(:name => 'four', :likes => 'chicken', :updated_on => time)
33
+ DataMapper.repository(:search).adapter.client.index('items_delta')
34
+
35
+ assert_equal [resource], Explicit.search(:updated_on => time.to_i)
36
+ assert_equal [resource], Explicit.search(:updated_on => (time .. time + 1))
37
+ assert_equal [], Explicit.search(:updated_on => (time - 60 * 60))
38
+ assert_equal [], Explicit.search(:updated_on => (time + 60 * 60))
39
+ end
40
+
41
+ def test_search_attribute_boolean
42
+ # TODO:
43
+ end
44
+
45
+ def test_search_attribute_integer
46
+ # TODO
47
+ end
48
+ end # TestAdapterExplicit
@@ -0,0 +1,25 @@
1
+ require 'test_adapter'
2
+ require 'files/resource_resource'
3
+
4
+ class TestAdapterResource < TestAdapter
5
+ def setup
6
+ super
7
+ DataMapper.setup(:search, :adapter => 'sphinx', :config => @config, :managed => true)
8
+ end
9
+
10
+ def teardown
11
+ DataMapper.repository(:search).adapter.client.stop
12
+ super
13
+ end
14
+
15
+ def test_initialize
16
+ assert_nothing_raised{ Resource.new }
17
+ end
18
+
19
+ def test_search_properties
20
+ assert_equal Resource.all, Resource.search
21
+ assert_equal [Resource.first(:id => 2)], Resource.search(:name => 'two')
22
+ assert_equal [Resource.first(:id => 2)], Resource.search(:conditions => ['two'])
23
+ end
24
+ end # TestAdapterResource
25
+
@@ -0,0 +1,23 @@
1
+ require 'test_adapter'
2
+ require 'files/resource_searchable'
3
+
4
+ class TestAdapterSearchable < TestAdapter
5
+ def setup
6
+ super
7
+ DataMapper.setup(:search, :adapter => 'sphinx', :config => @config, :managed => true)
8
+ end
9
+
10
+ def teardown
11
+ DataMapper.repository(:search).adapter.client.stop
12
+ super
13
+ end
14
+
15
+ def test_initialize
16
+ assert_nothing_raised{ Searchable.new }
17
+ end
18
+
19
+ def test_search
20
+ assert_equal Searchable.all, Searchable.search
21
+ assert_equal [Searchable.first(:id => 2)], Searchable.search(:name => 'two')
22
+ end
23
+ end # TestAdapterSearchable
@@ -0,0 +1,46 @@
1
+ require 'test_adapter'
2
+ require 'files/resource_vanilla'
3
+ require 'files/resource_storage_name'
4
+
5
+ class TestAdapterVanilla < TestAdapter
6
+ def setup
7
+ super
8
+ DataMapper.setup(:default, :adapter => 'sphinx', :config => @config, :managed => true)
9
+ end
10
+
11
+ def teardown
12
+ DataMapper.repository(:default).adapter.client.stop
13
+ super
14
+ end
15
+
16
+ def test_initialize
17
+ assert_nothing_raised{ Vanilla.new }
18
+ assert_nothing_raised{ StorageName.new }
19
+ end
20
+
21
+ def test_all
22
+ assert_equal [{:id => 1}, {:id => 2}, {:id => 3}], Vanilla.all
23
+ assert_equal [{:id => 1}], Vanilla.all(:name => 'one')
24
+ end
25
+
26
+ def test_all_limit
27
+ assert_equal [{:id => 1}], Vanilla.all(:limit => 1)
28
+ assert_equal [{:id => 1}, {:id => 2}], Vanilla.all(:limit => 2)
29
+ end
30
+
31
+ def test_all_offset
32
+ assert_equal [{:id => 1}, {:id => 2}, {:id => 3}], Vanilla.all(:offset => 0)
33
+ assert_equal [{:id => 2}, {:id => 3}], Vanilla.all(:offset => 1)
34
+ assert_equal [], Vanilla.all(:offset => 3)
35
+ end
36
+
37
+ def test_first
38
+ assert_equal({:id => 1}, Vanilla.first(:name => 'one'))
39
+ assert_nil Vanilla.first(:name => 'missing')
40
+ end
41
+
42
+ def test_storage_name
43
+ assert_equal Vanilla.all, StorageName.all
44
+ assert_equal Vanilla.first, StorageName.first
45
+ end
46
+ end
@@ -0,0 +1,31 @@
1
+ require 'test_adapter'
2
+
3
+ class TestClient < TestAdapter
4
+ def test_initialize
5
+ assert_nothing_raised { DataMapper::Adapters::Sphinx::Client.new(@config) }
6
+ end
7
+
8
+ def test_index
9
+ client = DataMapper::Adapters::Sphinx::Client.new(@config)
10
+ assert_nothing_raised{ client.index }
11
+ assert_nothing_raised{ client.index 'items' }
12
+ assert_nothing_raised{ client.index '*' }
13
+ assert_nothing_raised{ client.index ['items', 'items_delta'] }
14
+ end
15
+
16
+ def test_managed_initialize
17
+ assert_nothing_raised { DataMapper::Adapters::Sphinx::ManagedClient.new(@config) }
18
+ end
19
+
20
+ def test_search
21
+ begin
22
+ client = DataMapper::Adapters::Sphinx::ManagedClient.new(@config)
23
+ client.index
24
+ assert match = client.search('two')
25
+ assert_equal 1, match[:total]
26
+ assert_equal 2, match[:matches][0][:doc]
27
+ ensure
28
+ client.stop
29
+ end
30
+ end
31
+ end # TestClient