Bug 30399: Make Patron.t pass even if mandatory attributes exist
[koha.git] / t / db_dependent / Koha / Patron.t
1 #!/usr/bin/perl
2
3 # Copyright 2019 Koha Development team
4 #
5 # This file is part of Koha
6 #
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.
11 #
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.
16 #
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>.
19
20 use Modern::Perl;
21
22 use Test::More tests => 18;
23 use Test::Exception;
24 use Test::Warn;
25
26 use Koha::CirculationRules;
27 use Koha::Database;
28 use Koha::DateUtils qw(dt_from_string);
29 use Koha::ArticleRequests;
30 use Koha::Patrons;
31 use Koha::Patron::Relationships;
32
33 use t::lib::TestBuilder;
34 use t::lib::Mocks;
35
36 my $schema  = Koha::Database->new->schema;
37 my $builder = t::lib::TestBuilder->new;
38
39 subtest 'add_guarantor() tests' => sub {
40
41     plan tests => 6;
42
43     $schema->storage->txn_begin;
44
45     t::lib::Mocks::mock_preference( 'borrowerRelationship', 'father1|father2' );
46
47     my $patron_1 = $builder->build_object({ class => 'Koha::Patrons' });
48     my $patron_2 = $builder->build_object({ class => 'Koha::Patrons' });
49
50     throws_ok
51         { $patron_1->add_guarantor({ guarantor_id => $patron_2->borrowernumber }); }
52         'Koha::Exceptions::Patron::Relationship::InvalidRelationship',
53         'Exception is thrown as no relationship passed';
54
55     is( $patron_1->guarantee_relationships->count, 0, 'No guarantors added' );
56
57     throws_ok
58         { $patron_1->add_guarantor({ guarantor_id => $patron_2->borrowernumber, relationship => 'father' }); }
59         'Koha::Exceptions::Patron::Relationship::InvalidRelationship',
60         'Exception is thrown as a wrong relationship was passed';
61
62     is( $patron_1->guarantee_relationships->count, 0, 'No guarantors added' );
63
64     $patron_1->add_guarantor({ guarantor_id => $patron_2->borrowernumber, relationship => 'father1' });
65
66     my $guarantors = $patron_1->guarantor_relationships;
67
68     is( $guarantors->count, 1, 'No guarantors added' );
69
70     {
71         local *STDERR;
72         open STDERR, '>', '/dev/null';
73         throws_ok
74             { $patron_1->add_guarantor({ guarantor_id => $patron_2->borrowernumber, relationship => 'father2' }); }
75             'Koha::Exceptions::Patron::Relationship::DuplicateRelationship',
76             'Exception is thrown for duplicated relationship';
77         close STDERR;
78     }
79
80     $schema->storage->txn_rollback;
81 };
82
83 subtest 'relationships_debt() tests' => sub {
84
85     plan tests => 168;
86
87     $schema->storage->txn_begin;
88
89     t::lib::Mocks::mock_preference( 'borrowerRelationship', 'parent' );
90
91     my $parent_1 = $builder->build_object({ class => 'Koha::Patrons', value => { firstname => "Parent 1" } });
92     my $parent_2 = $builder->build_object({ class => 'Koha::Patrons', value => { firstname => "Parent 2" } });
93     my $child_1 = $builder->build_object({ class => 'Koha::Patrons', value => { firstname => " Child 1" } });
94     my $child_2 = $builder->build_object({ class => 'Koha::Patrons', value => { firstname => " Child 2" } });
95
96     $child_1->add_guarantor({ guarantor_id => $parent_1->borrowernumber, relationship => 'parent' });
97     $child_1->add_guarantor({ guarantor_id => $parent_2->borrowernumber, relationship => 'parent' });
98     $child_2->add_guarantor({ guarantor_id => $parent_1->borrowernumber, relationship => 'parent' });
99     $child_2->add_guarantor({ guarantor_id => $parent_2->borrowernumber, relationship => 'parent' });
100
101     is( $child_1->guarantor_relationships->guarantors->count, 2, 'Child 1 has correct number of guarantors' );
102     is( $child_2->guarantor_relationships->guarantors->count, 2, 'Child 2 has correct number of guarantors' );
103     is( $parent_1->guarantee_relationships->guarantees->count, 2, 'Parent 1 has correct number of guarantees' );
104     is( $parent_2->guarantee_relationships->guarantees->count, 2, 'Parent 2 has correct number of guarantees' );
105
106     my $patrons = [ $parent_1, $parent_2, $child_1, $child_2 ];
107
108     # First test: No debt
109     my ($parent1_debt, $parent2_debt, $child1_debt, $child2_debt) = (0,0,0,0);
110     _test_combinations($patrons, $parent1_debt,$parent2_debt,$child1_debt,$child2_debt);
111
112     # Add debt to child_2
113     $child2_debt = 2;
114     $child_2->account->add_debit({ type => 'ACCOUNT', amount => $child2_debt, interface => 'commandline' });
115     is( $child_2->account->non_issues_charges, $child2_debt, 'Debt added to Child 2' );
116     _test_combinations($patrons, $parent1_debt,$parent2_debt,$child1_debt,$child2_debt);
117
118     $parent1_debt = 3;
119     $parent_1->account->add_debit({ type => 'ACCOUNT', amount => $parent1_debt, interface => 'commandline' });
120     is( $parent_1->account->non_issues_charges, $parent1_debt, 'Debt added to Parent 1' );
121     _test_combinations($patrons, $parent1_debt,$parent2_debt,$child1_debt,$child2_debt);
122
123     $parent2_debt = 5;
124     $parent_2->account->add_debit({ type => 'ACCOUNT', amount => $parent2_debt, interface => 'commandline' });
125     is( $parent_2->account->non_issues_charges, $parent2_debt, 'Parent 2 owes correct amount' );
126     _test_combinations($patrons, $parent1_debt,$parent2_debt,$child1_debt,$child2_debt);
127
128     $child1_debt = 7;
129     $child_1->account->add_debit({ type => 'ACCOUNT', amount => $child1_debt, interface => 'commandline' });
130     is( $child_1->account->non_issues_charges, $child1_debt, 'Child 1 owes correct amount' );
131     _test_combinations($patrons, $parent1_debt,$parent2_debt,$child1_debt,$child2_debt);
132
133     $schema->storage->txn_rollback;
134 };
135
136 sub _test_combinations {
137     my ( $patrons, $parent1_debt, $parent2_debt, $child1_debt, $child2_debt ) = @_;
138     note("Testing with parent 1 debt $parent1_debt | Parent 2 debt $parent2_debt | Child 1 debt $child1_debt | Child 2 debt $child2_debt");
139     # Options
140     # P1 => P1 + C1 + C2 ( - P1 ) ( + P2 )
141     # P2 => P2 + C1 + C2 ( - P2 ) ( + P1 )
142     # C1 => P1 + P2 + C1 + C2 ( - C1 )
143     # C2 => P1 + P2 + C1 + C2 ( - C2 )
144
145 # 3 params, count from 0 to 7 in binary ( 3 places ) to get the set of switches, then do that 4 times, one for each parent and child
146     for my $i ( 0 .. 7 ) {
147         my ( $only_this_guarantor, $include_guarantors, $include_this_patron )
148           = split '', sprintf( "%03b", $i );
149         note("---------------------");
150         for my $patron ( @$patrons ) {
151             if ( $only_this_guarantor
152                 && !$patron->guarantee_relationships->count )
153             {
154                 throws_ok {
155                     $patron->relationships_debt(
156                         {
157                             only_this_guarantor => $only_this_guarantor,
158                             include_guarantors  => $include_guarantors,
159                             include_this_patron => $include_this_patron
160                         }
161                     );
162                 }
163                 'Koha::Exceptions::BadParameter',
164                   'Exception is thrown as patron is not a guarantor';
165
166             }
167             else {
168
169                 my $debt = 0;
170                 if ( $patron->firstname eq 'Parent 1' ) {
171                     $debt += $parent1_debt if ($include_this_patron && $include_guarantors);
172                     $debt += $child1_debt + $child2_debt;
173                     $debt += $parent2_debt unless ($only_this_guarantor || !$include_guarantors);
174                 }
175                 elsif ( $patron->firstname eq 'Parent 2' ) {
176                     $debt += $parent2_debt if ($include_this_patron & $include_guarantors);
177                     $debt += $child1_debt + $child2_debt;
178                     $debt += $parent1_debt unless ($only_this_guarantor || !$include_guarantors);
179                 }
180                 elsif ( $patron->firstname eq ' Child 1' ) {
181                     $debt += $child1_debt if ($include_this_patron);
182                     $debt += $child2_debt;
183                     $debt += $parent1_debt + $parent2_debt if ($include_guarantors);
184                 }
185                 else {
186                     $debt += $child2_debt if ($include_this_patron);
187                     $debt += $child1_debt;
188                     $debt += $parent1_debt + $parent2_debt if ($include_guarantors);
189                 }
190
191                 is(
192                     $patron->relationships_debt(
193                         {
194                             only_this_guarantor => $only_this_guarantor,
195                             include_guarantors  => $include_guarantors,
196                             include_this_patron => $include_this_patron
197                         }
198                     ),
199                     $debt,
200                     $patron->firstname
201                       . " debt of " . sprintf('%02d',$debt) . " calculated correctly for ( only_this_guarantor: $only_this_guarantor, include_guarantors: $include_guarantors, include_this_patron: $include_this_patron)"
202                 );
203             }
204         }
205     }
206 }
207
208 subtest 'add_enrolment_fee_if_needed() tests' => sub {
209
210     plan tests => 2;
211
212     subtest 'category has enrolment fee' => sub {
213         plan tests => 7;
214
215         $schema->storage->txn_begin;
216
217         my $category = $builder->build_object(
218             {
219                 class => 'Koha::Patron::Categories',
220                 value => {
221                     enrolmentfee => 20
222                 }
223             }
224         );
225
226         my $patron = $builder->build_object(
227             {
228                 class => 'Koha::Patrons',
229                 value => {
230                     categorycode => $category->categorycode
231                 }
232             }
233         );
234
235         my $enrollment_fee = $patron->add_enrolment_fee_if_needed();
236         is( $enrollment_fee * 1, 20, 'Enrolment fee amount is correct' );
237         my $account = $patron->account;
238         is( $patron->account->balance * 1, 20, 'Patron charged the enrolment fee' );
239         # second enrolment fee, new
240         $enrollment_fee = $patron->add_enrolment_fee_if_needed(0);
241         # third enrolment fee, renewal
242         $enrollment_fee = $patron->add_enrolment_fee_if_needed(1);
243         is( $patron->account->balance * 1, 60, 'Patron charged the enrolment fees' );
244
245         my @debits = $account->outstanding_debits->as_list;
246         is( scalar @debits, 3, '3 enrolment fees' );
247         is( $debits[0]->debit_type_code, 'ACCOUNT', 'Account type set correctly' );
248         is( $debits[1]->debit_type_code, 'ACCOUNT', 'Account type set correctly' );
249         is( $debits[2]->debit_type_code, 'ACCOUNT_RENEW', 'Account type set correctly' );
250
251         $schema->storage->txn_rollback;
252     };
253
254     subtest 'no enrolment fee' => sub {
255
256         plan tests => 3;
257
258         $schema->storage->txn_begin;
259
260         my $category = $builder->build_object(
261             {
262                 class => 'Koha::Patron::Categories',
263                 value => {
264                     enrolmentfee => 0
265                 }
266             }
267         );
268
269         my $patron = $builder->build_object(
270             {
271                 class => 'Koha::Patrons',
272                 value => {
273                     categorycode => $category->categorycode
274                 }
275             }
276         );
277
278         my $enrollment_fee = $patron->add_enrolment_fee_if_needed();
279         is( $enrollment_fee * 1, 0, 'No enrolment fee' );
280         my $account = $patron->account;
281         is( $patron->account->balance, 0, 'Patron not charged anything' );
282
283         my @debits = $account->outstanding_debits->as_list;
284         is( scalar @debits, 0, 'no debits' );
285
286         $schema->storage->txn_rollback;
287     };
288 };
289
290 subtest 'to_api() tests' => sub {
291
292     plan tests => 6;
293
294     $schema->storage->txn_begin;
295
296     my $patron_class = Test::MockModule->new('Koha::Patron');
297     $patron_class->mock(
298         'algo',
299         sub { return 'algo' }
300     );
301
302     my $patron = $builder->build_object(
303         {
304             class => 'Koha::Patrons',
305             value => {
306                 debarred => undef
307             }
308         }
309     );
310
311     my $restricted = $patron->to_api->{restricted};
312     ok( defined $restricted, 'restricted is defined' );
313     ok( !$restricted, 'debarred is undef, restricted evaluates to false' );
314
315     $patron->debarred( dt_from_string->add( days => 1 ) )->store->discard_changes;
316     $restricted = $patron->to_api->{restricted};
317     ok( defined $restricted, 'restricted is defined' );
318     ok( $restricted, 'debarred is defined, restricted evaluates to true' );
319
320     my $patron_json = $patron->to_api({ embed => { algo => {} } });
321     ok( exists $patron_json->{algo} );
322     is( $patron_json->{algo}, 'algo' );
323
324     $schema->storage->txn_rollback;
325 };
326
327 subtest 'login_attempts tests' => sub {
328     plan tests => 1;
329
330     $schema->storage->txn_begin;
331
332     my $patron = $builder->build_object(
333         {
334             class => 'Koha::Patrons',
335         }
336     );
337     my $patron_info = $patron->unblessed;
338     $patron->delete;
339     delete $patron_info->{login_attempts};
340     my $new_patron = Koha::Patron->new($patron_info)->store;
341     is( $new_patron->discard_changes->login_attempts, 0, "login_attempts defaults to 0 as expected");
342
343     $schema->storage->txn_rollback;
344 };
345
346 subtest 'is_superlibrarian() tests' => sub {
347
348     plan tests => 3;
349
350     $schema->storage->txn_begin;
351
352     my $patron = $builder->build_object(
353         {
354             class => 'Koha::Patrons',
355
356             value => {
357                 flags => 16
358             }
359         }
360     );
361
362     is( $patron->is_superlibrarian, 0, 'Patron is not a superlibrarian and the method returns the correct value' );
363
364     $patron->flags(1)->store->discard_changes;
365     is( $patron->is_superlibrarian, 1, 'Patron is a superlibrarian and the method returns the correct value' );
366
367     $patron->flags(0)->store->discard_changes;
368     is( $patron->is_superlibrarian, 0, 'Patron is not a superlibrarian and the method returns the correct value' );
369
370     $schema->storage->txn_rollback;
371 };
372
373 subtest 'extended_attributes' => sub {
374
375     plan tests => 15;
376
377     my $schema = Koha::Database->new->schema;
378     $schema->storage->txn_begin;
379
380     Koha::Patron::Attribute::Types->search->delete;
381
382     my $patron_1 = $builder->build_object({class=> 'Koha::Patrons'});
383     my $patron_2 = $builder->build_object({class=> 'Koha::Patrons'});
384
385     t::lib::Mocks::mock_userenv({ patron => $patron_1 });
386
387     my $attribute_type1 = Koha::Patron::Attribute::Type->new(
388         {
389             code        => 'my code1',
390             description => 'my description1',
391             unique_id   => 1
392         }
393     )->store;
394     my $attribute_type2 = Koha::Patron::Attribute::Type->new(
395         {
396             code             => 'my code2',
397             description      => 'my description2',
398             opac_display     => 1,
399             staff_searchable => 1
400         }
401     )->store;
402
403     my $new_library = $builder->build( { source => 'Branch' } );
404     my $attribute_type_limited = Koha::Patron::Attribute::Type->new(
405         { code => 'my code3', description => 'my description3' } )->store;
406     $attribute_type_limited->library_limits( [ $new_library->{branchcode} ] );
407
408     my $attributes_for_1 = [
409         {
410             attribute => 'my attribute1',
411             code => $attribute_type1->code(),
412         },
413         {
414             attribute => 'my attribute2',
415             code => $attribute_type2->code(),
416         },
417         {
418             attribute => 'my attribute limited',
419             code => $attribute_type_limited->code(),
420         }
421     ];
422
423     my $attributes_for_2 = [
424         {
425             attribute => 'my attribute12',
426             code => $attribute_type1->code(),
427         },
428         {
429             attribute => 'my attribute limited 2',
430             code => $attribute_type_limited->code(),
431         }
432     ];
433
434     my $extended_attributes = $patron_1->extended_attributes;
435     is( ref($extended_attributes), 'Koha::Patron::Attributes', 'Koha::Patron->extended_attributes must return a Koha::Patron::Attribute set' );
436     is( $extended_attributes->count, 0, 'There should not be attribute yet');
437
438     $patron_1->extended_attributes->filter_by_branch_limitations->delete;
439     $patron_2->extended_attributes->filter_by_branch_limitations->delete;
440     $patron_1->extended_attributes($attributes_for_1);
441     $patron_2->extended_attributes($attributes_for_2);
442
443     my $extended_attributes_for_1 = $patron_1->extended_attributes;
444     is( $extended_attributes_for_1->count, 3, 'There should be 3 attributes now for patron 1');
445
446     my $extended_attributes_for_2 = $patron_2->extended_attributes;
447     is( $extended_attributes_for_2->count, 2, 'There should be 2 attributes now for patron 2');
448
449     my $attribute_12 = $extended_attributes_for_2->search({ code => $attribute_type1->code })->next;
450     is( $attribute_12->attribute, 'my attribute12', 'search by code should return the correct attribute' );
451
452     $attribute_12 = $patron_2->get_extended_attribute( $attribute_type1->code );
453     is( $attribute_12->attribute, 'my attribute12', 'Koha::Patron->get_extended_attribute should return the correct attribute value' );
454
455     my $expected_attributes_for_2 = [
456         {
457             code      => $attribute_type1->code(),
458             attribute => 'my attribute12',
459         },
460         {
461             code      => $attribute_type_limited->code(),
462             attribute => 'my attribute limited 2',
463         }
464     ];
465     # Sorting them by code
466     $expected_attributes_for_2 = [ sort { $a->{code} cmp $b->{code} } @$expected_attributes_for_2 ];
467     my @extended_attributes_for_2 = $extended_attributes_for_2->as_list;
468
469     is_deeply(
470         [
471             {
472                 code      => $extended_attributes_for_2[0]->code,
473                 attribute => $extended_attributes_for_2[0]->attribute
474             },
475             {
476                 code      => $extended_attributes_for_2[1]->code,
477                 attribute => $extended_attributes_for_2[1]->attribute
478             }
479         ],
480         $expected_attributes_for_2
481     );
482
483     # TODO - What about multiple? POD explains the problem
484     my $non_existent = $patron_2->get_extended_attribute( 'not_exist' );
485     is( $non_existent, undef, 'Koha::Patron->get_extended_attribute must return undef if the attribute does not exist' );
486
487     # Test branch limitations
488     t::lib::Mocks::mock_userenv({ patron => $patron_2 });
489     # Return all
490     $extended_attributes_for_1 = $patron_1->extended_attributes;
491     is( $extended_attributes_for_1->count, 3, 'There should be 2 attributes for patron 1, the limited one should be returned');
492
493     # Return filtered
494     $extended_attributes_for_1 = $patron_1->extended_attributes->filter_by_branch_limitations;
495     is( $extended_attributes_for_1->count, 2, 'There should be 2 attributes for patron 1, the limited one should be returned');
496
497     # Not filtered
498     my $limited_value = $patron_1->get_extended_attribute( $attribute_type_limited->code );
499     is( $limited_value->attribute, 'my attribute limited', );
500
501     ## Do we need a filtered?
502     #$limited_value = $patron_1->get_extended_attribute( $attribute_type_limited->code );
503     #is( $limited_value, undef, );
504
505     $schema->storage->txn_rollback;
506
507     subtest 'non-repeatable attributes tests' => sub {
508
509         plan tests => 3;
510
511         $schema->storage->txn_begin;
512         Koha::Patron::Attribute::Types->search->delete;
513
514         my $patron = $builder->build_object({ class => 'Koha::Patrons' });
515         my $attribute_type = $builder->build_object(
516             {
517                 class => 'Koha::Patron::Attribute::Types',
518                 value => { repeatable => 0 }
519             }
520         );
521
522         is( $patron->extended_attributes->count, 0, 'Patron has no extended attributes' );
523
524         throws_ok
525             {
526                 $patron->extended_attributes(
527                     [
528                         { code => $attribute_type->code, attribute => 'a' },
529                         { code => $attribute_type->code, attribute => 'b' }
530                     ]
531                 );
532             }
533             'Koha::Exceptions::Patron::Attribute::NonRepeatable',
534             'Exception thrown on non-repeatable attribute';
535
536         is( $patron->extended_attributes->count, 0, 'Extended attributes storing rolled back' );
537
538         $schema->storage->txn_rollback;
539
540     };
541
542     subtest 'unique attributes tests' => sub {
543
544         plan tests => 5;
545
546         $schema->storage->txn_begin;
547         Koha::Patron::Attribute::Types->search->delete;
548
549         my $patron_1 = $builder->build_object({ class => 'Koha::Patrons' });
550         my $patron_2 = $builder->build_object({ class => 'Koha::Patrons' });
551
552         my $attribute_type_1 = $builder->build_object(
553             {
554                 class => 'Koha::Patron::Attribute::Types',
555                 value => { unique => 1 }
556             }
557         );
558
559         my $attribute_type_2 = $builder->build_object(
560             {
561                 class => 'Koha::Patron::Attribute::Types',
562                 value => { unique => 0 }
563             }
564         );
565
566         is( $patron_1->extended_attributes->count, 0, 'patron_1 has no extended attributes' );
567         is( $patron_2->extended_attributes->count, 0, 'patron_2 has no extended attributes' );
568
569         $patron_1->extended_attributes(
570             [
571                 { code => $attribute_type_1->code, attribute => 'a' },
572                 { code => $attribute_type_2->code, attribute => 'a' }
573             ]
574         );
575
576         throws_ok
577             {
578                 $patron_2->extended_attributes(
579                     [
580                         { code => $attribute_type_1->code, attribute => 'a' },
581                         { code => $attribute_type_2->code, attribute => 'a' }
582                     ]
583                 );
584             }
585             'Koha::Exceptions::Patron::Attribute::UniqueIDConstraint',
586             'Exception thrown on unique attribute';
587
588         is( $patron_1->extended_attributes->count, 2, 'Extended attributes stored' );
589         is( $patron_2->extended_attributes->count, 0, 'Extended attributes storing rolled back' );
590
591         $schema->storage->txn_rollback;
592
593     };
594
595     subtest 'invalid type attributes tests' => sub {
596
597         plan tests => 3;
598
599         $schema->storage->txn_begin;
600         Koha::Patron::Attribute::Types->search->delete;
601
602         my $patron = $builder->build_object({ class => 'Koha::Patrons' });
603
604         my $attribute_type_1 = $builder->build_object(
605             {
606                 class => 'Koha::Patron::Attribute::Types',
607                 value => { repeatable => 0 }
608             }
609         );
610
611         my $attribute_type_2 = $builder->build_object(
612             {
613                 class => 'Koha::Patron::Attribute::Types'
614             }
615         );
616
617         my $type_2 = $attribute_type_2->code;
618         $attribute_type_2->delete;
619
620         is( $patron->extended_attributes->count, 0, 'Patron has no extended attributes' );
621
622         throws_ok
623             {
624                 $patron->extended_attributes(
625                     [
626                         { code => $attribute_type_1->code, attribute => 'a' },
627                         { code => $attribute_type_2->code, attribute => 'b' }
628                     ]
629                 );
630             }
631             'Koha::Exceptions::Patron::Attribute::InvalidType',
632             'Exception thrown on invalid attribute type';
633
634         is( $patron->extended_attributes->count, 0, 'Extended attributes storing rolled back' );
635
636         $schema->storage->txn_rollback;
637
638     };
639
640     subtest 'globally mandatory attributes tests' => sub {
641
642         plan tests => 5;
643
644         $schema->storage->txn_begin;
645         Koha::Patron::Attribute::Types->search->delete;
646
647         my $patron = $builder->build_object({ class => 'Koha::Patrons' });
648
649         my $attribute_type_1 = $builder->build_object(
650             {
651                 class => 'Koha::Patron::Attribute::Types',
652                 value => { mandatory => 1, class => 'a' }
653             }
654         );
655
656         my $attribute_type_2 = $builder->build_object(
657             {
658                 class => 'Koha::Patron::Attribute::Types',
659                 value => { mandatory => 0, class => 'a' }
660             }
661         );
662
663         is( $patron->extended_attributes->count, 0, 'Patron has no extended attributes' );
664
665         throws_ok
666             {
667                 $patron->extended_attributes(
668                     [
669                         { code => $attribute_type_2->code, attribute => 'b' }
670                     ]
671                 );
672             }
673             'Koha::Exceptions::Patron::MissingMandatoryExtendedAttribute',
674             'Exception thrown on missing mandatory attribute type';
675
676         is( $@->type, $attribute_type_1->code, 'Exception parameters are correct' );
677
678         is( $patron->extended_attributes->count, 0, 'Extended attributes storing rolled back' );
679
680         $patron->extended_attributes(
681             [
682                 { code => $attribute_type_1->code, attribute => 'b' }
683             ]
684         );
685
686         is( $patron->extended_attributes->count, 1, 'Extended attributes succeeded' );
687
688         $schema->storage->txn_rollback;
689
690     };
691
692 };
693
694 subtest 'can_log_into() tests' => sub {
695
696     plan tests => 5;
697
698     $schema->storage->txn_begin;
699
700     my $patron = $builder->build_object(
701         {
702             class => 'Koha::Patrons',
703             value => {
704                 flags => undef
705             }
706         }
707     );
708     my $library = $builder->build_object({ class => 'Koha::Libraries' });
709
710     t::lib::Mocks::mock_preference('IndependentBranches', 1);
711
712     ok( $patron->can_log_into( $patron->library ), 'Patron can log into its own library' );
713     ok( !$patron->can_log_into( $library ), 'Patron cannot log into different library, IndependentBranches on' );
714
715     # make it a superlibrarian
716     $patron->set({ flags => 1 })->store->discard_changes;
717     ok( $patron->can_log_into( $library ), 'Superlibrarian can log into different library, IndependentBranches on' );
718
719     t::lib::Mocks::mock_preference('IndependentBranches', 0);
720
721     # No special permissions
722     $patron->set({ flags => undef })->store->discard_changes;
723     ok( $patron->can_log_into( $patron->library ), 'Patron can log into its own library' );
724     ok( $patron->can_log_into( $library ), 'Patron can log into any library' );
725
726     $schema->storage->txn_rollback;
727 };
728
729 subtest 'can_request_article() tests' => sub {
730
731     plan tests => 4;
732
733     $schema->storage->txn_begin;
734
735     t::lib::Mocks::mock_preference( 'ArticleRequests', 1 );
736
737     my $item = $builder->build_sample_item;
738
739     my $library_1 = $builder->build_object( { class => 'Koha::Libraries' } );
740     my $library_2 = $builder->build_object( { class => 'Koha::Libraries' } );
741     my $patron    = $builder->build_object( { class => 'Koha::Patrons' } );
742
743     t::lib::Mocks::mock_userenv( { branchcode => $library_2->id } );
744
745     Koha::CirculationRules->set_rule(
746         {
747             categorycode => undef,
748             branchcode   => $library_1->id,
749             rule_name    => 'open_article_requests_limit',
750             rule_value   => 4,
751         }
752     );
753
754     $builder->build_object(
755         {
756             class => 'Koha::ArticleRequests',
757             value => { status => 'REQUESTED', borrowernumber => $patron->id }
758         }
759     );
760     $builder->build_object(
761         {
762             class => 'Koha::ArticleRequests',
763             value => { status => 'PENDING', borrowernumber => $patron->id }
764         }
765     );
766     $builder->build_object(
767         {
768             class => 'Koha::ArticleRequests',
769             value => { status => 'PROCESSING', borrowernumber => $patron->id }
770         }
771     );
772     $builder->build_object(
773         {
774             class => 'Koha::ArticleRequests',
775             value => { status => 'CANCELED', borrowernumber => $patron->id }
776         }
777     );
778
779     ok(
780         $patron->can_request_article( $library_1->id ),
781         '3 current requests, 4 is the limit: allowed'
782     );
783
784     # Completed request, same day
785     my $completed = $builder->build_object(
786         {
787             class => 'Koha::ArticleRequests',
788             value => {
789                 status         => 'COMPLETED',
790                 borrowernumber => $patron->id
791             }
792         }
793     );
794
795     ok( !$patron->can_request_article( $library_1->id ),
796         '3 current requests and a completed one the same day: denied' );
797
798     $completed->updated_on(
799         dt_from_string->add( days => -1 )->set(
800             hour   => 23,
801             minute => 59,
802             second => 59,
803         )
804     )->store;
805
806     ok( $patron->can_request_article( $library_1->id ),
807         '3 current requests and a completed one the day before: allowed' );
808
809     Koha::CirculationRules->set_rule(
810         {
811             categorycode => undef,
812             branchcode   => $library_2->id,
813             rule_name    => 'open_article_requests_limit',
814             rule_value   => 3,
815         }
816     );
817
818     ok( !$patron->can_request_article,
819         'Not passing the library_id param makes it fallback to userenv: denied'
820     );
821
822     $schema->storage->txn_rollback;
823 };
824
825 subtest 'article_requests() tests' => sub {
826
827     plan tests => 3;
828
829     $schema->storage->txn_begin;
830
831     my $library = $builder->build_object({ class => 'Koha::Libraries' });
832     t::lib::Mocks::mock_userenv( { branchcode => $library->id } );
833
834     my $patron = $builder->build_object( { class => 'Koha::Patrons' } );
835
836     my $article_requests = $patron->article_requests;
837     is( ref($article_requests), 'Koha::ArticleRequests',
838         'In scalar context, type is correct' );
839     is( $article_requests->count, 0, 'No article requests' );
840
841     foreach my $i ( 0 .. 3 ) {
842
843         my $item = $builder->build_sample_item;
844
845         Koha::ArticleRequest->new(
846             {
847                 borrowernumber => $patron->id,
848                 biblionumber   => $item->biblionumber,
849                 itemnumber     => $item->id,
850                 title          => "Title",
851             }
852         )->request;
853     }
854
855     $article_requests = $patron->article_requests;
856     is( $article_requests->count, 4, '4 article requests' );
857
858     $schema->storage->txn_rollback;
859
860 };
861
862 subtest 'can_patron_change_staff_only_lists() tests' => sub {
863
864     plan tests => 3;
865
866     $schema->storage->txn_begin;
867
868     # make a user with no special permissions
869     my $patron = $builder->build_object(
870         {
871             class => 'Koha::Patrons',
872             value => {
873                 flags => undef
874             }
875         }
876     );
877     is( $patron->can_patron_change_staff_only_lists(), 0, 'Patron without permissions cannot change staff only lists');
878
879     # make it a 'Catalogue' permission
880     $patron->set({ flags => 4 })->store->discard_changes;
881     is( $patron->can_patron_change_staff_only_lists(), 1, 'Catalogue patron can change staff only lists');
882
883
884     # make it a superlibrarian
885     $patron->set({ flags => 1 })->store->discard_changes;
886     is( $patron->can_patron_change_staff_only_lists(), 1, 'Superlibrarian patron can change staff only lists');
887
888     $schema->storage->txn_rollback;
889 };
890
891 subtest 'password expiration tests' => sub {
892
893     plan tests => 5;
894
895     $schema->storage->txn_begin;
896     my $date = dt_from_string();
897     my $category = $builder->build_object({ class => 'Koha::Patron::Categories', value => {
898             password_expiry_days => 10,
899             require_strong_password => 0,
900         }
901     });
902     my $patron = $builder->build_object({ class=> 'Koha::Patrons', value => {
903             categorycode => $category->categorycode,
904             password => 'hats'
905         }
906     });
907
908     $patron->delete()->store()->discard_changes(); # Make sure we are storing a 'new' patron
909
910     is( $patron->password_expiration_date(), $date->add( days => 10 )->ymd() , "Password expiration date set correctly on patron creation");
911
912     $patron = $builder->build_object({ class => 'Koha::Patrons', value => {
913             categorycode => $category->categorycode,
914             password => undef
915         }
916     });
917     $patron->delete()->store()->discard_changes();
918
919     is( $patron->password_expiration_date(), undef, "Password expiration date is not set if patron does not have a password");
920
921     $category->password_expiry_days(undef)->store();
922     $patron = $builder->build_object({ class => 'Koha::Patrons', value => {
923             categorycode => $category->categorycode
924         }
925     });
926     $patron->delete()->store()->discard_changes();
927     is( $patron->password_expiration_date(), undef, "Password expiration date is not set if category does not have expiry days set");
928
929     $schema->storage->txn_rollback;
930
931     subtest 'password_expired' => sub {
932
933         plan tests => 3;
934
935         $schema->storage->txn_begin;
936         my $date = dt_from_string();
937         $patron = $builder->build_object({ class => 'Koha::Patrons', value => {
938                 password_expiration_date => undef
939             }
940         });
941         is( $patron->password_expired, 0, "Patron with no password expiration date, password not expired");
942         $patron->password_expiration_date( $date )->store;
943         $patron->discard_changes();
944         is( $patron->password_expired, 1, "Patron with password expiration date of today, password expired");
945         $date->subtract( days => 1 );
946         $patron->password_expiration_date( $date )->store;
947         $patron->discard_changes();
948         is( $patron->password_expired, 1, "Patron with password expiration date in past, password expired");
949
950         $schema->storage->txn_rollback;
951     };
952
953     subtest 'set_password' => sub {
954
955         plan tests => 4;
956
957         $schema->storage->txn_begin;
958
959         my $date = dt_from_string();
960         my $category = $builder->build_object({ class => 'Koha::Patron::Categories', value => {
961                 password_expiry_days => 10
962             }
963         });
964         my $patron = $builder->build_object({ class => 'Koha::Patrons', value => {
965                 categorycode => $category->categorycode,
966                 password_expiration_date =>  $date->subtract( days => 1 )
967             }
968         });
969         is( $patron->password_expired, 1, "Patron password is expired");
970
971         $date = dt_from_string();
972         $patron->set_password({ password => "kitten", skip_validation => 1 })->discard_changes();
973         is( $patron->password_expired, 0, "Patron password no longer expired when new password set");
974         is( $patron->password_expiration_date(), $date->add( days => 10 )->ymd(), "Password expiration date set correctly on patron creation");
975
976
977         $category->password_expiry_days( undef )->store();
978         $patron->set_password({ password => "puppies", skip_validation => 1 })->discard_changes();
979         is( $patron->password_expiration_date(), undef, "Password expiration date is unset if category does not have expiry days");
980
981         $schema->storage->txn_rollback;
982     };
983
984 };
985
986 subtest 'safe_to_delete() tests' => sub {
987
988     plan tests => 14;
989
990     $schema->storage->txn_begin;
991
992     my $patron = $builder->build_object({ class => 'Koha::Patrons' });
993
994     ## Make it the anonymous
995     t::lib::Mocks::mock_preference( 'AnonymousPatron', $patron->id );
996
997     ok( !$patron->safe_to_delete, 'Cannot delete, it is the anonymous patron' );
998     my $message = $patron->safe_to_delete->messages->[0];
999     is( $message->type, 'error', 'Type is error' );
1000     is( $message->message, 'is_anonymous_patron', 'Cannot delete, it is the anonymous patron' );
1001     # cleanup
1002     t::lib::Mocks::mock_preference( 'AnonymousPatron', 0 );
1003
1004     ## Make it have a checkout
1005     my $checkout = $builder->build_object(
1006         {
1007             class => 'Koha::Checkouts',
1008             value => { borrowernumber => $patron->id }
1009         }
1010     );
1011
1012     ok( !$patron->safe_to_delete, 'Cannot delete, has checkouts' );
1013     $message = $patron->safe_to_delete->messages->[0];
1014     is( $message->type, 'error', 'Type is error' );
1015     is( $message->message, 'has_checkouts', 'Cannot delete, has checkouts' );
1016     # cleanup
1017     $checkout->delete;
1018
1019     ## Make it have a guarantee
1020     t::lib::Mocks::mock_preference( 'borrowerRelationship', 'parent' );
1021     $builder->build_object({ class => 'Koha::Patrons' })
1022             ->add_guarantor({ guarantor_id => $patron->id, relationship => 'parent' });
1023
1024     ok( !$patron->safe_to_delete, 'Cannot delete, has guarantees' );
1025     $message = $patron->safe_to_delete->messages->[0];
1026     is( $message->type, 'error', 'Type is error' );
1027     is( $message->message, 'has_guarantees', 'Cannot delete, has guarantees' );
1028
1029     # cleanup
1030     $patron->guarantee_relationships->delete;
1031
1032     ## Make it have debt
1033     my $debit = $patron->account->add_debit({ amount => 10, interface => 'intranet', type => 'MANUAL' });
1034
1035     ok( !$patron->safe_to_delete, 'Cannot delete, has debt' );
1036     $message = $patron->safe_to_delete->messages->[0];
1037     is( $message->type, 'error', 'Type is error' );
1038     is( $message->message, 'has_debt', 'Cannot delete, has debt' );
1039     # cleanup
1040     $patron->account->pay({ amount => 10, debits => [ $debit ] });
1041
1042     ## Happy case :-D
1043     ok( $patron->safe_to_delete, 'Can delete, all conditions met' );
1044     my $messages = $patron->safe_to_delete->messages;
1045     is_deeply( $messages, [], 'Patron can be deleted, no messages' );
1046 };
1047
1048 subtest 'article_request_fee() tests' => sub {
1049
1050     plan tests => 3;
1051
1052     $schema->storage->txn_begin;
1053
1054     # Cleanup, to avoid interference
1055     Koha::CirculationRules->search( { rule_name => 'article_request_fee' } )->delete;
1056
1057     t::lib::Mocks::mock_preference( 'ArticleRequests', 1 );
1058
1059     my $item = $builder->build_sample_item;
1060
1061     my $library_1 = $builder->build_object( { class => 'Koha::Libraries' } );
1062     my $library_2 = $builder->build_object( { class => 'Koha::Libraries' } );
1063     my $patron    = $builder->build_object( { class => 'Koha::Patrons' } );
1064
1065     # Rule that should never be picked, because the patron's category is always picked
1066     Koha::CirculationRules->set_rule(
1067         {   categorycode => undef,
1068             branchcode   => undef,
1069             rule_name    => 'article_request_fee',
1070             rule_value   => 1,
1071         }
1072     );
1073
1074     is( $patron->article_request_fee( { library_id => $library_2->id } ), 1, 'library_id used correctly' );
1075
1076     Koha::CirculationRules->set_rule(
1077         {   categorycode => $patron->categorycode,
1078             branchcode   => undef,
1079             rule_name    => 'article_request_fee',
1080             rule_value   => 2,
1081         }
1082     );
1083
1084     Koha::CirculationRules->set_rule(
1085         {   categorycode => $patron->categorycode,
1086             branchcode   => $library_1->id,
1087             rule_name    => 'article_request_fee',
1088             rule_value   => 3,
1089         }
1090     );
1091
1092     is( $patron->article_request_fee( { library_id => $library_2->id } ), 2, 'library_id used correctly' );
1093
1094     t::lib::Mocks::mock_userenv( { branchcode => $library_1->id } );
1095
1096     is( $patron->article_request_fee(), 3, 'env used correctly' );
1097
1098     $schema->storage->txn_rollback;
1099 };
1100
1101 subtest 'add_article_request_fee_if_needed() tests' => sub {
1102
1103     plan tests => 12;
1104
1105     $schema->storage->txn_begin;
1106
1107     my $amount = 0;
1108
1109     my $patron_mock = Test::MockModule->new('Koha::Patron');
1110     $patron_mock->mock( 'article_request_fee', sub { return $amount; } );
1111
1112     my $patron = $builder->build_object( { class => 'Koha::Patrons' } );
1113
1114     is( $patron->article_request_fee, $amount, 'article_request_fee mocked' );
1115
1116     my $library_1 = $builder->build_object( { class => 'Koha::Libraries' } );
1117     my $library_2 = $builder->build_object( { class => 'Koha::Libraries' } );
1118     my $staff     = $builder->build_object( { class => 'Koha::Patrons' } );
1119     my $item      = $builder->build_sample_item;
1120
1121     t::lib::Mocks::mock_userenv(
1122         { branchcode => $library_1->id, patron => $staff } );
1123
1124     my $debit = $patron->add_article_request_fee_if_needed();
1125     is( $debit, undef, 'No fee, no debit line' );
1126
1127     # positive value
1128     $amount = 1;
1129
1130     $debit = $patron->add_article_request_fee_if_needed({ item_id => $item->id });
1131     is( ref($debit), 'Koha::Account::Line', 'Debit object type correct' );
1132     is( $debit->amount, $amount,
1133         'amount set to $patron->article_request_fee value' );
1134     is( $debit->manager_id, $staff->id,
1135         'manager_id set to userenv session user' );
1136     is( $debit->branchcode, $library_1->id,
1137         'branchcode set to userenv session library' );
1138     is( $debit->debit_type_code, 'ARTICLE_REQUEST',
1139         'debit_type_code set correctly' );
1140     is( $debit->itemnumber, $item->id,
1141         'itemnumber set correctly' );
1142
1143     $amount = 100;
1144
1145     $debit = $patron->add_article_request_fee_if_needed({ library_id => $library_2->id });
1146     is( ref($debit), 'Koha::Account::Line', 'Debit object type correct' );
1147     is( $debit->amount, $amount,
1148         'amount set to $patron->article_request_fee value' );
1149     is( $debit->branchcode, $library_2->id,
1150         'branchcode set to userenv session library' );
1151     is( $debit->itemnumber, undef,
1152         'itemnumber set correctly to undef' );
1153
1154     $schema->storage->txn_rollback;
1155 };
1156
1157 subtest 'messages' => sub {
1158     plan tests => 4;
1159
1160     $schema->storage->txn_begin;
1161
1162     my $patron = $builder->build_object( { class => 'Koha::Patrons' } );
1163     my $messages = $patron->messages;
1164     is( $messages->count, 0, "No message yet" );
1165     my $message_1 = $builder->build_object(
1166         {
1167             class => 'Koha::Patron::Messages',
1168             value => { borrowernumber => $patron->borrowernumber }
1169         }
1170     );
1171     my $message_2 = $builder->build_object(
1172         {
1173             class => 'Koha::Patron::Messages',
1174             value => { borrowernumber => $patron->borrowernumber }
1175         }
1176     );
1177
1178     $messages = $patron->messages;
1179     is( $messages->count, 2, "There are two messages for this patron" );
1180     is( $messages->next->message, $message_1->message );
1181     is( $messages->next->message, $message_2->message );
1182     $schema->storage->txn_rollback;
1183 };
1184
1185 subtest 'recalls() tests' => sub {
1186
1187     plan tests => 3;
1188
1189     $schema->storage->txn_begin;
1190
1191     my $patron = $builder->build_object( { class => 'Koha::Patrons' } );
1192     my $biblio1 = $builder->build_object({ class => 'Koha::Biblios' });
1193     my $item1 = $builder->build_object({ class => 'Koha::Items' }, { value => { biblionumber => $biblio1->biblionumber } });
1194     my $biblio2 = $builder->build_object({ class => 'Koha::Biblios' });
1195     my $item2 = $builder->build_object({ class => 'Koha::Items' }, { value => { biblionumber => $biblio2->biblionumber } });
1196
1197     Koha::Recall->new(
1198         {   biblio_id         => $biblio1->biblionumber,
1199             patron_id         => $patron->borrowernumber,
1200             item_id           => $item1->itemnumber,
1201             pickup_library_id => $patron->branchcode,
1202             created_date      => \'NOW()',
1203             item_level        => 1,
1204         }
1205     )->store;
1206     Koha::Recall->new(
1207         {   biblio_id         => $biblio2->biblionumber,
1208             patron_id         => $patron->borrowernumber,
1209             item_id           => $item2->itemnumber,
1210             pickup_library_id => $patron->branchcode,
1211             created_date      => \'NOW()',
1212             item_level        => 1,
1213         }
1214     )->store;
1215     Koha::Recall->new(
1216         {   biblio_id         => $biblio1->biblionumber,
1217             patron_id         => $patron->borrowernumber,
1218             item_id           => undef,
1219             pickup_library_id => $patron->branchcode,
1220             created_date      => \'NOW()',
1221             item_level        => 0,
1222         }
1223     )->store;
1224     my $recall = Koha::Recall->new(
1225         {   biblio_id         => $biblio1->biblionumber,
1226             patron_id         => $patron->borrowernumber,
1227             item_id           => undef,
1228             pickup_library_id => $patron->branchcode,
1229             created_date      => \'NOW()',
1230             item_level        => 0,
1231         }
1232     )->store;
1233     $recall->set_cancelled;
1234
1235     is( $patron->recalls->count,                                                                       4, "Correctly gets this patron's recalls" );
1236     is( $patron->recalls->filter_by_current->count,                                                    3, "Correctly gets this patron's active recalls" );
1237     is( $patron->recalls->filter_by_current->search( { biblio_id => $biblio1->biblionumber } )->count, 2, "Correctly gets this patron's active recalls on a specific biblio" );
1238
1239     $schema->storage->txn_rollback;
1240 };
1241
1242 subtest 'encode_secret and decoded_secret' => sub {
1243     plan tests => 5;
1244     $schema->storage->txn_begin;
1245
1246     t::lib::Mocks::mock_config('encryption_key', 't0P_secret');
1247
1248     my $patron = $builder->build_object({ class => 'Koha::Patrons' });
1249     is( $patron->decoded_secret, undef, 'TestBuilder does not initialize it' );
1250     $patron->secret(q{});
1251     is( $patron->decoded_secret, q{}, 'Empty string case' );
1252
1253     $patron->encode_secret('encrypt_me'); # Note: lazy testing; should be base32 string normally.
1254     is( length($patron->secret) > 0, 1, 'Secret length' );
1255     isnt( $patron->secret, 'encrypt_me', 'Encrypted column' );
1256     is( $patron->decoded_secret, 'encrypt_me', 'Decrypted column' );
1257
1258     $schema->storage->txn_rollback;
1259 };