Bug 20589: (follow-up) Fix tests
[koha.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 => 5;
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}{analyzer_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}{properties}{isbn__sort}{index}, 'false', 'Field mappings parsed correctly' );
116 };
117
118 subtest 'Koha::SearchEngine::Elasticsearch::marc_records_to_documents () tests' => sub {
119
120     plan tests => 50;
121
122     t::lib::Mocks::mock_preference('marcflavour', 'MARC21');
123     t::lib::Mocks::mock_preference('ElasticsearchMARCFormat', 'ISO2709');
124
125     my @mappings = (
126         {
127             name => 'control_number',
128             type => 'string',
129             facet => 0,
130             suggestible => 0,
131             searchable => 1,
132             sort => undef,
133             marc_type => 'marc21',
134             marc_field => '001',
135         },
136         {
137             name => 'isbn',
138             type => 'isbn',
139             facet => 0,
140             suggestible => 0,
141             searchable => 1,
142             sort => 0,
143             marc_type => 'marc21',
144             marc_field => '020a',
145         },
146         {
147             name => 'author',
148             type => 'string',
149             facet => 1,
150             suggestible => 1,
151             searchable => 1,
152             sort => undef,
153             marc_type => 'marc21',
154             marc_field => '100a',
155         },
156         {
157             name => 'author',
158             type => 'string',
159             facet => 1,
160             suggestible => 1,
161             searchable => 1,
162             sort => 1,
163             marc_type => 'marc21',
164             marc_field => '110a',
165         },
166         {
167             name => 'title',
168             type => 'string',
169             facet => 0,
170             suggestible => 1,
171             searchable => 1,
172             sort => 1,
173             marc_type => 'marc21',
174             marc_field => '245(ab)ab',
175         },
176         {
177             name => 'unimarc_title',
178             type => 'string',
179             facet => 0,
180             suggestible => 1,
181             searchable => 1,
182             sort => 1,
183             marc_type => 'unimarc',
184             marc_field => '245a',
185         },
186         {
187             name => 'title',
188             type => 'string',
189             facet => 0,
190             suggestible => undef,
191             searchable => 1,
192             sort => 0,
193             marc_type => 'marc21',
194             marc_field => '220',
195         },
196         {
197             name => 'title_wildcard',
198             type => 'string',
199             facet => 0,
200             suggestible => 0,
201             searchable => 1,
202             sort => undef,
203             marc_type => 'marc21',
204             marc_field => '245',
205         },
206         {
207             name => 'sum_item_price',
208             type => 'sum',
209             facet => 0,
210             suggestible => 0,
211             searchable => 1,
212             sort => 0,
213             marc_type => 'marc21',
214             marc_field => '952g',
215         },
216         {
217             name => 'items_withdrawn_status',
218             type => 'boolean',
219             facet => 0,
220             suggestible => 0,
221             searchable => 1,
222             sort => 0,
223             marc_type => 'marc21',
224             marc_field => '9520',
225         },
226         {
227             name => 'local_classification',
228             type => 'string',
229             facet => 0,
230             suggestible => 0,
231             searchable => 1,
232             sort => 1,
233             marc_type => 'marc21',
234             marc_field => '952o',
235         },
236         {
237             name => 'type_of_record',
238             type => 'string',
239             facet => 0,
240             suggestible => 0,
241             searchable => 1,
242             sort => 0,
243             marc_type => 'marc21',
244             marc_field => 'leader_/6',
245         },
246         {
247             name => 'type_of_record_and_bib_level',
248             type => 'string',
249             facet => 0,
250             suggestible => 0,
251             searchable => 1,
252             sort => 0,
253             marc_type => 'marc21',
254             marc_field => 'leader_/6-7',
255         },
256         {
257             name => 'ff7-00',
258             type => 'string',
259             facet => 0,
260             suggestible => 0,
261             searchable => 1,
262             sort => 0,
263             marc_type => 'marc21',
264             marc_field => '007_/0',
265         },
266     );
267
268     my $se = Test::MockModule->new('Koha::SearchEngine::Elasticsearch');
269     $se->mock('_foreach_mapping', sub {
270         my ($self, $sub) = @_;
271
272         foreach my $map (@mappings) {
273             $sub->(
274                 $map->{name},
275                 $map->{type},
276                 $map->{facet},
277                 $map->{suggestible},
278                 $map->{sort},
279                 $map->{searchable},
280                 $map->{marc_type},
281                 $map->{marc_field}
282             );
283         }
284     });
285
286     my $see = Koha::SearchEngine::Elasticsearch::Search->new({ index => $Koha::SearchEngine::Elasticsearch::BIBLIOS_INDEX });
287
288     my $callno = 'ABC123';
289     my $callno2 = 'ABC456';
290     my $long_callno = '1234567890' x 30;
291
292     my $marc_record_1 = MARC::Record->new();
293     $marc_record_1->leader('     cam  22      a 4500');
294     $marc_record_1->append_fields(
295         MARC::Field->new('001', '123'),
296         MARC::Field->new('007', 'ku'),
297         MARC::Field->new('020', '', '', a => '1-56619-909-3'),
298         MARC::Field->new('100', '', '', a => 'Author 1'),
299         MARC::Field->new('110', '', '', a => 'Corp Author'),
300         MARC::Field->new('210', '', '', a => 'Title 1'),
301         MARC::Field->new('245', '', '', a => 'Title:', b => 'first record'),
302         MARC::Field->new('999', '', '', c => '1234567'),
303         # '  ' for testing trimming of white space in boolean value callback:
304         MARC::Field->new('952', '', '', 0 => '  ', g => '123.30', o => $callno),
305         MARC::Field->new('952', '', '', 0 => 0, g => '127.20', o => $callno2),
306         MARC::Field->new('952', '', '', 0 => 1, g => '0.00', o => $long_callno),
307     );
308     my $marc_record_2 = MARC::Record->new();
309     $marc_record_2->leader('     cam  22      a 4500');
310     $marc_record_2->append_fields(
311         MARC::Field->new('100', '', '', a => 'Author 2'),
312         # MARC::Field->new('210', '', '', a => 'Title 2'),
313         # MARC::Field->new('245', '', '', a => 'Title: second record'),
314         MARC::Field->new('999', '', '', c => '1234568'),
315         MARC::Field->new('952', '', '', 0 => 1, g => 'string where should be numeric', o => $long_callno),
316     );
317     my $records = [$marc_record_1, $marc_record_2];
318
319     $see->get_elasticsearch_mappings(); #sort_fields will call this and use the actual db values unless we call it first
320
321     my $docs = $see->marc_records_to_documents($records);
322
323     # First record:
324     is(scalar @{$docs}, 2, 'Two records converted to documents');
325     is($docs->[0][0], '1234567', 'First document biblionumber should be set as first element in document touple');
326
327     is_deeply($docs->[0][1]->{control_number}, ['123'], 'First record control number should be set correctly');
328
329     is_deeply($docs->[0][1]->{'ff7-00'}, ['k'], 'First record ff7-00 should be set correctly');
330
331     is(scalar @{$docs->[0][1]->{author}}, 2, 'First document author field should contain two values');
332     is_deeply($docs->[0][1]->{author}, ['Author 1', 'Corp Author'], 'First document author field should be set correctly');
333
334     is(scalar @{$docs->[0][1]->{author__sort}}, 1, 'First document author__sort field should have a single value');
335     is_deeply($docs->[0][1]->{author__sort}, ['Author 1 Corp Author'], 'First document author__sort field should be set correctly');
336
337     is(scalar @{$docs->[0][1]->{title__sort}}, 1, 'First document title__sort field should have a single');
338     is_deeply($docs->[0][1]->{title__sort}, ['Title: first record Title: first record'], 'First document title__sort field should be set correctly');
339
340     is(scalar @{$docs->[0][1]->{title_wildcard}}, 2, 'First document title_wildcard field should have two values');
341     is_deeply($docs->[0][1]->{title_wildcard}, ['Title:', 'first record'], 'First document title_wildcard field should be set correctly');
342
343     is(scalar @{$docs->[0][1]->{author__suggestion}}, 2, 'First document author__suggestion field should contain two values');
344     is_deeply(
345         $docs->[0][1]->{author__suggestion},
346         [
347             {
348                 'input' => 'Author 1'
349             },
350             {
351                 'input' => 'Corp Author'
352             }
353         ],
354         'First document author__suggestion field should be set correctly'
355     );
356
357     is(scalar @{$docs->[0][1]->{title__suggestion}}, 3, 'First document title__suggestion field should contain three values');
358     is_deeply(
359         $docs->[0][1]->{title__suggestion},
360         [
361             { 'input' => 'Title:' },
362             { 'input' => 'first record' },
363             { 'input' => 'Title: first record' }
364         ],
365         'First document title__suggestion field should be set correctly'
366     );
367
368     ok(!(defined $docs->[0][1]->{title__facet}), 'First document should have no title__facet field');
369
370     is(scalar @{$docs->[0][1]->{author__facet}}, 2, 'First document author__facet field should have two values');
371     is_deeply(
372         $docs->[0][1]->{author__facet},
373         ['Author 1', 'Corp Author'],
374         'First document author__facet field should be set correctly'
375     );
376
377     is(scalar @{$docs->[0][1]->{items_withdrawn_status}}, 2, 'First document items_withdrawn_status field should have two values');
378     is_deeply(
379         $docs->[0][1]->{items_withdrawn_status},
380         ['false', 'true'],
381         'First document items_withdrawn_status field should be set correctly'
382     );
383
384     is(
385         $docs->[0][1]->{sum_item_price},
386         '250.5',
387         'First document sum_item_price field should be set correctly'
388     );
389
390     ok(defined $docs->[0][1]->{marc_data}, 'First document marc_data field should be set');
391     ok(defined $docs->[0][1]->{marc_format}, 'First document marc_format field should be set');
392     is($docs->[0][1]->{marc_format}, 'base64ISO2709', 'First document marc_format should be set correctly');
393
394     my $decoded_marc_record = $see->decode_record_from_result($docs->[0][1]);
395
396     ok($decoded_marc_record->isa('MARC::Record'), "base64ISO2709 record successfully decoded from result");
397     is($decoded_marc_record->as_usmarc(), $marc_record_1->as_usmarc(), "Decoded base64ISO2709 record has same data as original record");
398
399     is(scalar @{$docs->[0][1]->{type_of_record}}, 1, 'First document type_of_record field should have one value');
400     is_deeply(
401         $docs->[0][1]->{type_of_record},
402         ['a'],
403         'First document type_of_record field should be set correctly'
404     );
405
406     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');
407     is_deeply(
408         $docs->[0][1]->{type_of_record_and_bib_level},
409         ['am'],
410         'First document type_of_record_and_bib_level field should be set correctly'
411     );
412
413     is(scalar @{$docs->[0][1]->{isbn}}, 4, 'First document isbn field should contain four values');
414     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');
415
416     is_deeply(
417         $docs->[0][1]->{'local_classification'},
418         [$callno, $callno2, $long_callno],
419         'First document local_classification field should be set correctly'
420     );
421
422     # Second record:
423
424     is(scalar @{$docs->[1][1]->{author}}, 1, 'Second document author field should contain one value');
425     is_deeply($docs->[1][1]->{author}, ['Author 2'], 'Second document author field should be set correctly');
426
427     is(scalar @{$docs->[1][1]->{items_withdrawn_status}}, 1, 'Second document items_withdrawn_status field should have one value');
428     is_deeply(
429         $docs->[1][1]->{items_withdrawn_status},
430         ['true'],
431         'Second document items_withdrawn_status field should be set correctly'
432     );
433
434     is(
435         $docs->[1][1]->{sum_item_price},
436         0,
437         'Second document sum_item_price field should be set correctly'
438     );
439
440     is_deeply(
441         $docs->[1][1]->{local_classification__sort},
442         [substr($long_callno, 0, 255)],
443         'Second document local_classification__sort field should be set correctly'
444     );
445
446     # Mappings marc_type:
447
448     ok(!(defined $docs->[0][1]->{unimarc_title}), "No mapping when marc_type doesn't match marc flavour");
449
450     # Marc serialization format fallback for records exceeding ISO2709 max record size
451
452     my $large_marc_record = MARC::Record->new();
453     $large_marc_record->leader('     cam  22      a 4500');
454
455     $large_marc_record->append_fields(
456         MARC::Field->new('100', '', '', a => 'Author 1'),
457         MARC::Field->new('110', '', '', a => 'Corp Author'),
458         MARC::Field->new('210', '', '', a => 'Title 1'),
459         MARC::Field->new('245', '', '', a => 'Title:', b => 'large record'),
460         MARC::Field->new('999', '', '', c => '1234567'),
461     );
462
463     my $item_field = MARC::Field->new('952', '', '', o => '123456789123456789123456789', p => '123456789', z => 'test');
464     my $items_count = 1638;
465     while(--$items_count) {
466         $large_marc_record->append_fields($item_field);
467     }
468
469     $docs = $see->marc_records_to_documents([$large_marc_record]);
470
471     is($docs->[0][1]->{marc_format}, 'MARCXML', 'For record exceeding max record size marc_format should be set correctly');
472
473     $decoded_marc_record = $see->decode_record_from_result($docs->[0][1]);
474
475     ok($decoded_marc_record->isa('MARC::Record'), "MARCXML record successfully decoded from result");
476     is($decoded_marc_record->as_xml_record(), $large_marc_record->as_xml_record(), "Decoded MARCXML record has same data as original record");
477
478     push @mappings, {
479         name => 'title',
480         type => 'string',
481         facet => 0,
482         suggestible => 1,
483         sort => 1,
484         marc_type => 'marc21',
485         marc_field => '245((ab)ab',
486     };
487
488     my $exception = try {
489         $see->marc_records_to_documents($records);
490     }
491     catch {
492         return $_;
493     };
494
495     ok(defined $exception, "Exception has been thrown when processing mapping with unmatched opening parenthesis");
496     ok($exception->isa("Koha::Exceptions::Elasticsearch::MARCFieldExprParseError"), "Exception is of correct class");
497     ok($exception->message =~ /Unmatched opening parenthesis/, "Exception has the correct message");
498
499     pop @mappings;
500     push @mappings, {
501         name => 'title',
502         type => 'string',
503         facet => 0,
504         suggestible => 1,
505         sort => 1,
506         marc_type => 'marc21',
507         marc_field => '245(ab))ab',
508     };
509
510     $exception = try {
511         $see->marc_records_to_documents($records);
512     }
513     catch {
514         return $_;
515     };
516
517     ok(defined $exception, "Exception has been thrown when processing mapping with unmatched closing parenthesis");
518     ok($exception->isa("Koha::Exceptions::Elasticsearch::MARCFieldExprParseError"), "Exception is of correct class");
519     ok($exception->message =~ /Unmatched closing parenthesis/, "Exception has the correct message");
520 };
521
522 subtest 'Koha::SearchEngine::Elasticsearch::marc_records_to_documents_array () tests' => sub {
523
524     plan tests => 6;
525
526     t::lib::Mocks::mock_preference('marcflavour', 'MARC21');
527     t::lib::Mocks::mock_preference('ElasticsearchMARCFormat', 'ARRAY');
528
529     my @mappings = (
530         {
531             name => 'control_number',
532             type => 'string',
533             facet => 0,
534             suggestible => 0,
535             sort => undef,
536             searchable => 1,
537             marc_type => 'marc21',
538             marc_field => '001',
539         }
540     );
541
542     my $se = Test::MockModule->new('Koha::SearchEngine::Elasticsearch');
543     $se->mock('_foreach_mapping', sub {
544         my ($self, $sub) = @_;
545
546         foreach my $map (@mappings) {
547             $sub->(
548                 $map->{name},
549                 $map->{type},
550                 $map->{facet},
551                 $map->{suggestible},
552                 $map->{sort},
553                 $map->{searchable},
554                 $map->{marc_type},
555                 $map->{marc_field}
556             );
557         }
558     });
559
560     my $see = Koha::SearchEngine::Elasticsearch::Search->new({ index => $Koha::SearchEngine::Elasticsearch::BIBLIOS_INDEX });
561
562     my $marc_record_1 = MARC::Record->new();
563     $marc_record_1->leader('     cam  22      a 4500');
564     $marc_record_1->append_fields(
565         MARC::Field->new('001', '123'),
566         MARC::Field->new('020', '', '', a => '1-56619-909-3'),
567         MARC::Field->new('100', '', '', a => 'Author 1'),
568         MARC::Field->new('110', '', '', a => 'Corp Author'),
569         MARC::Field->new('210', '', '', a => 'Title 1'),
570         MARC::Field->new('245', '', '', a => 'Title:', b => 'first record'),
571         MARC::Field->new('999', '', '', c => '1234567'),
572     );
573     my $marc_record_2 = MARC::Record->new();
574     $marc_record_2->leader('     cam  22      a 4500');
575     $marc_record_2->append_fields(
576         MARC::Field->new('100', '', '', a => 'Author 2'),
577         # MARC::Field->new('210', '', '', a => 'Title 2'),
578         # MARC::Field->new('245', '', '', a => 'Title: second record'),
579         MARC::Field->new('999', '', '', c => '1234568'),
580         MARC::Field->new('952', '', '', 0 => 1, g => 'string where should be numeric'),
581     );
582     my $records = [$marc_record_1, $marc_record_2];
583
584     $see->get_elasticsearch_mappings(); #sort_fields will call this and use the actual db values unless we call it first
585
586     my $docs = $see->marc_records_to_documents($records);
587
588     # First record:
589     is(scalar @{$docs}, 2, 'Two records converted to documents');
590
591     is($docs->[0][0], '1234567', 'First document biblionumber should be set as first element in document touple');
592
593     is_deeply($docs->[0][1]->{control_number}, ['123'], 'First record control number should be set correctly');
594
595     is($docs->[0][1]->{marc_format}, 'ARRAY', 'First document marc_format should be set correctly');
596
597     my $decoded_marc_record = $see->decode_record_from_result($docs->[0][1]);
598
599     ok($decoded_marc_record->isa('MARC::Record'), "ARRAY record successfully decoded from result");
600     is($decoded_marc_record->as_usmarc(), $marc_record_1->as_usmarc(), "Decoded ARRAY record has same data as original record");
601 };