3 # Copyright 2020 Koha Development team
5 # This file is part of Koha
7 # Koha is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
23 use Test::More tests => 7;
24 use Test::Deep qw( cmp_methods );
27 use Koha::CirculationRules;
31 use t::lib::TestBuilder;
32 use Koha::Cache::Memory::Lite;
34 my $schema = Koha::Database->new->schema;
35 my $builder = t::lib::TestBuilder->new;
37 subtest 'get_effective_issuing_rule' => sub {
40 $schema->storage->txn_begin;
42 my $categorycode = $builder->build({ source => 'Category' })->{'categorycode'};
43 my $itemtype = $builder->build({ source => 'Itemtype' })->{'itemtype'};
44 my $branchcode = $builder->build({ source => 'Branch' })->{'branchcode'};
46 subtest 'Call with undefined values' => sub {
50 Koha::CirculationRules->delete;
52 is(Koha::CirculationRules->search->count, 0, 'There are no issuing rules.');
53 # undef, undef, undef => 1
54 $rule = Koha::CirculationRules->get_effective_rule({
56 categorycode => undef,
61 is($rule, undef, 'When I attempt to get effective issuing rule by'
62 .' providing undefined values, then undef is returned.');
64 # undef, undef, undef => 2
66 Koha::CirculationRule->new(
69 categorycode => undef,
75 'Given I added an issuing rule branchcode => undef,'
76 .' categorycode => undef, itemtype => undef,');
77 $rule = Koha::CirculationRules->get_effective_rule({
79 categorycode => undef,
87 categorycode => undef,
92 'When I attempt to get effective'
93 .' issuing rule by providing undefined values, then the above one is'
98 subtest 'Performance' => sub {
101 my $worst_case = timethis(500,
102 sub { Koha::CirculationRules->get_effective_rule({
103 branchcode => 'nonexistent',
104 categorycode => 'nonexistent',
105 itemtype => 'nonexistent',
106 rule_name => 'nonexistent',
110 my $mid_case = timethis(500,
111 sub { Koha::CirculationRules->get_effective_rule({
112 branchcode => $branchcode,
113 categorycode => 'nonexistent',
114 itemtype => 'nonexistent',
115 rule_name => 'nonexistent',
119 my $sec_best_case = timethis(500,
120 sub { Koha::CirculationRules->get_effective_rule({
121 branchcode => $branchcode,
122 categorycode => $categorycode,
123 itemtype => 'nonexistent',
124 rule_name => 'nonexistent',
128 my $best_case = timethis(500,
129 sub { Koha::CirculationRules->get_effective_rule({
130 branchcode => $branchcode,
131 categorycode => $categorycode,
132 itemtype => $itemtype,
133 rule_name => 'nonexistent',
137 ok($worst_case, 'In worst case, get_effective_issuing_rule finds matching'
138 .' rule '.sprintf('%.2f', $worst_case->iters/$worst_case->cpu_a)
139 .' times per second.');
140 ok($mid_case, 'In mid case, get_effective_issuing_rule finds matching'
141 .' rule '.sprintf('%.2f', $mid_case->iters/$mid_case->cpu_a)
142 .' times per second.');
143 ok($sec_best_case, 'In second best case, get_effective_issuing_rule finds matching'
144 .' rule '.sprintf('%.2f', $sec_best_case->iters/$sec_best_case->cpu_a)
145 .' times per second.');
146 ok($best_case, 'In best case, get_effective_issuing_rule finds matching'
147 .' rule '.sprintf('%.2f', $best_case->iters/$best_case->cpu_a)
148 .' times per second.');
151 $schema->storage->txn_rollback;
155 subtest 'set_rule' => sub {
158 $schema->storage->txn_begin;
160 my $branchcode = $builder->build({ source => 'Branch' })->{'branchcode'};
161 my $categorycode = $builder->build({ source => 'Category' })->{'categorycode'};
162 my $itemtype = $builder->build({ source => 'Itemtype' })->{'itemtype'};
164 subtest 'Correct call' => sub {
167 Koha::CirculationRules->delete;
170 Koha::CirculationRules->set_rule( {
171 branchcode => $branchcode,
172 rule_name => 'lostreturn',
175 }, 'setting lostreturn with branch' );
178 Koha::CirculationRules->set_rule( {
179 branchcode => $branchcode,
180 rule_name => 'processingreturn',
183 }, 'setting processingreturn with branch' );
186 Koha::CirculationRules->set_rule( {
187 branchcode => $branchcode,
188 categorycode => $categorycode,
189 rule_name => 'patron_maxissueqty',
192 }, 'setting patron_maxissueqty with branch/category succeeds' );
195 Koha::CirculationRules->set_rule( {
196 branchcode => $branchcode,
197 itemtype => $itemtype,
198 rule_name => 'holdallowed',
201 }, 'setting holdallowed with branch/itemtype succeeds' );
204 Koha::CirculationRules->set_rule( {
205 branchcode => $branchcode,
206 categorycode => $categorycode,
207 itemtype => $itemtype,
208 rule_name => 'article_requests',
211 }, 'setting fine with branch/category/itemtype succeeds' );
214 subtest 'Call with missing params' => sub {
217 Koha::CirculationRules->delete;
220 Koha::CirculationRules->set_rule( {
221 rule_name => 'lostreturn',
224 }, qr/branchcode/, 'setting lostreturn without branch fails' );
227 Koha::CirculationRules->set_rule( {
228 rule_name => 'processingreturn',
231 }, qr/branchcode/, 'setting processingreturn without branch fails' );
234 Koha::CirculationRules->set_rule( {
235 branchcode => $branchcode,
236 rule_name => 'patron_maxissueqty',
239 }, qr/categorycode/, 'setting patron_maxissueqty without categorycode fails' );
242 Koha::CirculationRules->set_rule( {
243 branchcode => $branchcode,
244 rule_name => 'holdallowed',
247 }, qr/itemtype/, 'setting holdallowed without itemtype fails' );
250 Koha::CirculationRules->set_rule( {
251 branchcode => $branchcode,
252 categorycode => $categorycode,
256 }, qr/itemtype/, 'setting fine without itemtype fails' );
259 subtest 'Call with extra params' => sub {
262 Koha::CirculationRules->delete;
265 Koha::CirculationRules->set_rule( {
266 branchcode => $branchcode,
267 categorycode => $categorycode,
268 rule_name => 'lostreturn',
271 }, qr/categorycode/, 'setting lostreturn with categorycode fails' );
274 Koha::CirculationRules->set_rule( {
275 branchcode => $branchcode,
276 categorycode => $categorycode,
277 rule_name => 'processingreturn',
280 }, qr/categorycode/, 'setting processingreturn with categorycode fails' );
283 Koha::CirculationRules->set_rule( {
284 branchcode => $branchcode,
285 categorycode => $categorycode,
286 itemtype => $itemtype,
287 rule_name => 'patron_maxissueqty',
290 }, qr/itemtype/, 'setting patron_maxissueqty with itemtype fails' );
293 Koha::CirculationRules->set_rule( {
294 branchcode => $branchcode,
295 rule_name => 'holdallowed',
296 categorycode => $categorycode,
297 itemtype => $itemtype,
300 }, qr/categorycode/, 'setting holdallowed with categorycode fails' );
303 subtest 'Call with badly formatted params' => sub {
306 Koha::CirculationRules->delete;
308 foreach my $monetary_rule ( ( 'article_request_fee', 'fine', 'overduefinescap', 'recall_overdue_fine' ) ) {
311 Koha::CirculationRules->set_rule(
315 ( $monetary_rule ne 'article_request_fee' ? ( itemtype => '*' ) : () ),
316 rule_name => $monetary_rule,
317 rule_value => '10,00',
322 "setting $monetary_rule fails when passed value is not decimal"
327 $schema->storage->txn_rollback;
330 subtest 'clone' => sub {
333 $schema->storage->txn_begin;
335 my $branchcode = $builder->build({ source => 'Branch' })->{'branchcode'};
336 my $categorycode = $builder->build({ source => 'Category' })->{'categorycode'};
337 my $itemtype = $builder->build({ source => 'Itemtype' })->{'itemtype'};
339 subtest 'Clone multiple rules' => sub {
342 Koha::CirculationRules->delete;
344 Koha::CirculationRule->new({
346 categorycode => $categorycode,
347 itemtype => $itemtype,
352 Koha::CirculationRule->new({
354 categorycode => $categorycode,
355 itemtype => $itemtype,
356 rule_name => 'lengthunit',
357 rule_value => 'days',
360 Koha::CirculationRules->search({ branchcode => undef })->clone($branchcode);
362 my $rule_fine = Koha::CirculationRules->get_effective_rule({
363 branchcode => $branchcode,
364 categorycode => $categorycode,
365 itemtype => $itemtype,
368 my $rule_lengthunit = Koha::CirculationRules->get_effective_rule({
369 branchcode => $branchcode,
370 categorycode => $categorycode,
371 itemtype => $itemtype,
372 rule_name => 'lengthunit',
378 branchcode => $branchcode,
379 categorycode => $categorycode,
380 itemtype => $itemtype,
384 'When I attempt to get cloned fine rule,'
385 .' then the above one is returned.'
390 branchcode => $branchcode,
391 categorycode => $categorycode,
392 itemtype => $itemtype,
393 rule_name => 'lengthunit',
394 rule_value => 'days',
396 'When I attempt to get cloned lengthunit rule,'
397 .' then the above one is returned.'
402 subtest 'Clone one rule' => sub {
405 Koha::CirculationRules->delete;
407 Koha::CirculationRule->new({
409 categorycode => $categorycode,
410 itemtype => $itemtype,
415 my $rule = Koha::CirculationRules->search({ branchcode => undef })->next;
416 $rule->clone($branchcode);
418 my $cloned_rule = Koha::CirculationRules->get_effective_rule({
419 branchcode => $branchcode,
420 categorycode => $categorycode,
421 itemtype => $itemtype,
428 branchcode => $branchcode,
429 categorycode => $categorycode,
430 itemtype => $itemtype,
434 'When I attempt to get cloned fine rule,'
435 .' then the above one is returned.'
440 $schema->storage->txn_rollback;
443 subtest 'set_rule + get_effective_rule' => sub {
446 $schema->storage->txn_begin;
448 my $categorycode = $builder->build_object( { class => 'Koha::Patron::Categories' } )->categorycode;
449 my $itemtype = $builder->build_object( { class => 'Koha::ItemTypes' } )->itemtype;
450 my $branchcode = $builder->build_object( { class => 'Koha::Libraries' } )->branchcode;
451 my $branchcode_2 = $builder->build_object( { class => 'Koha::Libraries' } )->branchcode;
452 my $rule_name = 'maxissueqty';
453 my $default_rule_value = 1;
456 Koha::CirculationRules->delete;
458 throws_ok { Koha::CirculationRules->get_effective_rule }
459 'Koha::Exceptions::MissingParameter',
460 "Exception should be raised if get_effective_rule is called without rule_name parameter";
462 $rule = Koha::CirculationRules->get_effective_rule(
464 branchcode => $branchcode,
465 categorycode => $categorycode,
466 itemtype => $itemtype,
467 rule_name => $rule_name,
470 is( $rule, undef, 'Undef should be returned if no rule exist' );
472 Koha::CirculationRules->set_rule(
477 rule_name => $rule_name,
478 rule_value => $default_rule_value,
482 $rule = Koha::CirculationRules->get_effective_rule(
485 categorycode => undef,
487 rule_name => $rule_name,
490 is( $rule->rule_value, $default_rule_value, 'undef means default' );
491 $rule = Koha::CirculationRules->get_effective_rule(
496 rule_name => $rule_name,
500 is( $rule->rule_value, $default_rule_value, '* means default' );
502 $rule = Koha::CirculationRules->get_effective_rule(
504 branchcode => $branchcode_2,
507 rule_name => $rule_name,
510 is( $rule->rule_value, 1,
511 'Default rule is returned if there is no rule for this branchcode' );
513 subtest 'test rules that cannot be blank' => sub {
515 foreach my $no_blank_rule ( ('holdallowed','hold_fulfillment_policy','returnbranch') ){
516 Koha::CirculationRules->set_rule(
518 branchcode => $branchcode,
520 rule_name => $no_blank_rule,
525 $rule = Koha::CirculationRules->get_effective_rule(
527 branchcode => $branchcode,
528 categorycode => undef,
530 rule_name => $no_blank_rule,
533 is( $rule, undef, 'Rules that cannot be blank are not set when passed blank string' );
537 subtest 'test rules that can be blank' => sub {
539 foreach my $blank_rule ( ('overduefinescap') ) {
540 Koha::CirculationRules->set_rule(
542 branchcode => $branchcode,
545 rule_name => $blank_rule,
550 $rule = Koha::CirculationRules->get_effective_rule(
552 branchcode => $branchcode,
553 categorycode => undef,
555 rule_name => $blank_rule,
558 is( $rule->rule_value, '', "$blank_rule allowed to be set to blank" );
562 subtest 'test rule matching with different combinations of rule scopes' => sub {
563 my ( $tests, $order ) = _prepare_tests_for_rule_scope_combinations(
565 branchcode => $branchcode,
566 categorycode => $categorycode,
567 itemtype => $itemtype,
572 plan tests => 2**scalar @$order;
574 foreach my $test (@$tests) {
575 my $rule_params = {%$test};
576 $rule_params->{rule_name} = $rule_name;
577 my $rule_value = $rule_params->{rule_value} = int( rand(10) );
579 Koha::CirculationRules->set_rule($rule_params);
581 my $rule = Koha::CirculationRules->get_effective_rule(
583 branchcode => $branchcode,
584 categorycode => $categorycode,
585 itemtype => $itemtype,
586 rule_name => $rule_name,
590 my $scope_output = '';
591 foreach my $key ( values @$order ) {
592 $scope_output .= " $key" if $test->{$key} ne '*';
595 is( $rule->rule_value, $rule_value,
597 . ( $scope_output ? $scope_output : ' nothing' ) );
601 my $our_branch_rules = Koha::CirculationRules->search({branchcode => $branchcode});
602 is( $our_branch_rules->count, 5, "We added 9 rules");
603 $our_branch_rules->delete;
604 is( $our_branch_rules->count, 0, "We deleted 9 rules");
606 $schema->storage->txn_rollback;
609 subtest 'get_onshelfholds_policy() tests' => sub {
613 $schema->storage->txn_begin;
615 my $item = $builder->build_sample_item();
617 my $circ_rules = Koha::CirculationRules->new;
619 $circ_rules->search({ rule_name => 'onshelfholds' })->delete;
621 $circ_rules->set_rule(
626 rule_name => 'onshelfholds',
631 is( $circ_rules->get_onshelfholds_policy({ item => $item }), 1, 'If rule_value is set on a matching rule, return it' );
632 # Delete the rule (i.e. get_effective_rule returns undef)
634 is( $circ_rules->get_onshelfholds_policy({ item => $item }), 0, 'If no matching rule, fallback to 0' );
636 $schema->storage->txn_rollback;
639 subtest 'get_effective_daysmode' => sub {
642 $schema->storage->txn_begin;
644 my $item_1 = $builder->build_sample_item();
645 my $item_2 = $builder->build_sample_item();
648 Koha::CirculationRules->search( { rule_name => 'daysmode' } )->delete;
650 # Default value 'Datedue' at pref level
651 t::lib::Mocks::mock_preference( 'useDaysMode', 'Datedue' );
654 Koha::CirculationRules->get_effective_daysmode(
656 categorycode => undef,
657 itemtype => $item_1->effective_itemtype,
662 'daysmode default to pref value if the rule does not exist'
665 Koha::CirculationRules->set_rule(
670 rule_name => 'daysmode',
671 rule_value => 'Calendar',
674 Koha::CirculationRules->set_rule(
678 itemtype => $item_1->effective_itemtype,
679 rule_name => 'daysmode',
680 rule_value => 'Days',
685 Koha::CirculationRules->get_effective_daysmode(
687 categorycode => undef,
688 itemtype => $item_1->effective_itemtype,
693 "daysmode for item_1 is the specific rule"
696 Koha::CirculationRules->get_effective_daysmode(
698 categorycode => undef,
699 itemtype => $item_2->effective_itemtype,
704 "daysmode for item_2 is the one defined for the default circ rule"
707 Koha::CirculationRules->set_rule(
711 itemtype => $item_2->effective_itemtype,
712 rule_name => 'daysmode',
718 Koha::CirculationRules->get_effective_daysmode(
720 categorycode => undef,
721 itemtype => $item_2->effective_itemtype,
726 'daysmode default to pref value if the rule exists but set to""'
729 $schema->storage->txn_rollback;
732 subtest 'get_lostreturn_policy() tests' => sub {
735 $schema->storage->txn_begin;
737 $schema->resultset('CirculationRule')->search()->delete;
739 my $default_proc_rule_charge = $builder->build(
741 source => 'CirculationRule',
744 categorycode => undef,
746 rule_name => 'processingreturn',
747 rule_value => 'charge'
751 my $default_lost_rule_charge = $builder->build(
753 source => 'CirculationRule',
756 categorycode => undef,
758 rule_name => 'lostreturn',
759 rule_value => 'charge'
763 my $branchcode = $builder->build( { source => 'Branch' } )->{branchcode};
764 my $specific_lost_rule_false = $builder->build(
766 source => 'CirculationRule',
768 branchcode => $branchcode,
769 categorycode => undef,
771 rule_name => 'lostreturn',
776 my $specific_proc_rule_false = $builder->build(
778 source => 'CirculationRule',
780 branchcode => $branchcode,
781 categorycode => undef,
783 rule_name => 'processingreturn',
788 my $branchcode2 = $builder->build( { source => 'Branch' } )->{branchcode};
789 my $specific_lost_rule_refund = $builder->build(
791 source => 'CirculationRule',
793 branchcode => $branchcode2,
794 categorycode => undef,
796 rule_name => 'lostreturn',
797 rule_value => 'refund'
801 my $specific_proc_rule_refund = $builder->build(
803 source => 'CirculationRule',
805 branchcode => $branchcode2,
806 categorycode => undef,
808 rule_name => 'processingreturn',
809 rule_value => 'refund'
813 my $branchcode3 = $builder->build( { source => 'Branch' } )->{branchcode};
814 my $specific_lost_rule_restore = $builder->build(
816 source => 'CirculationRule',
818 branchcode => $branchcode3,
819 categorycode => undef,
821 rule_name => 'lostreturn',
822 rule_value => 'restore'
826 my $specific_proc_rule_restore = $builder->build(
828 source => 'CirculationRule',
830 branchcode => $branchcode3,
831 categorycode => undef,
833 rule_name => 'processingreturn',
834 rule_value => 'restore'
839 # Make sure we have an unused branchcode
840 my $branch_without_rule = $builder->build( { source => 'Branch' } )->{branchcode};
842 my $item = $builder->build_sample_item(
844 homebranch => $specific_lost_rule_restore->{branchcode},
845 holdingbranch => $specific_lost_rule_false->{branchcode}
849 return_branch => $specific_lost_rule_refund->{ branchcode },
854 t::lib::Mocks::mock_preference( 'RefundLostOnReturnControl', 'CheckinLibrary' );
855 is_deeply( Koha::CirculationRules->get_lostreturn_policy( $params ),
856 { lostreturn => 'refund', processingreturn => 'refund' },'Specific rule for checkin branch is applied (refund)');
858 t::lib::Mocks::mock_preference( 'RefundLostOnReturnControl', 'ItemHomeBranch' );
859 is_deeply( Koha::CirculationRules->get_lostreturn_policy( $params ),
860 { lostreturn => 'restore', processingreturn => 'restore' },'Specific rule for home branch is applied (restore)');
862 t::lib::Mocks::mock_preference( 'RefundLostOnReturnControl', 'ItemHoldingBranch' );
863 is_deeply( Koha::CirculationRules->get_lostreturn_policy( $params ),
864 { lostreturn => 0, processingreturn => 0 },'Specific rule for holding branch is applied (false)');
867 t::lib::Mocks::mock_preference( 'RefundLostOnReturnControl', 'CheckinLibrary' );
868 $params->{return_branch} = $branch_without_rule;
869 is_deeply( Koha::CirculationRules->get_lostreturn_policy( $params ),
870 { lostreturn => 'charge', processingreturn => 'charge' },'No rule for branch, global rule applied (charge)');
872 # Change the default value just to try
873 Koha::CirculationRules->search({ branchcode => undef, rule_name => 'lostreturn' })->next->rule_value(0)->store;
874 Koha::CirculationRules->search({ branchcode => undef, rule_name => 'processingreturn' })->next->rule_value(0)->store;
875 my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
876 $memory_cache->flush();
877 is_deeply( Koha::CirculationRules->get_lostreturn_policy( $params ),
878 { lostreturn => 0, processingreturn => 0 },'No rule for branch, global rule applied (false)');
880 # No default rule defined check
881 Koha::CirculationRules
885 categorycode => undef,
887 rule_name => 'lostreturn'
892 # No default rule defined check
893 Koha::CirculationRules
897 categorycode => undef,
899 rule_name => 'processingreturn'
904 is_deeply( Koha::CirculationRules->get_lostreturn_policy( $params ),
905 { lostreturn => 'refund', processingreturn => 'refund' },'No rule for branch, no default rule, fallback default (refund)');
907 # Fallback to ItemHoldBranch if CheckinLibrary is undefined
908 $params->{return_branch} = undef;
909 is_deeply( Koha::CirculationRules->get_lostreturn_policy( $params ),
910 { lostreturn => 'restore', processingreturn => 'restore' },'return_branch undefined, fallback to ItemHomeBranch rule (restore)');
912 $schema->storage->txn_rollback;
916 my ( $rule, $expected, $message ) = @_;
918 ok( $rule, $message ) ?
919 cmp_methods( $rule, [ %$expected ], $message ) :
923 sub _prepare_tests_for_rule_scope_combinations {
924 my ( $scope, $rule_name ) = @_;
926 # Here we create a combinations of 1s and 0s the following way
937 # (the number of columns equals to the amount of rule scopes)
938 # The ... symbolizes possible future scopes.
940 # - 0 equals to circulation rule scope with any value (aka. *)
941 # - 1 equals to circulation rule scope exact value, e.g.
942 # "CPL" (for branchcode).
944 # The order is the same as the weight of scopes when sorting circulation
945 # rules. So the first column of numbers is the scope with most weight.
946 # This is defined by C<$order> which will be assigned next.
948 # We must maintain the order in order to keep the test valid. This should be
949 # equal to Koha/CirculationRules.pm "order_by" of C<get_effective_rule> sub.
950 # Let's explicitly define the order and fail test if we are missing a scope:
951 my $order = [ 'branchcode', 'categorycode', 'itemtype' ];
952 is( join(", ", sort keys %$scope),
953 join(", ", sort @$order), 'Missing a scope!' ) if keys %$scope ne scalar @$order;
956 foreach my $value ( glob( "{0,1}" x keys %$scope || 1 ) ) {
957 my $test = { %$scope };
958 for ( my $i=0; $i < keys %$scope; $i++ ) {
959 $test->{$order->[$i]} = '*' unless substr( $value, $i, 1 );
964 return \@tests, $order;