Bug 24813: Prevent api/v1/holds.t to fail randomly
[koha.git] / t / db_dependent / api / v1 / holds.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 => 8;
21 use Test::Mojo;
22 use t::lib::TestBuilder;
23 use t::lib::Mocks;
24
25 use DateTime;
26
27 use C4::Context;
28 use Koha::Patrons;
29 use C4::Reserves;
30 use C4::Items;
31
32 use Koha::Database;
33 use Koha::DateUtils;
34 use Koha::Biblios;
35 use Koha::Biblioitems;
36 use Koha::Items;
37 use Koha::CirculationRules;
38
39 my $schema  = Koha::Database->new->schema;
40 my $builder = t::lib::TestBuilder->new();
41
42 $schema->storage->txn_begin;
43
44 t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 );
45
46 my $t = Test::Mojo->new('Koha::REST::V1');
47
48 my $categorycode = $builder->build({ source => 'Category' })->{categorycode};
49 my $branchcode = $builder->build({ source => 'Branch' })->{branchcode};
50 my $branchcode2 = $builder->build({ source => 'Branch' })->{branchcode};
51 my $itemtype = $builder->build({ source => 'Itemtype' })->{itemtype};
52
53 # Generic password for everyone
54 my $password = 'thePassword123';
55
56 # User without any permissions
57 my $nopermission = $builder->build_object({
58     class => 'Koha::Patrons',
59     value => {
60         branchcode   => $branchcode,
61         categorycode => $categorycode,
62         flags        => 0
63     }
64 });
65 $nopermission->set_password( { password => $password, skip_validation => 1 } );
66 my $nopermission_userid = $nopermission->userid;
67
68 my $patron_1 = $builder->build_object(
69     {
70         class => 'Koha::Patrons',
71         value => {
72             categorycode => $categorycode,
73             branchcode   => $branchcode,
74             surname      => 'Test Surname',
75             flags        => 80, #borrowers and reserveforothers flags
76         }
77     }
78 );
79 $patron_1->set_password( { password => $password, skip_validation => 1 } );
80 my $userid_1 = $patron_1->userid;
81
82 my $patron_2 = $builder->build_object(
83     {
84         class => 'Koha::Patrons',
85         value => {
86             categorycode => $categorycode,
87             branchcode   => $branchcode,
88             surname      => 'Test Surname 2',
89             flags        => 16, # borrowers flag
90         }
91     }
92 );
93 $patron_2->set_password( { password => $password, skip_validation => 1 } );
94 my $userid_2 = $patron_2->userid;
95
96 my $patron_3 = $builder->build_object(
97     {
98         class => 'Koha::Patrons',
99         value => {
100             categorycode => $categorycode,
101             branchcode   => $branchcode,
102             surname      => 'Test Surname 3',
103             flags        => 64, # reserveforothers flag
104         }
105     }
106 );
107 $patron_3->set_password( { password => $password, skip_validation => 1 } );
108 my $userid_3 = $patron_3->userid;
109
110 my $biblio_1 = $builder->build_sample_biblio;
111 my $item_1   = $builder->build_sample_item({ biblionumber => $biblio_1->biblionumber, itype => $itemtype });
112
113 my $biblio_2 = $builder->build_sample_biblio;
114 my $item_2   = $builder->build_sample_item({ biblionumber => $biblio_2->biblionumber, itype => $itemtype });
115
116 my $dbh = C4::Context->dbh;
117 $dbh->do('DELETE FROM reserves');
118 Koha::CirculationRules->search()->delete();
119 Koha::CirculationRules->set_rules(
120     {
121         categorycode => undef,
122         branchcode   => undef,
123         itemtype     => undef,
124         rules        => {
125             reservesallowed => 1,
126             holds_per_record => 99
127         }
128     }
129 );
130
131 my $reserve_id = C4::Reserves::AddReserve(
132     {
133         branchcode     => $branchcode,
134         borrowernumber => $patron_1->borrowernumber,
135         biblionumber   => $biblio_1->biblionumber,
136         priority       => 1,
137         itemnumber     => $item_1->itemnumber,
138     }
139 );
140
141 # Add another reserve to be able to change first reserve's rank
142 my $reserve_id2 = C4::Reserves::AddReserve(
143     {
144         branchcode     => $branchcode,
145         borrowernumber => $patron_2->borrowernumber,
146         biblionumber   => $biblio_1->biblionumber,
147         priority       => 2,
148         itemnumber     => $item_1->itemnumber,
149     }
150 );
151
152 my $suspended_until = DateTime->now->add(days => 10)->truncate( to => 'day' );
153 my $expiration_date = DateTime->now->add(days => 10)->truncate( to => 'day' );
154
155 my $post_data = {
156     patron_id => int($patron_1->borrowernumber),
157     biblio_id => int($biblio_1->biblionumber),
158     item_id => int($item_1->itemnumber),
159     pickup_library_id => $branchcode,
160     expiration_date => output_pref({ dt => $expiration_date, dateformat => 'rfc3339', dateonly => 1 }),
161     priority => 2,
162 };
163 my $put_data = {
164     priority => 2,
165     suspended_until => output_pref({ dt => $suspended_until, dateformat => 'rfc3339' }),
166 };
167
168 subtest "Test endpoints without authentication" => sub {
169     plan tests => 8;
170     $t->get_ok('/api/v1/holds')
171       ->status_is(401);
172     $t->post_ok('/api/v1/holds')
173       ->status_is(401);
174     $t->put_ok('/api/v1/holds/0')
175       ->status_is(401);
176     $t->delete_ok('/api/v1/holds/0')
177       ->status_is(401);
178 };
179
180 subtest "Test endpoints without permission" => sub {
181
182     plan tests => 10;
183
184     $t->get_ok( "//$nopermission_userid:$password@/api/v1/holds?patron_id=" . $patron_1->borrowernumber ) # no permission
185       ->status_is(403);
186
187     $t->get_ok( "//$userid_3:$password@/api/v1/holds?patron_id=" . $patron_1->borrowernumber )    # no permission
188       ->status_is(403);
189
190     $t->post_ok( "//$nopermission_userid:$password@/api/v1/holds" => json => $post_data )
191       ->status_is(403);
192
193     $t->put_ok( "//$nopermission_userid:$password@/api/v1/holds/0" => json => $put_data )
194       ->status_is(403);
195
196     $t->delete_ok( "//$nopermission_userid:$password@/api/v1/holds/0" )
197       ->status_is(403);
198 };
199
200 subtest "Test endpoints with permission" => sub {
201
202     plan tests => 57;
203
204     $t->get_ok( "//$userid_1:$password@/api/v1/holds" )
205       ->status_is(200)
206       ->json_has('/0')
207       ->json_has('/1')
208       ->json_hasnt('/2');
209
210     $t->get_ok( "//$userid_1:$password@/api/v1/holds?priority=2" )
211       ->status_is(200)
212       ->json_is('/0/patron_id', $patron_2->borrowernumber)
213       ->json_hasnt('/1');
214
215     # While suspended_until is date-time, it's always set to midnight.
216     my $expected_suspended_until = $suspended_until->strftime('%FT00:00:00%z');
217     substr($expected_suspended_until, -2, 0, ':');
218
219     $t->put_ok( "//$userid_1:$password@/api/v1/holds/$reserve_id" => json => $put_data )
220       ->status_is(200)
221       ->json_is( '/hold_id', $reserve_id )
222       ->json_is( '/suspended_until', $expected_suspended_until )
223       ->json_is( '/priority', 2 )
224       ->json_is( '/pickup_library_id', $branchcode );
225
226     # Change only pickup library, everything else should remain
227     $t->put_ok( "//$userid_1:$password@/api/v1/holds/$reserve_id" => json => { pickup_library_id => $branchcode2 } )
228       ->status_is(200)
229       ->json_is( '/hold_id', $reserve_id )
230       ->json_is( '/suspended_until', $expected_suspended_until )
231       ->json_is( '/priority', 2 )
232       ->json_is( '/pickup_library_id', $branchcode2 );
233
234     # Reset suspended_until, everything else should remain
235     $t->put_ok( "//$userid_1:$password@/api/v1/holds/$reserve_id" => json => { suspended_until => undef } )
236       ->status_is(200)
237       ->json_is( '/hold_id', $reserve_id )
238       ->json_is( '/suspended_until', undef )
239       ->json_is( '/priority', 2 )
240       ->json_is( '/pickup_library_id', $branchcode2 );
241
242     $t->delete_ok( "//$userid_3:$password@/api/v1/holds/$reserve_id" )
243       ->status_is(200);
244
245     $t->put_ok( "//$userid_3:$password@/api/v1/holds/$reserve_id" => json => $put_data )
246       ->status_is(404)
247       ->json_has('/error');
248
249     $t->delete_ok( "//$userid_3:$password@/api/v1/holds/$reserve_id" )
250       ->status_is(404)
251       ->json_has('/error');
252
253     $t->get_ok( "//$userid_2:$password@/api/v1/holds?patron_id=" . $patron_1->borrowernumber )
254       ->status_is(200)
255       ->json_is([]);
256
257     my $inexisting_borrowernumber = $patron_2->borrowernumber * 2;
258     $t->get_ok( "//$userid_1:$password@/api/v1/holds?patron_id=$inexisting_borrowernumber")
259       ->status_is(200)
260       ->json_is([]);
261
262     $t->delete_ok( "//$userid_3:$password@/api/v1/holds/$reserve_id2" )
263       ->status_is(200);
264
265     $t->post_ok( "//$userid_3:$password@/api/v1/holds" => json => $post_data )
266       ->status_is(201)
267       ->json_has('/hold_id');
268     # Get id from response
269     $reserve_id = $t->tx->res->json->{hold_id};
270
271     $t->get_ok( "//$userid_1:$password@/api/v1/holds?patron_id=" . $patron_1->borrowernumber )
272       ->status_is(200)
273       ->json_is('/0/hold_id', $reserve_id)
274       ->json_is('/0/expiration_date', output_pref({ dt => $expiration_date, dateformat => 'rfc3339', dateonly => 1 }))
275       ->json_is('/0/pickup_library_id', $branchcode);
276
277     $t->post_ok( "//$userid_3:$password@/api/v1/holds" => json => $post_data )
278       ->status_is(403)
279       ->json_like('/error', qr/itemAlreadyOnHold/);
280
281     $post_data->{biblionumber} = int($biblio_2->biblionumber);
282     $post_data->{itemnumber}   = int($item_2->itemnumber);
283
284     $t->post_ok( "//$userid_3:$password@/api/v1/holds" => json => $post_data )
285       ->status_is(403)
286       ->json_like('/error', qr/itemAlreadyOnHold/);
287 };
288
289 subtest 'Reserves with itemtype' => sub {
290     plan tests => 9;
291
292     my $post_data = {
293         patron_id => int($patron_1->borrowernumber),
294         biblio_id => int($biblio_1->biblionumber),
295         pickup_library_id => $branchcode,
296         item_type => $itemtype,
297     };
298
299     $t->delete_ok( "//$userid_3:$password@/api/v1/holds/$reserve_id" )
300       ->status_is(200);
301
302     $t->post_ok( "//$userid_3:$password@/api/v1/holds" => json => $post_data )
303       ->status_is(201)
304       ->json_has('/hold_id');
305
306     $reserve_id = $t->tx->res->json->{hold_id};
307
308     $t->get_ok( "//$userid_1:$password@/api/v1/holds?patron_id=" . $patron_1->borrowernumber )
309       ->status_is(200)
310       ->json_is('/0/hold_id', $reserve_id)
311       ->json_is('/0/item_type', $itemtype);
312 };
313
314
315 subtest 'test AllowHoldDateInFuture' => sub {
316
317     plan tests => 6;
318
319     $dbh->do('DELETE FROM reserves');
320
321     my $future_hold_date = DateTime->now->add(days => 10)->truncate( to => 'day' );
322
323     my $post_data = {
324         patron_id => int($patron_1->borrowernumber),
325         biblio_id => int($biblio_1->biblionumber),
326         item_id => int($item_1->itemnumber),
327         pickup_library_id => $branchcode,
328         expiration_date => output_pref({ dt => $expiration_date, dateformat => 'rfc3339', dateonly => 1 }),
329         hold_date => output_pref({ dt => $future_hold_date, dateformat => 'rfc3339', dateonly => 1 }),
330         priority => 2,
331     };
332
333     t::lib::Mocks::mock_preference( 'AllowHoldDateInFuture', 0 );
334
335     $t->post_ok( "//$userid_3:$password@/api/v1/holds" => json => $post_data )
336       ->status_is(400)
337       ->json_has('/error');
338
339     t::lib::Mocks::mock_preference( 'AllowHoldDateInFuture', 1 );
340
341     $t->post_ok( "//$userid_3:$password@/api/v1/holds" => json => $post_data )
342       ->status_is(201)
343       ->json_is('/hold_date', output_pref({ dt => $future_hold_date, dateformat => 'rfc3339', dateonly => 1 }));
344 };
345
346 subtest 'test AllowHoldPolicyOverride' => sub {
347
348     plan tests => 5;
349
350     $dbh->do('DELETE FROM reserves');
351
352     Koha::CirculationRules->set_rules(
353         {
354             itemtype     => undef,
355             branchcode   => undef,
356             rules        => {
357                 holdallowed              => 1
358             }
359         }
360     );
361
362     t::lib::Mocks::mock_preference( 'AllowHoldPolicyOverride', 0 );
363
364     $t->post_ok( "//$userid_3:$password@/api/v1/holds" => json => $post_data )
365       ->status_is(403)
366       ->json_has('/error');
367
368     t::lib::Mocks::mock_preference( 'AllowHoldPolicyOverride', 1 );
369
370     $t->post_ok( "//$userid_3:$password@/api/v1/holds" => json => $post_data )
371       ->status_is(201);
372 };
373
374 $schema->storage->txn_rollback;
375
376 subtest 'suspend and resume tests' => sub {
377
378     plan tests => 24;
379
380     $schema->storage->txn_begin;
381
382     my $password = 'AbcdEFG123';
383
384     my $patron = $builder->build_object(
385         { class => 'Koha::Patrons', value => { userid => 'tomasito', flags => 1 } } );
386     $patron->set_password({ password => $password, skip_validation => 1 });
387     my $userid = $patron->userid;
388
389     # Disable logging
390     t::lib::Mocks::mock_preference( 'HoldsLog',      0 );
391     t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 );
392
393     my $hold = $builder->build_object(
394         {   class => 'Koha::Holds',
395             value => { suspend => 0, suspend_until => undef, waitingdate => undef, found => undef }
396         }
397     );
398
399     ok( !$hold->is_suspended, 'Hold is not suspended' );
400     $t->post_ok( "//$userid:$password@/api/v1/holds/" . $hold->id . "/suspension" )
401         ->status_is( 201, 'Hold suspension created' );
402
403     $hold->discard_changes;    # refresh object
404
405     ok( $hold->is_suspended, 'Hold is suspended' );
406     $t->json_is('/end_date', undef, 'Hold suspension has no end date');
407
408     my $end_date = output_pref({
409       dt         => dt_from_string( undef ),
410       dateformat => 'rfc3339',
411       dateonly   => 1
412     });
413
414     $t->post_ok( "//$userid:$password@/api/v1/holds/" . $hold->id . "/suspension" => json => { end_date => $end_date } );
415
416     $hold->discard_changes;    # refresh object
417
418     ok( $hold->is_suspended, 'Hold is suspended' );
419     $t->json_is(
420       '/end_date',
421       output_pref({
422         dt         => dt_from_string( $hold->suspend_until ),
423         dateformat => 'rfc3339',
424         dateonly   => 1
425       }),
426       'Hold suspension has correct end date'
427     );
428
429     $t->delete_ok( "//$userid:$password@/api/v1/holds/" . $hold->id . "/suspension" )
430       ->status_is( 204, "Correct status when deleting a resource" )
431       ->json_is( undef );
432
433     # Pass a an expiration date for the suspension
434     my $date = dt_from_string()->add( days => 5 );
435     $t->post_ok(
436               "//$userid:$password@/api/v1/holds/"
437             . $hold->id
438             . "/suspension" => json => {
439             end_date =>
440                 output_pref( { dt => $date, dateformat => 'rfc3339', dateonly => 1 } )
441             }
442     )->status_is( 201, 'Hold suspension created' )
443         ->json_is( '/end_date',
444         output_pref( { dt => $date, dateformat => 'rfc3339', dateonly => 1 } ) )
445         ->header_is( Location => "/api/v1/holds/" . $hold->id . "/suspension", 'The Location header is set' );
446
447     $t->delete_ok( "//$userid:$password@/api/v1/holds/" . $hold->id . "/suspension" )
448       ->status_is( 204, "Correct status when deleting a resource" )
449       ->json_is( undef );
450
451     $hold->set_waiting->discard_changes;
452
453     $t->post_ok( "//$userid:$password@/api/v1/holds/" . $hold->id . "/suspension" )
454       ->status_is( 400, 'Cannot suspend waiting hold' )
455       ->json_is( '/error', 'Found hold cannot be suspended. Status=W' );
456
457     $hold->set_waiting(1)->discard_changes;
458
459     $t->post_ok( "//$userid:$password@/api/v1/holds/" . $hold->id . "/suspension" )
460       ->status_is( 400, 'Cannot suspend waiting hold' )
461       ->json_is( '/error', 'Found hold cannot be suspended. Status=T' );
462
463     $schema->storage->txn_rollback;
464 };
465
466 subtest 'PUT /holds/{hold_id}/priority tests' => sub {
467
468     plan tests => 14;
469
470     $schema->storage->txn_begin;
471
472     my $password = 'AbcdEFG123';
473
474     my $library  = $builder->build_object({ class => 'Koha::Libraries' });
475     my $patron_np = $builder->build_object(
476         { class => 'Koha::Patrons', value => { flags => 0 } } );
477     $patron_np->set_password( { password => $password, skip_validation => 1 } );
478     my $userid_np = $patron_np->userid;
479
480     my $patron = $builder->build_object(
481         { class => 'Koha::Patrons', value => { flags => 0 } } );
482     $patron->set_password( { password => $password, skip_validation => 1 } );
483     my $userid = $patron->userid;
484     $builder->build(
485         {
486             source => 'UserPermission',
487             value  => {
488                 borrowernumber => $patron->borrowernumber,
489                 module_bit     => 6,
490                 code           => 'modify_holds_priority',
491             },
492         }
493     );
494
495     # Disable logging
496     t::lib::Mocks::mock_preference( 'HoldsLog',      0 );
497     t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 );
498
499     my $biblio   = $builder->build_sample_biblio;
500     my $patron_1 = $builder->build_object(
501         {
502             class => 'Koha::Patrons',
503             value => { branchcode => $library->branchcode }
504         }
505     );
506     my $patron_2 = $builder->build_object(
507         {
508             class => 'Koha::Patrons',
509             value => { branchcode => $library->branchcode }
510         }
511     );
512     my $patron_3 = $builder->build_object(
513         {
514             class => 'Koha::Patrons',
515             value => { branchcode => $library->branchcode }
516         }
517     );
518
519     my $hold_1 = Koha::Holds->find(
520         AddReserve(
521             {
522                 branchcode     => $library->branchcode,
523                 borrowernumber => $patron_1->borrowernumber,
524                 biblionumber   => $biblio->biblionumber,
525                 priority       => 1,
526             }
527         )
528     );
529     my $hold_2 = Koha::Holds->find(
530         AddReserve(
531             {
532                 branchcode     => $library->branchcode,
533                 borrowernumber => $patron_2->borrowernumber,
534                 biblionumber   => $biblio->biblionumber,
535                 priority       => 2,
536             }
537         )
538     );
539     my $hold_3 = Koha::Holds->find(
540         AddReserve(
541             {
542                 branchcode     => $library->branchcode,
543                 borrowernumber => $patron_3->borrowernumber,
544                 biblionumber   => $biblio->biblionumber,
545                 priority       => 3,
546             }
547         )
548     );
549
550     $t->put_ok( "//$userid_np:$password@/api/v1/holds/"
551           . $hold_3->id
552           . "/priority" => json => 1 )->status_is(403);
553
554     $t->put_ok( "//$userid:$password@/api/v1/holds/"
555           . $hold_3->id
556           . "/priority" => json => 1 )->status_is(200)->json_is(1);
557
558     is( $hold_1->discard_changes->priority, 2, 'Priority adjusted correctly' );
559     is( $hold_2->discard_changes->priority, 3, 'Priority adjusted correctly' );
560     is( $hold_3->discard_changes->priority, 1, 'Priority adjusted correctly' );
561
562     $t->put_ok( "//$userid:$password@/api/v1/holds/"
563           . $hold_3->id
564           . "/priority" => json => 3 )->status_is(200)->json_is(3);
565
566     is( $hold_1->discard_changes->priority, 1, 'Priority adjusted correctly' );
567     is( $hold_2->discard_changes->priority, 2, 'Priority adjusted correctly' );
568     is( $hold_3->discard_changes->priority, 3, 'Priority adjusted correctly' );
569
570     $schema->storage->txn_rollback;
571 };