3 # This file is part of Koha.
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.
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.
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>.
20 use Test::More tests => 6;
24 use t::lib::TestBuilder;
30 use List::Util qw( any );
32 use Koha::SearchEngine::Elasticsearch;
33 use Koha::SearchEngine::Elasticsearch::Search;
35 my $schema = Koha::Database->new->schema;
36 $schema->storage->txn_begin;
38 subtest '_read_configuration() tests' => sub {
43 t::lib::Mocks::mock_config( 'elasticsearch', undef );
45 # 'elasticsearch' missing in configuration
47 $configuration = Koha::SearchEngine::Elasticsearch::_read_configuration;
49 'Koha::Exceptions::Config::MissingEntry',
50 'Configuration problem, exception thrown';
53 "Missing <elasticsearch> entry in koha-conf.xml",
54 'Exception message is correct'
57 # 'elasticsearch' present but no 'server' entry
58 t::lib::Mocks::mock_config( 'elasticsearch', {} );
60 $configuration = Koha::SearchEngine::Elasticsearch::_read_configuration;
62 'Koha::Exceptions::Config::MissingEntry',
63 'Configuration problem, exception thrown';
66 "Missing <elasticsearch>/<server> entry in koha-conf.xml",
67 'Exception message is correct'
70 # 'elasticsearch' and 'server' entries present, but no 'index_name'
71 t::lib::Mocks::mock_config( 'elasticsearch', { server => 'a_server' } );
73 $configuration = Koha::SearchEngine::Elasticsearch::_read_configuration;
75 'Koha::Exceptions::Config::MissingEntry',
76 'Configuration problem, exception thrown';
79 "Missing <elasticsearch>/<index_name> entry in koha-conf.xml",
80 'Exception message is correct'
83 # Correct configuration, only one server
84 t::lib::Mocks::mock_config( 'elasticsearch', { server => 'a_server', index_name => 'index' } );
86 $configuration = Koha::SearchEngine::Elasticsearch::_read_configuration;
87 is( $configuration->{index_name}, 'index', 'Index configuration parsed correctly' );
88 is_deeply( $configuration->{nodes}, ['a_server'], 'Server configuration parsed correctly' );
90 # Correct configuration, two servers
91 my @servers = ('a_server', 'another_server');
92 t::lib::Mocks::mock_config( 'elasticsearch', { server => \@servers, index_name => 'index' } );
94 $configuration = Koha::SearchEngine::Elasticsearch::_read_configuration;
95 is( $configuration->{index_name}, 'index', 'Index configuration parsed correctly' );
96 is_deeply( $configuration->{nodes}, \@servers , 'Server configuration parsed correctly' );
99 subtest 'get_elasticsearch_settings() tests' => sub {
105 # test reading index settings
106 my $es = Koha::SearchEngine::Elasticsearch->new( {index => $Koha::SearchEngine::Elasticsearch::BIBLIOS_INDEX} );
107 $settings = $es->get_elasticsearch_settings();
108 is( $settings->{index}{analysis}{analyzer}{analyzer_phrase}{tokenizer}, 'keyword', 'Index settings parsed correctly' );
111 subtest 'get_elasticsearch_mappings() tests' => sub {
117 # test reading mappings
118 my $es = Koha::SearchEngine::Elasticsearch->new( {index => $Koha::SearchEngine::Elasticsearch::BIBLIOS_INDEX} );
119 $mappings = $es->get_elasticsearch_mappings();
120 is( $mappings->{data}{properties}{isbn__sort}{index}, 'false', 'Field mappings parsed correctly' );
123 subtest 'Koha::SearchEngine::Elasticsearch::marc_records_to_documents () tests' => sub {
127 t::lib::Mocks::mock_preference('marcflavour', 'MARC21');
128 t::lib::Mocks::mock_preference('ElasticsearchMARCFormat', 'ISO2709');
132 name => 'control_number',
138 marc_type => 'marc21',
148 marc_type => 'marc21',
149 marc_field => '020a',
158 marc_type => 'marc21',
159 marc_field => '100a',
168 marc_type => 'marc21',
169 marc_field => '110a',
178 marc_type => 'marc21',
179 marc_field => '245(ab)ab',
182 name => 'unimarc_title',
188 marc_type => 'unimarc',
189 marc_field => '245a',
195 suggestible => undef,
198 marc_type => 'marc21',
202 name => 'uniform_title',
208 marc_type => 'marc21',
209 marc_field => '240a',
212 name => 'title_wildcard',
218 marc_type => 'marc21',
222 name => 'sum_item_price',
228 marc_type => 'marc21',
229 marc_field => '952g',
232 name => 'items_withdrawn_status',
238 marc_type => 'marc21',
239 marc_field => '9520',
242 name => 'local_classification',
248 marc_type => 'marc21',
249 marc_field => '952o',
252 name => 'type_of_record',
258 marc_type => 'marc21',
259 marc_field => 'leader_/6',
262 name => 'type_of_record_and_bib_level',
268 marc_type => 'marc21',
269 marc_field => 'leader_/6-7',
278 marc_type => 'marc21',
279 marc_field => '007_/0',
288 marc_type => 'marc21',
289 marc_field => '952l',
293 my $se = Test::MockModule->new('Koha::SearchEngine::Elasticsearch');
294 $se->mock('_foreach_mapping', sub {
295 my ($self, $sub) = @_;
297 foreach my $map (@mappings) {
311 my $see = Koha::SearchEngine::Elasticsearch::Search->new({ index => $Koha::SearchEngine::Elasticsearch::BIBLIOS_INDEX });
313 my $callno = 'ABC123';
314 my $callno2 = 'ABC456';
315 my $long_callno = '1234567890' x 30;
317 my $marc_record_1 = MARC::Record->new();
318 $marc_record_1->leader(' cam 22 a 4500');
319 $marc_record_1->append_fields(
320 MARC::Field->new('001', '123'),
321 MARC::Field->new('007', 'ku'),
322 MARC::Field->new('020', '', '', a => '1-56619-909-3'),
323 MARC::Field->new('100', '', '', a => 'Author 1'),
324 MARC::Field->new('110', '', '', a => 'Corp Author'),
325 MARC::Field->new('210', '', '', a => 'Title 1'),
326 MARC::Field->new('240', '', '4', a => 'The uniform title with nonfiling indicator'),
327 MARC::Field->new('245', '', '', a => 'Title:', b => 'first record'),
328 MARC::Field->new('999', '', '', c => '1234567'),
329 # ' ' for testing trimming of white space in boolean value callback:
330 MARC::Field->new('952', '', '', 0 => ' ', g => '123.30', o => $callno, l => 3),
331 MARC::Field->new('952', '', '', 0 => 0, g => '127.20', o => $callno2, l => 2),
332 MARC::Field->new('952', '', '', 0 => 1, g => '0.00', o => $long_callno, l => 1),
334 my $marc_record_2 = MARC::Record->new();
335 $marc_record_2->leader(' cam 22 a 4500');
336 $marc_record_2->append_fields(
337 MARC::Field->new('100', '', '', a => 'Author 2'),
338 # MARC::Field->new('210', '', '', a => 'Title 2'),
339 # MARC::Field->new('245', '', '', a => 'Title: second record'),
340 MARC::Field->new('999', '', '', c => '1234568'),
341 MARC::Field->new('952', '', '', 0 => 1, g => 'string where should be numeric', o => $long_callno),
343 my $records = [ $marc_record_1, $marc_record_2 ];
345 $see->get_elasticsearch_mappings(); #sort_fields will call this and use the actual db values unless we call it first
347 my $docs = $see->marc_records_to_documents($records);
350 is(scalar @{$docs}, 2, 'Two records converted to documents');
352 is_deeply($docs->[0]->{control_number}, ['123'], 'First record control number should be set correctly');
354 is_deeply($docs->[0]->{'ff7-00'}, ['k'], 'First record ff7-00 should be set correctly');
356 is(scalar @{$docs->[0]->{author}}, 2, 'First document author field should contain two values');
357 is_deeply($docs->[0]->{author}, ['Author 1', 'Corp Author'], 'First document author field should be set correctly');
359 is(scalar @{$docs->[0]->{author__sort}}, 1, 'First document author__sort field should have a single value');
360 is_deeply($docs->[0]->{author__sort}, ['Author 1 Corp Author'], 'First document author__sort field should be set correctly');
362 is(scalar @{$docs->[0]->{title__sort}}, 1, 'First document title__sort field should have a single');
363 is_deeply($docs->[0]->{title__sort}, ['Title: first record Title: first record'], 'First document title__sort field should be set correctly');
365 is($docs->[0]->{issues}, 6, 'Issues field should be sum of the issues for each item');
366 is($docs->[0]->{issues__sort}, 6, 'Issues sort field should also be a sum of the issues');
368 is(scalar @{$docs->[0]->{title_wildcard}}, 2, 'First document title_wildcard field should have two values');
369 is_deeply($docs->[0]->{title_wildcard}, ['Title:', 'first record'], 'First document title_wildcard field should be set correctly');
372 is(scalar @{$docs->[0]->{author__suggestion}}, 2, 'First document author__suggestion field should contain two values');
374 $docs->[0]->{author__suggestion},
377 'input' => 'Author 1'
380 'input' => 'Corp Author'
383 'First document author__suggestion field should be set correctly'
386 is(scalar @{$docs->[0]->{title__suggestion}}, 3, 'First document title__suggestion field should contain three values');
388 $docs->[0]->{title__suggestion},
390 { 'input' => 'Title:' },
391 { 'input' => 'first record' },
392 { 'input' => 'Title: first record' }
394 'First document title__suggestion field should be set correctly'
397 ok(!(defined $docs->[0]->{title__facet}), 'First document should have no title__facet field');
399 is(scalar @{$docs->[0]->{author__facet}}, 2, 'First document author__facet field should have two values');
401 $docs->[0]->{author__facet},
402 ['Author 1', 'Corp Author'],
403 'First document author__facet field should be set correctly'
406 is(scalar @{$docs->[0]->{items_withdrawn_status}}, 2, 'First document items_withdrawn_status field should have two values');
408 $docs->[0]->{items_withdrawn_status},
410 'First document items_withdrawn_status field should be set correctly'
414 $docs->[0]->{sum_item_price},
416 'First document sum_item_price field should be set correctly'
419 ok(defined $docs->[0]->{marc_data}, 'First document marc_data field should be set');
420 ok(defined $docs->[0]->{marc_format}, 'First document marc_format field should be set');
421 is($docs->[0]->{marc_format}, 'base64ISO2709', 'First document marc_format should be set correctly');
423 my $decoded_marc_record = $see->decode_record_from_result($docs->[0]);
425 ok($decoded_marc_record->isa('MARC::Record'), "base64ISO2709 record successfully decoded from result");
426 is($decoded_marc_record->as_usmarc(), $marc_record_1->as_usmarc(), "Decoded base64ISO2709 record has same data as original record");
428 is(scalar @{$docs->[0]->{type_of_record}}, 1, 'First document type_of_record field should have one value');
430 $docs->[0]->{type_of_record},
432 'First document type_of_record field should be set correctly'
435 is(scalar @{$docs->[0]->{type_of_record_and_bib_level}}, 1, 'First document type_of_record_and_bib_level field should have one value');
437 $docs->[0]->{type_of_record_and_bib_level},
439 'First document type_of_record_and_bib_level field should be set correctly'
442 is(scalar @{$docs->[0]->{isbn}}, 4, 'First document isbn field should contain four values');
443 is_deeply($docs->[0]->{isbn}, ['978-1-56619-909-4', '9781566199094', '1-56619-909-3', '1566199093'], 'First document isbn field should be set correctly');
446 $docs->[0]->{'local_classification'},
447 [$callno, $callno2, $long_callno],
448 'First document local_classification field should be set correctly'
451 # Nonfiling characters for sort fields
453 $docs->[0]->{uniform_title},
454 ['The uniform title with nonfiling indicator'],
455 'First document uniform_title field should contain the title verbatim'
458 $docs->[0]->{uniform_title__sort},
459 ['uniform title with nonfiling indicator'],
460 'First document uniform_title__sort field should contain the title with the first four initial characters removed'
465 is(scalar @{$docs->[1]->{author}}, 1, 'Second document author field should contain one value');
466 is_deeply($docs->[1]->{author}, ['Author 2'], 'Second document author field should be set correctly');
468 is(scalar @{$docs->[1]->{items_withdrawn_status}}, 1, 'Second document items_withdrawn_status field should have one value');
470 $docs->[1]->{items_withdrawn_status},
472 'Second document items_withdrawn_status field should be set correctly'
476 $docs->[1]->{sum_item_price},
478 'Second document sum_item_price field should be set correctly'
482 $docs->[1]->{local_classification__sort},
483 [substr($long_callno, 0, 255)],
484 'Second document local_classification__sort field should be set correctly'
487 # Mappings marc_type:
489 ok(!(defined $docs->[0]->{unimarc_title}), "No mapping when marc_type doesn't match marc flavour");
491 # Marc serialization format fallback for records exceeding ISO2709 max record size
493 my $large_marc_record = MARC::Record->new();
494 $large_marc_record->leader(' cam 22 a 4500');
496 $large_marc_record->append_fields(
497 MARC::Field->new('100', '', '', a => 'Author 1'),
498 MARC::Field->new('110', '', '', a => 'Corp Author'),
499 MARC::Field->new('210', '', '', a => 'Title 1'),
500 MARC::Field->new('245', '', '', a => 'Title:', b => 'large record'),
501 MARC::Field->new('999', '', '', c => '1234567'),
504 my $item_field = MARC::Field->new('952', '', '', o => '123456789123456789123456789', p => '123456789', z => 'test');
505 my $items_count = 1638;
506 while(--$items_count) {
507 $large_marc_record->append_fields($item_field);
510 $docs = $see->marc_records_to_documents([$large_marc_record]);
512 is($docs->[0]->{marc_format}, 'MARCXML', 'For record exceeding max record size marc_format should be set correctly');
514 $decoded_marc_record = $see->decode_record_from_result($docs->[0]);
516 ok($decoded_marc_record->isa('MARC::Record'), "MARCXML record successfully decoded from result");
517 is($decoded_marc_record->as_xml_record(), $large_marc_record->as_xml_record(), "Decoded MARCXML record has same data as original record");
525 marc_type => 'marc21',
526 marc_field => '245((ab)ab',
529 my $exception = try {
530 $see->marc_records_to_documents($records);
536 ok(defined $exception, "Exception has been thrown when processing mapping with unmatched opening parenthesis");
537 ok($exception->isa("Koha::Exceptions::Elasticsearch::MARCFieldExprParseError"), "Exception is of correct class");
538 ok($exception->message =~ /Unmatched opening parenthesis/, "Exception has the correct message");
547 marc_type => 'marc21',
548 marc_field => '245(ab))ab',
552 $see->marc_records_to_documents($records);
558 ok(defined $exception, "Exception has been thrown when processing mapping with unmatched closing parenthesis");
559 ok($exception->isa("Koha::Exceptions::Elasticsearch::MARCFieldExprParseError"), "Exception is of correct class");
560 ok($exception->message =~ /Unmatched closing parenthesis/, "Exception has the correct message");
563 subtest 'Koha::SearchEngine::Elasticsearch::marc_records_to_documents_array () tests' => sub {
567 t::lib::Mocks::mock_preference('marcflavour', 'MARC21');
568 t::lib::Mocks::mock_preference('ElasticsearchMARCFormat', 'ARRAY');
572 name => 'control_number',
578 marc_type => 'marc21',
583 my $se = Test::MockModule->new('Koha::SearchEngine::Elasticsearch');
584 $se->mock('_foreach_mapping', sub {
585 my ($self, $sub) = @_;
587 foreach my $map (@mappings) {
601 my $see = Koha::SearchEngine::Elasticsearch::Search->new({ index => $Koha::SearchEngine::Elasticsearch::BIBLIOS_INDEX });
603 my $marc_record_1 = MARC::Record->new();
604 $marc_record_1->leader(' cam 22 a 4500');
605 $marc_record_1->append_fields(
606 MARC::Field->new('001', '123'),
607 MARC::Field->new('020', '', '', a => '1-56619-909-3'),
608 MARC::Field->new('100', '', '', a => 'Author 1'),
609 MARC::Field->new('110', '', '', a => 'Corp Author'),
610 MARC::Field->new('210', '', '', a => 'Title 1'),
611 MARC::Field->new('245', '', '', a => 'Title:', b => 'first record'),
612 MARC::Field->new('999', '', '', c => '1234567'),
614 my $marc_record_2 = MARC::Record->new();
615 $marc_record_2->leader(' cam 22 a 4500');
616 $marc_record_2->append_fields(
617 MARC::Field->new('100', '', '', a => 'Author 2'),
618 # MARC::Field->new('210', '', '', a => 'Title 2'),
619 # MARC::Field->new('245', '', '', a => 'Title: second record'),
620 MARC::Field->new('999', '', '', c => '1234568'),
621 MARC::Field->new('952', '', '', 0 => 1, g => 'string where should be numeric'),
623 my $records = [ $marc_record_1, $marc_record_2 ];
625 $see->get_elasticsearch_mappings(); #sort_fields will call this and use the actual db values unless we call it first
627 my $docs = $see->marc_records_to_documents($records);
630 is(scalar @{$docs}, 2, 'Two records converted to documents');
632 is_deeply($docs->[0]->{control_number}, ['123'], 'First record control number should be set correctly');
634 is($docs->[0]->{marc_format}, 'ARRAY', 'First document marc_format should be set correctly');
636 my $decoded_marc_record = $see->decode_record_from_result($docs->[0]);
638 ok($decoded_marc_record->isa('MARC::Record'), "ARRAY record successfully decoded from result");
639 is($decoded_marc_record->as_usmarc(), $marc_record_1->as_usmarc(), "Decoded ARRAY record has same data as original record");
642 subtest 'Koha::SearchEngine::Elasticsearch::marc_records_to_documents () authority tests' => sub {
646 t::lib::Mocks::mock_preference('marcflavour', 'MARC21');
647 t::lib::Mocks::mock_preference('ElasticsearchMARCFormat', 'ISO2709');
649 my $builder = t::lib::TestBuilder->new;
650 my $auth_type = $builder->build_object({ class => 'Koha::Authority::Types', value =>{
651 auth_tag_to_report => '150'
663 marc_type => 'marc21',
664 marc_field => '150(ae)',
668 my $se = Test::MockModule->new('Koha::SearchEngine::Elasticsearch');
669 $se->mock('_foreach_mapping', sub {
670 my ($self, $sub) = @_;
672 foreach my $map (@mappings) {
686 my $see = Koha::SearchEngine::Elasticsearch::Search->new({ index => $Koha::SearchEngine::Elasticsearch::AUTHORITIES_INDEX });
687 my $marc_record_1 = MARC::Record->new();
688 $marc_record_1->append_fields(
689 MARC::Field->new('001', '123'),
690 MARC::Field->new('007', 'ku'),
691 MARC::Field->new('020', '', '', a => '1-56619-909-3'),
692 MARC::Field->new('150', '', '', a => 'Subject', v => 'Genresubdiv', x => 'Generalsubdiv', z => 'Geosubdiv'),
694 my $marc_record_2 = MARC::Record->new();
695 $marc_record_2->append_fields(
696 MARC::Field->new('150', '', '', a => 'Subject', v => 'Genresubdiv', z => 'Geosubdiv', x => 'Generalsubdiv', e => 'wrongsubdiv' ),
698 my $records = [ $marc_record_1, $marc_record_2 ];
700 $see->get_elasticsearch_mappings(); #sort_fields will call this and use the actual db values unless we call it first
702 my $docs = $see->marc_records_to_documents($records);
705 any { $_ eq "Subject formsubdiv Genresubdiv generalsubdiv Generalsubdiv geographicsubdiv Geosubdiv" }
706 @{$docs->[0]->{'match-heading'}},
707 "First record match-heading should contain the correctly formatted heading"
710 any { $_ eq "Subject formsubdiv Genresubdiv geographicsubdiv Geosubdiv generalsubdiv Generalsubdiv" }
711 @{$docs->[1]->{'match-heading'}},
712 "Second record match-heading should contain the correctly formatted heading without wrong subfield"
716 $schema->storage->txn_rollback;