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' );
538 subtest 'test rule matching with different combinations of rule scopes' => sub {
539 my ( $tests, $order ) = _prepare_tests_for_rule_scope_combinations(
541 branchcode => $branchcode,
542 categorycode => $categorycode,
543 itemtype => $itemtype,
548 plan tests => 2**scalar @$order;
550 foreach my $test (@$tests) {
551 my $rule_params = {%$test};
552 $rule_params->{rule_name} = $rule_name;
553 my $rule_value = $rule_params->{rule_value} = int( rand(10) );
555 Koha::CirculationRules->set_rule($rule_params);
557 my $rule = Koha::CirculationRules->get_effective_rule(
559 branchcode => $branchcode,
560 categorycode => $categorycode,
561 itemtype => $itemtype,
562 rule_name => $rule_name,
566 my $scope_output = '';
567 foreach my $key ( values @$order ) {
568 $scope_output .= " $key" if $test->{$key} ne '*';
571 is( $rule->rule_value, $rule_value,
573 . ( $scope_output ? $scope_output : ' nothing' ) );
577 my $our_branch_rules = Koha::CirculationRules->search({branchcode => $branchcode});
578 is( $our_branch_rules->count, 4, "We added 8 rules");
579 $our_branch_rules->delete;
580 is( $our_branch_rules->count, 0, "We deleted 8 rules");
582 $schema->storage->txn_rollback;
585 subtest 'get_onshelfholds_policy() tests' => sub {
589 $schema->storage->txn_begin;
591 my $item = $builder->build_sample_item();
593 my $circ_rules = Koha::CirculationRules->new;
595 $circ_rules->search({ rule_name => 'onshelfholds' })->delete;
597 $circ_rules->set_rule(
602 rule_name => 'onshelfholds',
607 is( $circ_rules->get_onshelfholds_policy({ item => $item }), 1, 'If rule_value is set on a matching rule, return it' );
608 # Delete the rule (i.e. get_effective_rule returns undef)
610 is( $circ_rules->get_onshelfholds_policy({ item => $item }), 0, 'If no matching rule, fallback to 0' );
612 $schema->storage->txn_rollback;
615 subtest 'get_effective_daysmode' => sub {
618 $schema->storage->txn_begin;
620 my $item_1 = $builder->build_sample_item();
621 my $item_2 = $builder->build_sample_item();
624 Koha::CirculationRules->search( { rule_name => 'daysmode' } )->delete;
626 # Default value 'Datedue' at pref level
627 t::lib::Mocks::mock_preference( 'useDaysMode', 'Datedue' );
630 Koha::CirculationRules->get_effective_daysmode(
632 categorycode => undef,
633 itemtype => $item_1->effective_itemtype,
638 'daysmode default to pref value if the rule does not exist'
641 Koha::CirculationRules->set_rule(
646 rule_name => 'daysmode',
647 rule_value => 'Calendar',
650 Koha::CirculationRules->set_rule(
654 itemtype => $item_1->effective_itemtype,
655 rule_name => 'daysmode',
656 rule_value => 'Days',
661 Koha::CirculationRules->get_effective_daysmode(
663 categorycode => undef,
664 itemtype => $item_1->effective_itemtype,
669 "daysmode for item_1 is the specific rule"
672 Koha::CirculationRules->get_effective_daysmode(
674 categorycode => undef,
675 itemtype => $item_2->effective_itemtype,
680 "daysmode for item_2 is the one defined for the default circ rule"
683 Koha::CirculationRules->set_rule(
687 itemtype => $item_2->effective_itemtype,
688 rule_name => 'daysmode',
694 Koha::CirculationRules->get_effective_daysmode(
696 categorycode => undef,
697 itemtype => $item_2->effective_itemtype,
702 'daysmode default to pref value if the rule exists but set to""'
705 $schema->storage->txn_rollback;
708 subtest 'get_lostreturn_policy() tests' => sub {
711 $schema->storage->txn_begin;
713 $schema->resultset('CirculationRule')->search()->delete;
715 my $default_proc_rule_charge = $builder->build(
717 source => 'CirculationRule',
720 categorycode => undef,
722 rule_name => 'processingreturn',
723 rule_value => 'charge'
727 my $default_lost_rule_charge = $builder->build(
729 source => 'CirculationRule',
732 categorycode => undef,
734 rule_name => 'lostreturn',
735 rule_value => 'charge'
739 my $branchcode = $builder->build( { source => 'Branch' } )->{branchcode};
740 my $specific_lost_rule_false = $builder->build(
742 source => 'CirculationRule',
744 branchcode => $branchcode,
745 categorycode => undef,
747 rule_name => 'lostreturn',
752 my $specific_proc_rule_false = $builder->build(
754 source => 'CirculationRule',
756 branchcode => $branchcode,
757 categorycode => undef,
759 rule_name => 'processingreturn',
764 my $branchcode2 = $builder->build( { source => 'Branch' } )->{branchcode};
765 my $specific_lost_rule_refund = $builder->build(
767 source => 'CirculationRule',
769 branchcode => $branchcode2,
770 categorycode => undef,
772 rule_name => 'lostreturn',
773 rule_value => 'refund'
777 my $specific_proc_rule_refund = $builder->build(
779 source => 'CirculationRule',
781 branchcode => $branchcode2,
782 categorycode => undef,
784 rule_name => 'processingreturn',
785 rule_value => 'refund'
789 my $branchcode3 = $builder->build( { source => 'Branch' } )->{branchcode};
790 my $specific_lost_rule_restore = $builder->build(
792 source => 'CirculationRule',
794 branchcode => $branchcode3,
795 categorycode => undef,
797 rule_name => 'lostreturn',
798 rule_value => 'restore'
802 my $specific_proc_rule_restore = $builder->build(
804 source => 'CirculationRule',
806 branchcode => $branchcode3,
807 categorycode => undef,
809 rule_name => 'processingreturn',
810 rule_value => 'restore'
815 # Make sure we have an unused branchcode
816 my $branch_without_rule = $builder->build( { source => 'Branch' } )->{branchcode};
818 my $item = $builder->build_sample_item(
820 homebranch => $specific_lost_rule_restore->{branchcode},
821 holdingbranch => $specific_lost_rule_false->{branchcode}
825 return_branch => $specific_lost_rule_refund->{ branchcode },
830 t::lib::Mocks::mock_preference( 'RefundLostOnReturnControl', 'CheckinLibrary' );
831 is_deeply( Koha::CirculationRules->get_lostreturn_policy( $params ),
832 { lostreturn => 'refund', processingreturn => 'refund' },'Specific rule for checkin branch is applied (refund)');
834 t::lib::Mocks::mock_preference( 'RefundLostOnReturnControl', 'ItemHomeBranch' );
835 is_deeply( Koha::CirculationRules->get_lostreturn_policy( $params ),
836 { lostreturn => 'restore', processingreturn => 'restore' },'Specific rule for home branch is applied (restore)');
838 t::lib::Mocks::mock_preference( 'RefundLostOnReturnControl', 'ItemHoldingBranch' );
839 is_deeply( Koha::CirculationRules->get_lostreturn_policy( $params ),
840 { lostreturn => 0, processingreturn => 0 },'Specific rule for holding branch is applied (false)');
843 t::lib::Mocks::mock_preference( 'RefundLostOnReturnControl', 'CheckinLibrary' );
844 $params->{return_branch} = $branch_without_rule;
845 is_deeply( Koha::CirculationRules->get_lostreturn_policy( $params ),
846 { lostreturn => 'charge', processingreturn => 'charge' },'No rule for branch, global rule applied (charge)');
848 # Change the default value just to try
849 Koha::CirculationRules->search({ branchcode => undef, rule_name => 'lostreturn' })->next->rule_value(0)->store;
850 Koha::CirculationRules->search({ branchcode => undef, rule_name => 'processingreturn' })->next->rule_value(0)->store;
851 my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
852 $memory_cache->flush();
853 is_deeply( Koha::CirculationRules->get_lostreturn_policy( $params ),
854 { lostreturn => 0, processingreturn => 0 },'No rule for branch, global rule applied (false)');
856 # No default rule defined check
857 Koha::CirculationRules
861 categorycode => undef,
863 rule_name => 'lostreturn'
868 # No default rule defined check
869 Koha::CirculationRules
873 categorycode => undef,
875 rule_name => 'processingreturn'
880 is_deeply( Koha::CirculationRules->get_lostreturn_policy( $params ),
881 { lostreturn => 'refund', processingreturn => 'refund' },'No rule for branch, no default rule, fallback default (refund)');
883 # Fallback to ItemHoldBranch if CheckinLibrary is undefined
884 $params->{return_branch} = undef;
885 is_deeply( Koha::CirculationRules->get_lostreturn_policy( $params ),
886 { lostreturn => 'restore', processingreturn => 'restore' },'return_branch undefined, fallback to ItemHomeBranch rule (restore)');
888 $schema->storage->txn_rollback;
892 my ( $rule, $expected, $message ) = @_;
894 ok( $rule, $message ) ?
895 cmp_methods( $rule, [ %$expected ], $message ) :
899 sub _prepare_tests_for_rule_scope_combinations {
900 my ( $scope, $rule_name ) = @_;
902 # Here we create a combinations of 1s and 0s the following way
913 # (the number of columns equals to the amount of rule scopes)
914 # The ... symbolizes possible future scopes.
916 # - 0 equals to circulation rule scope with any value (aka. *)
917 # - 1 equals to circulation rule scope exact value, e.g.
918 # "CPL" (for branchcode).
920 # The order is the same as the weight of scopes when sorting circulation
921 # rules. So the first column of numbers is the scope with most weight.
922 # This is defined by C<$order> which will be assigned next.
924 # We must maintain the order in order to keep the test valid. This should be
925 # equal to Koha/CirculationRules.pm "order_by" of C<get_effective_rule> sub.
926 # Let's explicitly define the order and fail test if we are missing a scope:
927 my $order = [ 'branchcode', 'categorycode', 'itemtype' ];
928 is( join(", ", sort keys %$scope),
929 join(", ", sort @$order), 'Missing a scope!' ) if keys %$scope ne scalar @$order;
932 foreach my $value ( glob( "{0,1}" x keys %$scope || 1 ) ) {
933 my $test = { %$scope };
934 for ( my $i=0; $i < keys %$scope; $i++ ) {
935 $test->{$order->[$i]} = '*' unless substr( $value, $i, 1 );
940 return \@tests, $order;