1fe3ad14e13ed03415c0276fdf0c67b4bf596605
[koha-equinox.git] / t / Koha / SearchEngine / Elasticsearch.t
1 #!/usr/bin/perl
2 #
3 # This file is part of Koha.
4 #
5 # Koha is free software; you can redistribute it and/or modify it
6 # under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 3 of the License, or
8 # (at your option) any later version.
9 #
10 # Koha is distributed in the hope that it will be useful, but
11 # WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with Koha; if not, see <http://www.gnu.org/licenses>.
17
18 use Modern::Perl;
19
20 use Test::More tests => 4;
21 use Test::Exception;
22
23 use t::lib::Mocks;
24
25 use Test::MockModule;
26
27 use MARC::Record;
28 use Try::Tiny;
29
30 use Koha::SearchEngine::Elasticsearch;
31 use Koha::SearchEngine::Elasticsearch::Search;
32
33 subtest '_read_configuration() tests' => sub {
34
35     plan tests => 10;
36
37     my $configuration;
38     t::lib::Mocks::mock_config( 'elasticsearch', undef );
39
40     # 'elasticsearch' missing in configuration
41     throws_ok {
42         $configuration = Koha::SearchEngine::Elasticsearch::_read_configuration;
43     }
44     'Koha::Exceptions::Config::MissingEntry',
45       'Configuration problem, exception thrown';
46     is(
47         $@->message,
48         "Missing 'elasticsearch' block in config file",
49         'Exception message is correct'
50     );
51
52     # 'elasticsearch' present but no 'server' entry
53     t::lib::Mocks::mock_config( 'elasticsearch', {} );
54     throws_ok {
55         $configuration = Koha::SearchEngine::Elasticsearch::_read_configuration;
56     }
57     'Koha::Exceptions::Config::MissingEntry',
58       'Configuration problem, exception thrown';
59     is(
60         $@->message,
61         "Missing 'server' entry in config file for elasticsearch",
62         'Exception message is correct'
63     );
64
65     # 'elasticsearch' and 'server' entries present, but no 'index_name'
66     t::lib::Mocks::mock_config( 'elasticsearch', { server => 'a_server' } );
67     throws_ok {
68         $configuration = Koha::SearchEngine::Elasticsearch::_read_configuration;
69     }
70     'Koha::Exceptions::Config::MissingEntry',
71       'Configuration problem, exception thrown';
72     is(
73         $@->message,
74         "Missing 'index_name' entry in config file for elasticsearch",
75         'Exception message is correct'
76     );
77
78     # Correct configuration, only one server
79     t::lib::Mocks::mock_config( 'elasticsearch',  { server => 'a_server', index_name => 'index' } );
80
81     $configuration = Koha::SearchEngine::Elasticsearch::_read_configuration;
82     is( $configuration->{index_name}, 'index', 'Index configuration parsed correctly' );
83     is_deeply( $configuration->{nodes}, ['a_server'], 'Server configuration parsed correctly' );
84
85     # Correct configuration, two servers
86     my @servers = ('a_server', 'another_server');
87     t::lib::Mocks::mock_config( 'elasticsearch', { server => \@servers, index_name => 'index' } );
88
89     $configuration = Koha::SearchEngine::Elasticsearch::_read_configuration;
90     is( $configuration->{index_name}, 'index', 'Index configuration parsed correctly' );
91     is_deeply( $configuration->{nodes}, \@servers , 'Server configuration parsed correctly' );
92 };
93
94 subtest 'get_elasticsearch_settings() tests' => sub {
95
96     plan tests => 1;
97
98     my $settings;
99
100     # test reading index settings
101     my $es = Koha::SearchEngine::Elasticsearch->new( {index => $Koha::SearchEngine::Elasticsearch::BIBLIOS_INDEX} );
102     $settings = $es->get_elasticsearch_settings();
103     is( $settings->{index}{analysis}{analyzer}{analyser_phrase}{tokenizer}, 'keyword', 'Index settings parsed correctly' );
104 };
105
106 subtest 'get_elasticsearch_mappings() tests' => sub {
107
108     plan tests => 1;
109
110     my $mappings;
111
112     # test reading mappings
113     my $es = Koha::SearchEngine::Elasticsearch->new( {index => $Koha::SearchEngine::Elasticsearch::BIBLIOS_INDEX} );
114     $mappings = $es->get_elasticsearch_mappings();
115     is( $mappings->{data}{_all}{type}, 'string', 'Field mappings parsed correctly' );
116 };
117
118 subtest 'Koha::SearchEngine::Elasticsearch::marc_records_to_documents () tests' => sub {
119
120     plan tests => 49;
121
122     t::lib::Mocks::mock_preference('marcflavour', 'MARC21');
123
124     my @mappings = (
125         {
126             name => 'control_number',
127             type => 'string',
128             facet => 0,
129             suggestible => 0,
130             sort => undef,
131             marc_type => 'marc21',
132             marc_field => '001',
133         },
134         {
135             name => 'isbn',
136             type => 'isbn',
137             facet => 0,
138             suggestible => 0,
139             sort => 0,
140             marc_type => 'marc21',
141             marc_field => '020a',
142         },
143         {
144             name => 'author',
145             type => 'string',
146             facet => 1,
147             suggestible => 1,
148             sort => undef,
149             marc_type => 'marc21',
150             marc_field => '100a',
151         },
152         {
153             name => 'author',
154             type => 'string',
155             facet => 1,
156             suggestible => 1,
157             sort => 1,
158             marc_type => 'marc21',
159             marc_field => '110a',
160         },
161         {
162             name => 'title',
163             type => 'string',
164             facet => 0,
165             suggestible => 1,
166             sort => 1,
167             marc_type => 'marc21',
168             marc_field => '245(ab)ab',
169         },
170         {
171             name => 'unimarc_title',
172             type => 'string',
173             facet => 0,
174             suggestible => 1,
175             sort => 1,
176             marc_type => 'unimarc',
177             marc_field => '245a',
178         },
179         {
180             name => 'title',
181             type => 'string',
182             facet => 0,
183             suggestible => undef,
184             sort => 0,
185             marc_type => 'marc21',
186             marc_field => '220',
187         },
188         {
189             name => 'title_wildcard',
190             type => 'string',
191             facet => 0,
192             suggestible => 0,
193             sort => undef,
194             marc_type => 'marc21',
195             marc_field => '245',
196         },
197         {
198             name => 'sum_item_price',
199             type => 'sum',
200             facet => 0,
201             suggestible => 0,
202             sort => 0,
203             marc_type => 'marc21',
204             marc_field => '952g',
205         },
206         {
207             name => 'items_withdrawn_status',
208             type => 'boolean',
209             facet => 0,
210             suggestible => 0,
211             sort => 0,
212             marc_type => 'marc21',
213             marc_field => '9520',
214         },
215         {
216             name => 'local_classification',
217             type => 'string',
218             facet => 0,
219             suggestible => 0,
220             sort => 1,
221             marc_type => 'marc21',
222             marc_field => '952o',
223         },
224         {
225             name => 'type_of_record',
226             type => 'string',
227             facet => 0,
228             suggestible => 0,
229             sort => 0,
230             marc_type => 'marc21',
231             marc_field => 'leader_/6',
232         },
233         {
234             name => 'type_of_record_and_bib_level',
235             type => 'string',
236             facet => 0,
237             suggestible => 0,
238             sort => 0,
239             marc_type => 'marc21',
240             marc_field => 'leader_/6-7',
241         },
242     );
243
244     my $se = Test::MockModule->new('Koha::SearchEngine::Elasticsearch');
245     $se->mock('_foreach_mapping', sub {
246         my ($self, $sub) = @_;
247
248         foreach my $map (@mappings) {
249             $sub->(
250                 $map->{name},
251                 $map->{type},
252                 $map->{facet},
253                 $map->{suggestible},
254                 $map->{sort},
255                 $map->{marc_type},
256                 $map->{marc_field}
257             );
258         }
259     });
260
261     my $see = Koha::SearchEngine::Elasticsearch::Search->new({ index => $Koha::SearchEngine::Elasticsearch::BIBLIOS_INDEX });
262
263     my $callno = 'ABC123';
264     my $callno2 = 'ABC456';
265     my $long_callno = '1234567890' x 30;
266
267     my $marc_record_1 = MARC::Record->new();
268     $marc_record_1->leader('     cam  22      a 4500');
269     $marc_record_1->append_fields(
270         MARC::Field->new('001', '123'),
271         MARC::Field->new('020', '', '', a => '1-56619-909-3'),
272         MARC::Field->new('100', '', '', a => 'Author 1'),
273         MARC::Field->new('110', '', '', a => 'Corp Author'),
274         MARC::Field->new('210', '', '', a => 'Title 1'),
275         MARC::Field->new('245', '', '', a => 'Title:', b => 'first record'),
276         MARC::Field->new('999', '', '', c => '1234567'),
277         # '  ' for testing trimming of white space in boolean value callback:
278         MARC::Field->new('952', '', '', 0 => '  ', g => '123.30', o => $callno),
279         MARC::Field->new('952', '', '', 0 => 0, g => '127.20', o => $callno2),
280         MARC::Field->new('952', '', '', 0 => 1, g => '0.00', o => $long_callno),
281     );
282     my $marc_record_2 = MARC::Record->new();
283     $marc_record_2->leader('     cam  22      a 4500');
284     $marc_record_2->append_fields(
285         MARC::Field->new('100', '', '', a => 'Author 2'),
286         # MARC::Field->new('210', '', '', a => 'Title 2'),
287         # MARC::Field->new('245', '', '', a => 'Title: second record'),
288         MARC::Field->new('999', '', '', c => '1234568'),
289         MARC::Field->new('952', '', '', 0 => 1, g => 'string where should be numeric', o => $long_callno),
290     );
291     my $records = [$marc_record_1, $marc_record_2];
292
293     $see->get_elasticsearch_mappings(); #sort_fields will call this and use the actual db values unless we call it first
294
295     my $docs = $see->marc_records_to_documents($records);
296
297     # First record:
298     is(scalar @{$docs}, 2, 'Two records converted to documents');
299
300     is($docs->[0][0], '1234567', 'First document biblionumber should be set as first element in document touple');
301
302     is_deeply($docs->[0][1]->{control_number}, ['123'], 'First record control number should be set correctly');
303
304     is(scalar @{$docs->[0][1]->{author}}, 2, 'First document author field should contain two values');
305     is_deeply($docs->[0][1]->{author}, ['Author 1', 'Corp Author'], 'First document author field should be set correctly');
306
307     is(scalar @{$docs->[0][1]->{author__sort}}, 1, 'First document author__sort field should have a single value');
308     is_deeply($docs->[0][1]->{author__sort}, ['Author 1 Corp Author'], 'First document author__sort field should be set correctly');
309
310     is(scalar @{$docs->[0][1]->{title__sort}}, 1, 'First document title__sort field should have a single');
311     is_deeply($docs->[0][1]->{title__sort}, ['Title: first record Title: first record'], 'First document title__sort field should be set correctly');
312
313     is(scalar @{$docs->[0][1]->{title_wildcard}}, 2, 'First document title_wildcard field should have two values');
314     is_deeply($docs->[0][1]->{title_wildcard}, ['Title:', 'first record'], 'First document title_wildcard field should be set correctly');
315
316     is(scalar @{$docs->[0][1]->{author__suggestion}}, 2, 'First document author__suggestion field should contain two values');
317     is_deeply(
318         $docs->[0][1]->{author__suggestion},
319         [
320             {
321                 'input' => 'Author 1'
322             },
323             {
324                 'input' => 'Corp Author'
325             }
326         ],
327         'First document author__suggestion field should be set correctly'
328     );
329
330     is(scalar @{$docs->[0][1]->{title__suggestion}}, 3, 'First document title__suggestion field should contain three values');
331     is_deeply(
332         $docs->[0][1]->{title__suggestion},
333         [
334             { 'input' => 'Title:' },
335             { 'input' => 'first record' },
336             { 'input' => 'Title: first record' }
337         ],
338         'First document title__suggestion field should be set correctly'
339     );
340
341     ok(!(defined $docs->[0][1]->{title__facet}), 'First document should have no title__facet field');
342
343     is(scalar @{$docs->[0][1]->{author__facet}}, 2, 'First document author__facet field should have two values');
344     is_deeply(
345         $docs->[0][1]->{author__facet},
346         ['Author 1', 'Corp Author'],
347         'First document author__facet field should be set correctly'
348     );
349
350     is(scalar @{$docs->[0][1]->{items_withdrawn_status}}, 2, 'First document items_withdrawn_status field should have two values');
351     is_deeply(
352         $docs->[0][1]->{items_withdrawn_status},
353         ['false', 'true'],
354         'First document items_withdrawn_status field should be set correctly'
355     );
356
357     is(
358         $docs->[0][1]->{sum_item_price},
359         '250.5',
360         'First document sum_item_price field should be set correctly'
361     );
362
363     ok(defined $docs->[0][1]->{marc_data}, 'First document marc_data field should be set');
364     ok(defined $docs->[0][1]->{marc_format}, 'First document marc_format field should be set');
365     is($docs->[0][1]->{marc_format}, 'base64ISO2709', 'First document marc_format should be set correctly');
366
367     my $decoded_marc_record = $see->decode_record_from_result($docs->[0][1]);
368
369     ok($decoded_marc_record->isa('MARC::Record'), "base64ISO2709 record successfully decoded from result");
370     is($decoded_marc_record->as_usmarc(), $marc_record_1->as_usmarc(), "Decoded base64ISO2709 record has same data as original record");
371
372     is(scalar @{$docs->[0][1]->{type_of_record}}, 1, 'First document type_of_record field should have one value');
373     is_deeply(
374         $docs->[0][1]->{type_of_record},
375         ['a'],
376         'First document type_of_record field should be set correctly'
377     );
378
379     is(scalar @{$docs->[0][1]->{type_of_record_and_bib_level}}, 1, 'First document type_of_record_and_bib_level field should have one value');
380     is_deeply(
381         $docs->[0][1]->{type_of_record_and_bib_level},
382         ['am'],
383         'First document type_of_record_and_bib_level field should be set correctly'
384     );
385
386     is(scalar @{$docs->[0][1]->{isbn}}, 4, 'First document isbn field should contain four values');
387     is_deeply($docs->[0][1]->{isbn}, ['978-1-56619-909-4', '9781566199094', '1-56619-909-3', '1566199093'], 'First document isbn field should be set correctly');
388
389     is_deeply(
390         $docs->[0][1]->{'local_classification'},
391         [$callno, $callno2, $long_callno],
392         'First document local_classification field should be set correctly'
393     );
394
395     # Second record:
396
397     is(scalar @{$docs->[1][1]->{author}}, 1, 'Second document author field should contain one value');
398     is_deeply($docs->[1][1]->{author}, ['Author 2'], 'Second document author field should be set correctly');
399
400     is(scalar @{$docs->[1][1]->{items_withdrawn_status}}, 1, 'Second document items_withdrawn_status field should have one value');
401     is_deeply(
402         $docs->[1][1]->{items_withdrawn_status},
403         ['true'],
404         'Second document items_withdrawn_status field should be set correctly'
405     );
406
407     is(
408         $docs->[1][1]->{sum_item_price},
409         0,
410         'Second document sum_item_price field should be set correctly'
411     );
412
413     is_deeply(
414         $docs->[1][1]->{local_classification__sort},
415         [substr($long_callno, 0, 255)],
416         'Second document local_classification__sort field should be set correctly'
417     );
418
419     # Mappings marc_type:
420
421     ok(!(defined $docs->[0][1]->{unimarc_title}), "No mapping when marc_type doesn't match marc flavour");
422
423     # Marc serialization format fallback for records exceeding ISO2709 max record size
424
425     my $large_marc_record = MARC::Record->new();
426     $large_marc_record->leader('     cam  22      a 4500');
427
428     $large_marc_record->append_fields(
429         MARC::Field->new('100', '', '', a => 'Author 1'),
430         MARC::Field->new('110', '', '', a => 'Corp Author'),
431         MARC::Field->new('210', '', '', a => 'Title 1'),
432         MARC::Field->new('245', '', '', a => 'Title:', b => 'large record'),
433         MARC::Field->new('999', '', '', c => '1234567'),
434     );
435
436     my $item_field = MARC::Field->new('952', '', '', o => '123456789123456789123456789', p => '123456789', z => 'test');
437     my $items_count = 1638;
438     while(--$items_count) {
439         $large_marc_record->append_fields($item_field);
440     }
441
442     $docs = $see->marc_records_to_documents([$large_marc_record]);
443
444     is($docs->[0][1]->{marc_format}, 'MARCXML', 'For record exceeding max record size marc_format should be set correctly');
445
446     $decoded_marc_record = $see->decode_record_from_result($docs->[0][1]);
447
448     ok($decoded_marc_record->isa('MARC::Record'), "MARCXML record successfully decoded from result");
449     is($decoded_marc_record->as_xml_record(), $large_marc_record->as_xml_record(), "Decoded MARCXML record has same data as original record");
450
451     push @mappings, {
452         name => 'title',
453         type => 'string',
454         facet => 0,
455         suggestible => 1,
456         sort => 1,
457         marc_type => 'marc21',
458         marc_field => '245((ab)ab',
459     };
460
461     my $exception = try {
462         $see->marc_records_to_documents($records);
463     }
464     catch {
465         return $_;
466     };
467
468     ok(defined $exception, "Exception has been thrown when processing mapping with unmatched opening parenthesis");
469     ok($exception->isa("Koha::Exceptions::Elasticsearch::MARCFieldExprParseError"), "Exception is of correct class");
470     ok($exception->message =~ /Unmatched opening parenthesis/, "Exception has the correct message");
471
472     pop @mappings;
473     push @mappings, {
474         name => 'title',
475         type => 'string',
476         facet => 0,
477         suggestible => 1,
478         sort => 1,
479         marc_type => 'marc21',
480         marc_field => '245(ab))ab',
481     };
482
483     $exception = try {
484         $see->marc_records_to_documents($records);
485     }
486     catch {
487         return $_;
488     };
489
490     ok(defined $exception, "Exception has been thrown when processing mapping with unmatched closing parenthesis");
491     ok($exception->isa("Koha::Exceptions::Elasticsearch::MARCFieldExprParseError"), "Exception is of correct class");
492     ok($exception->message =~ /Unmatched closing parenthesis/, "Exception has the correct message");
493 };