Bug 29741: (follow-up) Make DELETE /patrons use the new validation method
[koha.git] / t / db_dependent / api / v1 / patrons.t
1 #!/usr/bin/env perl
2
3 # This file is part of Koha.
4 #
5 # Koha is free software; you can redistribute it and/or modify it
6 # under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 3 of the License, or
8 # (at your option) any later version.
9 #
10 # Koha is distributed in the hope that it will be useful, but
11 # WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with Koha; if not, see <http://www.gnu.org/licenses>.
17
18 use Modern::Perl;
19
20 use Test::More tests => 7;
21 use Test::MockModule;
22 use Test::Mojo;
23 use Test::Warn;
24
25 use t::lib::TestBuilder;
26 use t::lib::Mocks;
27 use t::lib::Dates;
28
29 use C4::Auth;
30 use Koha::Database;
31 use Koha::DateUtils qw(dt_from_string output_pref);
32 use Koha::Exceptions::Patron;
33 use Koha::Exceptions::Patron::Attribute;
34 use Koha::Old::Patrons;
35 use Koha::Patron::Attributes;
36 use Koha::Patron::Debarments qw( AddDebarment );
37
38 use JSON qw(encode_json);
39
40 my $schema  = Koha::Database->new->schema;
41 my $builder = t::lib::TestBuilder->new;
42
43 my $t = Test::Mojo->new('Koha::REST::V1');
44 t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 );
45
46 subtest 'list() tests' => sub {
47     plan tests => 2;
48
49     $schema->storage->txn_begin;
50     unauthorized_access_tests('GET', undef, undef);
51     $schema->storage->txn_rollback;
52
53     subtest 'librarian access tests' => sub {
54         plan tests => 17;
55
56         $schema->storage->txn_begin;
57
58         my $librarian = $builder->build_object(
59             {
60                 class => 'Koha::Patrons',
61                 value => { flags => 2**4 }    # borrowers flag = 4
62             }
63         );
64         my $password = 'thePassword123';
65         $librarian->set_password( { password => $password, skip_validation => 1 } );
66         my $userid = $librarian->userid;
67
68         $t->get_ok("//$userid:$password@/api/v1/patrons")
69           ->status_is(200);
70
71         $t->get_ok("//$userid:$password@/api/v1/patrons?cardnumber=" . $librarian->cardnumber)
72           ->status_is(200)
73           ->json_is('/0/cardnumber' => $librarian->cardnumber);
74
75         $t->get_ok("//$userid:$password@/api/v1/patrons?q={\"cardnumber\":\"" . $librarian->cardnumber ."\"}")
76           ->status_is(200)
77           ->json_is('/0/cardnumber' => $librarian->cardnumber);
78
79         $t->get_ok("//$userid:$password@/api/v1/patrons?address2=" . $librarian->address2)
80           ->status_is(200)
81           ->json_is('/0/address2' => $librarian->address2);
82
83         my $patron = $builder->build_object({ class => 'Koha::Patrons' });
84         AddDebarment({ borrowernumber => $patron->borrowernumber });
85
86         $t->get_ok("//$userid:$password@/api/v1/patrons?restricted=" . Mojo::JSON->true . "&cardnumber=" . $patron->cardnumber )
87           ->status_is(200)
88           ->json_has('/0/restricted')
89           ->json_is( '/0/restricted' => Mojo::JSON->true )
90           ->json_hasnt('/1');
91
92         subtest 'searching date and date-time fields' => sub {
93
94             plan tests => 12;
95
96             my $date_of_birth = '1980-06-18';
97             my $last_seen     = '2021-06-25 14:05:35';
98
99             my $patron = $builder->build_object(
100                 {
101                     class => 'Koha::Patrons',
102                     value => {
103                         dateofbirth => $date_of_birth,
104                         lastseen    => $last_seen,
105                     }
106                 }
107             );
108
109             my $last_seen_rfc3339 = $last_seen . "z";
110
111             $t->get_ok("//$userid:$password@/api/v1/patrons?date_of_birth=" . $date_of_birth . "&cardnumber=" . $patron->cardnumber)
112               ->status_is(200)
113               ->json_is( '/0/patron_id' => $patron->id, 'Filtering by date works' );
114
115             $t->get_ok("//$userid:$password@/api/v1/patrons?last_seen=" . $last_seen_rfc3339 . "&cardnumber=" . $patron->cardnumber)
116               ->status_is(200)
117               ->json_is( '/0/patron_id' => $patron->id, 'Filtering by date-time works' );
118
119             my $q = encode_json(
120                 {
121                     date_of_birth => $date_of_birth,
122                     cardnumber    => $patron->cardnumber,
123                 }
124             );
125
126             $t->get_ok("//$userid:$password@/api/v1/patrons?q=$q")
127               ->status_is(200)
128               ->json_is( '/0/patron_id' => $patron->id, 'Filtering by date works' );
129
130             $q = encode_json(
131                 {
132                     last_seen  => $last_seen_rfc3339,
133                     cardnumber => $patron->cardnumber,
134                 }
135             );
136
137             $t->get_ok("//$userid:$password@/api/v1/patrons?q=$q")
138               ->status_is(200)
139               ->json_is( '/0/patron_id' => $patron->id, 'Filtering by date-time works' );
140         };
141
142         $schema->storage->txn_rollback;
143     };
144 };
145
146 subtest 'get() tests' => sub {
147     plan tests => 2;
148
149     $schema->storage->txn_begin;
150     unauthorized_access_tests('GET', -1, undef);
151     $schema->storage->txn_rollback;
152
153     subtest 'librarian access tests' => sub {
154         plan tests => 6;
155
156         $schema->storage->txn_begin;
157
158         my $librarian = $builder->build_object(
159             {
160                 class => 'Koha::Patrons',
161                 value => { flags => 2**4 }    # borrowers flag = 4
162             }
163         );
164         my $password = 'thePassword123';
165         $librarian->set_password( { password => $password, skip_validation => 1 } );
166         my $userid = $librarian->userid;
167
168         my $patron = $builder->build_object({ class => 'Koha::Patrons' });
169
170         $t->get_ok("//$userid:$password@/api/v1/patrons/" . $patron->id)
171           ->status_is(200)
172           ->json_is('/patron_id'        => $patron->id)
173           ->json_is('/category_id'      => $patron->categorycode )
174           ->json_is('/surname'          => $patron->surname)
175           ->json_is('/patron_card_lost' => Mojo::JSON->false );
176
177         $schema->storage->txn_rollback;
178     };
179 };
180
181 subtest 'add() tests' => sub {
182     plan tests => 2;
183
184     $schema->storage->txn_begin;
185
186     my $patron = $builder->build_object( { class => 'Koha::Patrons' } )->to_api;
187
188     unauthorized_access_tests('POST', undef, $patron);
189
190     $schema->storage->txn_rollback;
191
192     subtest 'librarian access tests' => sub {
193         plan tests => 24;
194
195         $schema->storage->txn_begin;
196
197         my $extended_attrs_exception;
198         my $type = 'hey';
199         my $code = 'ho';
200         my $attr = "Let's go";
201
202         # Mock early, so existing mandatory attributes don't break all the tests
203         my $mocked_patron = Test::MockModule->new('Koha::Patron');
204         $mocked_patron->mock(
205             'extended_attributes',
206             sub {
207
208                 if ($extended_attrs_exception) {
209                     if ( $extended_attrs_exception eq 'Koha::Exceptions::Patron::Attribute::NonRepeatable'
210                         or $extended_attrs_exception eq 'Koha::Exceptions::Patron::Attribute::UniqueIDConstraint'
211                       )
212                     {
213                         $extended_attrs_exception->throw(
214                             attribute => Koha::Patron::Attribute->new(
215                                 { code => $code, attribute => $attr }
216                             )
217                         );
218                     }
219                     else {
220                         $extended_attrs_exception->throw( type => $type );
221                     }
222                 }
223                 return [];
224             }
225         );
226
227         my $patron = $builder->build_object({ class => 'Koha::Patrons' });
228         my $newpatron = $patron->to_api;
229         # delete RO attributes
230         delete $newpatron->{patron_id};
231         delete $newpatron->{restricted};
232         delete $newpatron->{anonymized};
233
234         # Create a library just to make sure its ID doesn't exist on the DB
235         my $library_to_delete = $builder->build_object({ class => 'Koha::Libraries' });
236         my $deleted_library_id = $library_to_delete->id;
237         # Delete library
238         $library_to_delete->delete;
239
240         my $librarian = $builder->build_object(
241             {
242                 class => 'Koha::Patrons',
243                 value => { flags => 2**4 }    # borrowers flag = 4
244             }
245         );
246         my $password = 'thePassword123';
247         $librarian->set_password( { password => $password, skip_validation => 1 } );
248         my $userid = $librarian->userid;
249
250         $newpatron->{library_id} = $deleted_library_id;
251
252         warning_like {
253             $t->post_ok("//$userid:$password@/api/v1/patrons" => json => $newpatron)
254               ->status_is(409)
255               ->json_is('/error' => "Duplicate ID"); }
256             qr/DBD::mysql::st execute failed: Duplicate entry/;
257
258         $newpatron->{library_id} = $patron->branchcode;
259
260         # Create a library just to make sure its ID doesn't exist on the DB
261         my $category_to_delete = $builder->build_object({ class => 'Koha::Patron::Categories' });
262         my $deleted_category_id = $category_to_delete->id;
263         # Delete library
264         $category_to_delete->delete;
265
266         $newpatron->{category_id} = $deleted_category_id; # Test invalid patron category
267
268         $t->post_ok("//$userid:$password@/api/v1/patrons" => json => $newpatron)
269           ->status_is(400)
270           ->json_is('/error' => "Given category_id does not exist");
271         $newpatron->{category_id} = $patron->categorycode;
272
273         $newpatron->{falseproperty} = "Non existent property";
274
275         $t->post_ok("//$userid:$password@/api/v1/patrons" => json => $newpatron)
276           ->status_is(400);
277
278         delete $newpatron->{falseproperty};
279
280         my $patron_to_delete = $builder->build_object({ class => 'Koha::Patrons' });
281         $newpatron = $patron_to_delete->to_api;
282         # delete RO attributes
283         delete $newpatron->{patron_id};
284         delete $newpatron->{restricted};
285         delete $newpatron->{anonymized};
286         $patron_to_delete->delete;
287
288         # Set a date field
289         $newpatron->{date_of_birth} = '1980-06-18';
290         # Set a date-time field
291         $newpatron->{last_seen} = output_pref({ dt => dt_from_string->add( days => -1 ), dateformat => 'rfc3339' });
292
293         $t->post_ok("//$userid:$password@/api/v1/patrons" => json => $newpatron)
294           ->status_is(201, 'Patron created successfully')
295           ->header_like(
296             Location => qr|^\/api\/v1\/patrons/\d*|,
297             'SWAGGER3.4.1'
298           )
299           ->json_has('/patron_id', 'got a patron_id')
300           ->json_is( '/cardnumber'    => $newpatron->{ cardnumber })
301           ->json_is( '/surname'       => $newpatron->{ surname })
302           ->json_is( '/firstname'     => $newpatron->{ firstname })
303           ->json_is( '/date_of_birth' => $newpatron->{ date_of_birth }, 'Date field set (Bug 28585)' )
304           ->json_is( '/last_seen'     => $newpatron->{ last_seen }, 'Date-time field set (Bug 28585)' );
305
306         warning_like {
307             $t->post_ok("//$userid:$password@/api/v1/patrons" => json => $newpatron)
308               ->status_is(409)
309               ->json_has( '/error', 'Fails when trying to POST duplicate cardnumber' )
310               ->json_like( '/conflict' => qr/(borrowers\.)?cardnumber/ ); }
311             qr/DBD::mysql::st execute failed: Duplicate entry '(.*?)' for key '(borrowers\.)?cardnumber'/;
312
313         subtest 'extended_attributes handling tests' => sub {
314
315             plan tests => 19;
316
317             my $patrons_count = Koha::Patrons->search->count;
318
319             $extended_attrs_exception = 'Koha::Exceptions::Patron::MissingMandatoryExtendedAttribute';
320             $t->post_ok(
321                 "//$userid:$password@/api/v1/patrons" => json => {
322                     "firstname"   => "Katrina",
323                     "surname"     => "Fischer",
324                     "address"     => "Somewhere",
325                     "category_id" => "ST",
326                     "city"        => "Konstanz",
327                     "library_id"  => "MPL"
328                 }
329             )->status_is(400)
330               ->json_is( '/error' =>
331                   "Missing mandatory extended attribute (type=$type)" );
332
333             is( Koha::Patrons->search->count, $patrons_count, 'No patron added' );
334
335             $extended_attrs_exception = 'Koha::Exceptions::Patron::Attribute::InvalidType';
336             $t->post_ok(
337                 "//$userid:$password@/api/v1/patrons" => json => {
338                     "firstname"   => "Katrina",
339                     "surname"     => "Fischer",
340                     "address"     => "Somewhere",
341                     "category_id" => "ST",
342                     "city"        => "Konstanz",
343                     "library_id"  => "MPL"
344                 }
345             )->status_is(400)
346               ->json_is( '/error' =>
347                   "Tried to use an invalid attribute type. type=$type" );
348
349             is( Koha::Patrons->search->count, $patrons_count, 'No patron added' );
350
351             $extended_attrs_exception = 'Koha::Exceptions::Patron::Attribute::NonRepeatable';
352             $t->post_ok(
353                 "//$userid:$password@/api/v1/patrons" => json => {
354                     "firstname"   => "Katrina",
355                     "surname"     => "Fischer",
356                     "address"     => "Somewhere",
357                     "category_id" => "ST",
358                     "city"        => "Konstanz",
359                     "library_id"  => "MPL"
360                 }
361             )->status_is(400)
362               ->json_is( '/error' =>
363                   "Tried to add more than one non-repeatable attributes. type=$code value=$attr" );
364
365             is( Koha::Patrons->search->count, $patrons_count, 'No patron added' );
366
367             $extended_attrs_exception = 'Koha::Exceptions::Patron::Attribute::UniqueIDConstraint';
368             $t->post_ok(
369                 "//$userid:$password@/api/v1/patrons" => json => {
370                     "firstname"   => "Katrina",
371                     "surname"     => "Fischer",
372                     "address"     => "Somewhere",
373                     "category_id" => "ST",
374                     "city"        => "Konstanz",
375                     "library_id"  => "MPL"
376                 }
377             )->status_is(400)
378               ->json_is( '/error' =>
379                   "Your action breaks a unique constraint on the attribute. type=$code value=$attr" );
380
381             is( Koha::Patrons->search->count, $patrons_count, 'No patron added' );
382
383             $mocked_patron->unmock('extended_attributes');
384             # Temporarily get rid of mandatory attribute types
385             Koha::Patron::Attribute::Types->search({ mandatory => 1 })->delete;
386             # Create a couple attribute attribute types
387             my $repeatable_1 = $builder->build_object(
388                 {
389                     class => 'Koha::Patron::Attribute::Types',
390                     value => {
391                         mandatory     => 0,
392                         repeatable    => 1,
393                         unique        => 0,
394                         category_code => 'ST'
395                     }
396                 }
397             );
398             my $repeatable_2 = $builder->build_object(
399                 {
400                     class => 'Koha::Patron::Attribute::Types',
401                     value => {
402                         mandatory     => 0,
403                         repeatable    => 1,
404                         unique        => 0,
405                         category_code => 'ST'
406                     }
407                 }
408             );
409
410             my $patron_id = $t->post_ok(
411                 "//$userid:$password@/api/v1/patrons" => json => {
412                     "firstname"   => "Katrina",
413                     "surname"     => "Fischer",
414                     "address"     => "Somewhere",
415                     "category_id" => "ST",
416                     "city"        => "Konstanz",
417                     "library_id"  => "MPL",
418                     "extended_attributes" => [
419                         { type => $repeatable_1->code, value => 'a' },
420                         { type => $repeatable_1->code, value => 'b' },
421                         { type => $repeatable_1->code, value => 'c' },
422                         { type => $repeatable_2->code, value => 'd' },
423                         { type => $repeatable_2->code, value => 'e' }
424                     ]
425                 }
426             )->status_is(201, 'Patron added')->tx->res->json->{patron_id};
427             my $extended_attributes = join( ' ', sort map {$_->attribute} Koha::Patrons->find($patron_id)->extended_attributes->as_list);
428             is( $extended_attributes, 'a b c d e', 'Extended attributes are stored correctly');
429         };
430
431         $schema->storage->txn_rollback;
432     };
433 };
434
435 subtest 'update() tests' => sub {
436     plan tests => 2;
437
438     $schema->storage->txn_begin;
439     unauthorized_access_tests('PUT', 123, {email => 'nobody@example.com'});
440     $schema->storage->txn_rollback;
441
442     subtest 'librarian access tests' => sub {
443         plan tests => 44;
444
445         $schema->storage->txn_begin;
446
447         my $authorized_patron = $builder->build_object(
448             {
449                 class => 'Koha::Patrons',
450                 value => { flags => 1 }
451             }
452         );
453         my $password = 'thePassword123';
454         $authorized_patron->set_password(
455             { password => $password, skip_validation => 1 } );
456         my $userid = $authorized_patron->userid;
457
458         my $unauthorized_patron = $builder->build_object(
459             {
460                 class => 'Koha::Patrons',
461                 value => { flags => 0 }
462             }
463         );
464         $unauthorized_patron->set_password( { password => $password, skip_validation => 1 } );
465         my $unauth_userid = $unauthorized_patron->userid;
466
467         my $patron_1  = $authorized_patron;
468         my $patron_2  = $unauthorized_patron;
469         my $newpatron = $unauthorized_patron->to_api;
470         # delete RO attributes
471         delete $newpatron->{patron_id};
472         delete $newpatron->{restricted};
473         delete $newpatron->{anonymized};
474
475         $t->put_ok("//$userid:$password@/api/v1/patrons/-1" => json => $newpatron)
476           ->status_is(404)
477           ->json_has('/error', 'Fails when trying to PUT nonexistent patron');
478
479         # Create a library just to make sure its ID doesn't exist on the DB
480         my $category_to_delete = $builder->build_object({ class => 'Koha::Patron::Categories' });
481         my $deleted_category_id = $category_to_delete->id;
482         # Delete library
483         $category_to_delete->delete;
484
485         # Use an invalid category
486         $newpatron->{category_id} = $deleted_category_id;
487
488         $t->put_ok("//$userid:$password@/api/v1/patrons/" . $patron_2->borrowernumber => json => $newpatron)
489           ->status_is(400)
490           ->json_is('/error' => "Given category_id does not exist");
491
492         # Restore the valid category
493         $newpatron->{category_id} = $patron_2->categorycode;
494
495         # Create a library just to make sure its ID doesn't exist on the DB
496         my $library_to_delete = $builder->build_object({ class => 'Koha::Libraries' });
497         my $deleted_library_id = $library_to_delete->id;
498         # Delete library
499         $library_to_delete->delete;
500
501         # Use an invalid library_id
502         $newpatron->{library_id} = $deleted_library_id;
503
504         warning_like {
505             $t->put_ok("//$userid:$password@/api/v1/patrons/" . $patron_2->borrowernumber => json => $newpatron)
506               ->status_is(400)
507               ->json_is('/error' => "Given library_id does not exist"); }
508             qr/DBD::mysql::st execute failed: Cannot add or update a child row: a foreign key constraint fails/;
509
510         # Restore the valid library_id
511         $newpatron->{library_id} = $patron_2->branchcode;
512
513         # Use an invalid attribute
514         $newpatron->{falseproperty} = "Non existent property";
515
516         $t->put_ok( "//$userid:$password@/api/v1/patrons/" . $patron_2->borrowernumber => json => $newpatron )
517           ->status_is(400)
518           ->json_is('/errors/0/message' =>
519                     'Properties not allowed: falseproperty.');
520
521         # Get rid of the invalid attribute
522         delete $newpatron->{falseproperty};
523
524         # Set both cardnumber and userid to already existing values
525         $newpatron->{cardnumber} = $patron_1->cardnumber;
526         $newpatron->{userid}     = $patron_1->userid;
527
528         warning_like {
529             $t->put_ok( "//$userid:$password@/api/v1/patrons/" . $patron_2->borrowernumber => json => $newpatron )
530               ->status_is(409)
531               ->json_has( '/error', "Fails when trying to update to an existing cardnumber or userid")
532               ->json_like( '/conflict' => qr/(borrowers\.)?cardnumber/ ); }
533             qr/DBD::mysql::st execute failed: Duplicate entry '(.*?)' for key '(borrowers\.)?cardnumber'/;
534
535         $newpatron->{ cardnumber } = $patron_1->id . $patron_2->id;
536         $newpatron->{ userid }     = "user" . $patron_1->id.$patron_2->id;
537         $newpatron->{ surname }    = "user" . $patron_1->id.$patron_2->id;
538
539         ## Trying to set to null on specially handled cases
540         # Special case: a date
541         $newpatron->{ date_of_birth } = undef;
542         # Special case: a date-time
543         $newpatron->{ last_seen } = undef;
544
545         my $result = $t->put_ok( "//$userid:$password@/api/v1/patrons/" . $patron_2->borrowernumber => json => $newpatron )
546           ->status_is(200, 'Patron updated successfully');
547
548         # Put back the RO attributes
549         $newpatron->{patron_id} = $unauthorized_patron->to_api->{patron_id};
550         $newpatron->{restricted} = $unauthorized_patron->to_api->{restricted};
551         $newpatron->{anonymized} = $unauthorized_patron->to_api->{anonymized};
552
553         my $got = $result->tx->res->json;
554         my $updated_on_got = delete $got->{updated_on};
555         my $updated_on_expected = delete $newpatron->{updated_on};
556         is_deeply($got, $newpatron, 'Returned patron from update matches expected');
557         t::lib::Dates::compare( $updated_on_got, $updated_on_expected, 'updated_on values matched' );
558
559         is(Koha::Patrons->find( $patron_2->id )->cardnumber,
560            $newpatron->{ cardnumber }, 'Patron is really updated!');
561
562         my $superlibrarian = $builder->build_object(
563             {
564                 class => 'Koha::Patrons',
565                 value => { flags => 1 }
566             }
567         );
568
569         $newpatron->{cardnumber} = $superlibrarian->cardnumber;
570         $newpatron->{userid}     = $superlibrarian->userid;
571         $newpatron->{email}      = 'nosense@no.no';
572         # delete RO attributes
573         delete $newpatron->{patron_id};
574         delete $newpatron->{restricted};
575         delete $newpatron->{anonymized};
576
577         # attempt to update
578         $authorized_patron->flags( 2**4 )->store; # borrowers flag = 4
579         $t->put_ok( "//$userid:$password@/api/v1/patrons/" . $superlibrarian->borrowernumber => json => $newpatron )
580           ->status_is(403, "Non-superlibrarian user change of superlibrarian email forbidden")
581           ->json_is( { error => "Not enough privileges to change a superlibrarian's email" } );
582
583         # attempt to unset
584         $newpatron->{email} = undef;
585         $t->put_ok( "//$userid:$password@/api/v1/patrons/" . $superlibrarian->borrowernumber => json => $newpatron )
586           ->status_is(403, "Non-superlibrarian user change of superlibrarian email to undefined forbidden")
587           ->json_is( { error => "Not enough privileges to change a superlibrarian's email" } );
588
589         $newpatron->{email}           = $superlibrarian->email;
590         $newpatron->{secondary_email} = 'nonsense@no.no';
591
592         # attempt to update
593         $t->put_ok( "//$userid:$password@/api/v1/patrons/" . $superlibrarian->borrowernumber => json => $newpatron )
594           ->status_is(403, "Non-superlibrarian user change of superlibrarian secondary_email forbidden")
595           ->json_is( { error => "Not enough privileges to change a superlibrarian's email" } );
596
597         # attempt to unset
598         $newpatron->{secondary_email} = undef;
599         $t->put_ok( "//$userid:$password@/api/v1/patrons/" . $superlibrarian->borrowernumber => json => $newpatron )
600           ->status_is(403, "Non-superlibrarian user change of superlibrarian secondary_email to undefined forbidden")
601           ->json_is( { error => "Not enough privileges to change a superlibrarian's email" } );
602
603         $newpatron->{secondary_email}  = $superlibrarian->emailpro;
604         $newpatron->{altaddress_email} = 'nonsense@no.no';
605
606         # attempt to update
607         $t->put_ok( "//$userid:$password@/api/v1/patrons/" . $superlibrarian->borrowernumber => json => $newpatron )
608           ->status_is(403, "Non-superlibrarian user change of superlibrarian altaddress_email forbidden")
609           ->json_is( { error => "Not enough privileges to change a superlibrarian's email" } );
610
611         # attempt to unset
612         $newpatron->{altaddress_email} = undef;
613         $t->put_ok( "//$userid:$password@/api/v1/patrons/" . $superlibrarian->borrowernumber => json => $newpatron )
614           ->status_is(403, "Non-superlibrarian user change of superlibrarian altaddress_email to undefined forbidden")
615           ->json_is( { error => "Not enough privileges to change a superlibrarian's email" } );
616
617         # update patron without sending email
618         delete $newpatron->{email};
619         delete $newpatron->{secondary_email};
620         delete $newpatron->{altaddress_email};
621
622         # Set a date field
623         $newpatron->{date_of_birth} = '1980-06-18';
624         # Set a date-time field
625         $newpatron->{last_seen} = output_pref({ dt => dt_from_string->add( days => -1 ), dateformat => 'rfc3339' });
626
627         $t->put_ok( "//$userid:$password@/api/v1/patrons/" . $superlibrarian->borrowernumber => json => $newpatron )
628           ->status_is(200, "Non-superlibrarian user can edit superlibrarian successfully if not changing email")
629           ->json_is( '/date_of_birth' => $newpatron->{ date_of_birth }, 'Date field set (Bug 28585)' )
630           ->json_is( '/last_seen'     => $newpatron->{ last_seen }, 'Date-time field set (Bug 28585)' );
631
632         $schema->storage->txn_rollback;
633     };
634 };
635
636 subtest 'delete() tests' => sub {
637     plan tests => 2;
638
639     $schema->storage->txn_begin;
640     unauthorized_access_tests('DELETE', 123, undef);
641     $schema->storage->txn_rollback;
642
643     subtest 'librarian access test' => sub {
644         plan tests => 18;
645
646         $schema->storage->txn_begin;
647
648         my $authorized_patron = $builder->build_object(
649             {
650                 class => 'Koha::Patrons',
651                 value => { flags => 2**4 }    # borrowers flag = 4
652             }
653         );
654         my $password = 'thePassword123';
655         $authorized_patron->set_password(
656             { password => $password, skip_validation => 1 } );
657         my $userid = $authorized_patron->userid;
658
659         $t->delete_ok("//$userid:$password@/api/v1/patrons/-1")
660           ->status_is(404, 'Patron not found');
661
662         my $patron = $builder->build_object({ class => 'Koha::Patrons' });
663
664         t::lib::Mocks::mock_preference('AnonymousPatron', $patron->borrowernumber);
665         $t->delete_ok("//$userid:$password@/api/v1/patrons/" . $patron->borrowernumber)
666           ->status_is(403, 'Anonymous patron cannot be deleted')
667           ->json_is( { error => 'Anonymous patron cannot be deleted' } );
668         t::lib::Mocks::mock_preference('AnonymousPatron', 0); # back to default
669
670         t::lib::Mocks::mock_preference( 'borrowerRelationship', 'parent' );
671
672         my $checkout = $builder->build_object(
673             {
674                 class => 'Koha::Checkouts',
675                 value => { borrowernumber => $patron->borrowernumber }
676             }
677         );
678         my $debit = $patron->account->add_debit({ amount => 10, interface => 'intranet', type => 'MANUAL' });
679         my $guarantee = $builder->build_object({ class => 'Koha::Patrons' });
680
681         $guarantee->add_guarantor({ guarantor_id => $patron->id, relationship => 'parent' });
682
683         $t->delete_ok("//$userid:$password@/api/v1/patrons/" . $patron->borrowernumber)
684           ->status_is(409, 'Patron with checkouts cannot be deleted')
685           ->json_is( { error => 'Pending checkouts prevent deletion' } );
686
687         # Make sure it has no pending checkouts
688         $checkout->delete;
689
690         $t->delete_ok("//$userid:$password@/api/v1/patrons/" . $patron->borrowernumber)
691           ->status_is(409, 'Patron with debt cannot be deleted')
692           ->json_is( { error => 'Pending debts prevent deletion' } );
693
694         # Make sure it has no debt
695         $patron->account->pay({ amount => 10, debits => [ $debit ] });
696
697         $t->delete_ok("//$userid:$password@/api/v1/patrons/" . $patron->borrowernumber)
698           ->status_is(409, 'Patron with guarantees cannot be deleted')
699           ->json_is( { error => 'Patron is a guarantor and it prevents deletion' } );
700
701         # Remove guarantee
702         $patron->guarantee_relationships->delete;
703
704         $t->delete_ok("//$userid:$password@/api/v1/patrons/" . $patron->borrowernumber)
705           ->status_is(204, 'SWAGGER3.2.4')
706           ->content_is('', 'SWAGGER3.3.4');
707
708         my $deleted_patrons = Koha::Old::Patrons->search({ borrowernumber =>  $patron->borrowernumber });
709         is( $deleted_patrons->count, 1, 'The patron has been moved to the vault' );
710
711         $schema->storage->txn_rollback;
712     };
713 };
714
715 subtest 'guarantors_can_see_charges() tests' => sub {
716
717     plan tests => 11;
718
719     t::lib::Mocks::mock_preference( 'RESTPublicAPI', 1 );
720     t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 );
721
722     $schema->storage->txn_begin;
723
724     my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { privacy_guarantor_fines => 0 } });
725     my $password = 'thePassword123';
726     $patron->set_password({ password => $password, skip_validation => 1 });
727     my $userid = $patron->userid;
728     my $patron_id = $patron->borrowernumber;
729
730     t::lib::Mocks::mock_preference( 'AllowPatronToSetFinesVisibilityForGuarantor', 0 );
731
732     $t->put_ok( "//$userid:$password@/api/v1/public/patrons/$patron_id/guarantors/can_see_charges" => json => { allowed => Mojo::JSON->true } )
733       ->status_is( 403 )
734       ->json_is( '/error', 'The current configuration doesn\'t allow the requested action.' );
735
736     t::lib::Mocks::mock_preference( 'AllowPatronToSetFinesVisibilityForGuarantor', 1 );
737
738     $t->put_ok( "//$userid:$password@/api/v1/public/patrons/$patron_id/guarantors/can_see_charges" => json => { allowed => Mojo::JSON->true } )
739       ->status_is( 200 )
740       ->json_is( {} );
741
742     ok( $patron->discard_changes->privacy_guarantor_fines, 'privacy_guarantor_fines has been set correctly' );
743
744     $t->put_ok( "//$userid:$password@/api/v1/public/patrons/$patron_id/guarantors/can_see_charges" => json => { allowed => Mojo::JSON->false } )
745       ->status_is( 200 )
746       ->json_is( {} );
747
748     ok( !$patron->discard_changes->privacy_guarantor_fines, 'privacy_guarantor_fines has been set correctly' );
749
750     $schema->storage->txn_rollback;
751 };
752
753 subtest 'guarantors_can_see_checkouts() tests' => sub {
754
755     plan tests => 11;
756
757     t::lib::Mocks::mock_preference( 'RESTPublicAPI', 1 );
758     t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 );
759
760     $schema->storage->txn_begin;
761
762     my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { privacy_guarantor_checkouts => 0 } });
763     my $password = 'thePassword123';
764     $patron->set_password({ password => $password, skip_validation => 1 });
765     my $userid = $patron->userid;
766     my $patron_id = $patron->borrowernumber;
767
768     t::lib::Mocks::mock_preference( 'AllowPatronToSetCheckoutsVisibilityForGuarantor', 0 );
769
770     $t->put_ok( "//$userid:$password@/api/v1/public/patrons/$patron_id/guarantors/can_see_checkouts" => json => { allowed => Mojo::JSON->true } )
771       ->status_is( 403 )
772       ->json_is( '/error', 'The current configuration doesn\'t allow the requested action.' );
773
774     t::lib::Mocks::mock_preference( 'AllowPatronToSetCheckoutsVisibilityForGuarantor', 1 );
775
776     $t->put_ok( "//$userid:$password@/api/v1/public/patrons/$patron_id/guarantors/can_see_checkouts" => json => { allowed => Mojo::JSON->true } )
777       ->status_is( 200 )
778       ->json_is( {} );
779
780     ok( $patron->discard_changes->privacy_guarantor_checkouts, 'privacy_guarantor_checkouts has been set correctly' );
781
782     $t->put_ok( "//$userid:$password@/api/v1/public/patrons/$patron_id/guarantors/can_see_checkouts" => json => { allowed => Mojo::JSON->false } )
783       ->status_is( 200 )
784       ->json_is( {} );
785
786     ok( !$patron->discard_changes->privacy_guarantor_checkouts, 'privacy_guarantor_checkouts has been set correctly' );
787
788     $schema->storage->txn_rollback;
789 };
790
791 # Centralized tests for 401s and 403s assuming the endpoint requires
792 # borrowers flag for access
793 sub unauthorized_access_tests {
794     my ($verb, $patron_id, $json) = @_;
795
796     my $endpoint = '/api/v1/patrons';
797     $endpoint .= ($patron_id) ? "/$patron_id" : '';
798
799     subtest 'unauthorized access tests' => sub {
800         plan tests => 5;
801
802         my $verb_ok = lc($verb) . '_ok';
803
804         $t->$verb_ok($endpoint => json => $json)
805           ->status_is(401);
806
807         my $unauthorized_patron = $builder->build_object(
808             {
809                 class => 'Koha::Patrons',
810                 value => { flags => 0 }
811             }
812         );
813         my $password = "thePassword123!";
814         $unauthorized_patron->set_password(
815             { password => $password, skip_validation => 1 } );
816         my $unauth_userid = $unauthorized_patron->userid;
817
818         $t->$verb_ok( "//$unauth_userid:$password\@$endpoint" => json => $json )
819           ->status_is(403)
820           ->json_has('/required_permissions');
821     };
822 }