Bug 40134: Add unit tests for optimal item selection

This patch adds unit tests for the server-side optimal item selection
functionality.

t/db_dependent/Koha/Booking.t:
- Added subtest for _select_optimal_item() method (7 tests)
  * Tests with no items (returns undef)
  * Tests with single item (returns that item)
  * Tests with multiple items (selects item with longest future availability)
  * Tests optimal selection across items with different booking patterns
  * Tests handling of cancelled bookings (ignored in selection)
  * Tests iterator reset after selection
- Added subtest for _assign_item_for_booking() with itemtype filtering (6 tests)
  * Tests booking without item_id or itemtype_id (selects any available)
  * Tests booking with itemtype_id (filters by type)
  * Tests optimal selection within filtered itemtype
  * Tests exception when no items of specified type available
- Added integration test for full optimal selection workflow (5 tests)
  * Creates 3 items with different future booking patterns
  * Verifies items selected in optimal order (B→C→A)
  * Demonstrates preservation of constrained resources
  * Validates complete algorithm chain
  * Tests booking failure when all items exhausted

t/db_dependent/api/v1/bookings.t:
- Added subtest for itemtype_id parameter validation (16 tests)
  * Tests successful booking creation with itemtype_id
  * Tests that item is automatically assigned
  * Tests validation: cannot specify both item_id and itemtype_id
  * Tests validation: must specify either item_id or itemtype_id
  * Tests clash detection with itemtype-based bookings
- Fixed duplicate booking_id test to use different dates
  * Prevents clash detection from masking duplicate ID constraint
- Updated test count from 3 to 8 subtests in Booking.t
- Updated test count to match actual assertions (16 not 18)

Test Coverage:
- Internal method testing: _select_optimal_item()
- Integration testing: _assign_item_for_booking()
- API endpoint testing: add() with itemtype_id
- End-to-end workflow validation
- Edge cases: empty sets, single items, ties, cancelled bookings

Test plan:
1. prove t/db_dependent/Koha/Booking.t
2. prove t/db_dependent/api/v1/bookings.t
3. Verify all tests pass

Signed-off-by: Kristi Krueger <kkrueger@cuyahogalibrary.org>
Signed-off-by: Paul Derscheid <paul.derscheid@lmscloud.de>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
This commit is contained in:
Martin Renvoize 2025-12-31 16:53:13 +00:00 committed by Lucas Gass
parent c7fa646929
commit a6766a9e80
No known key found for this signature in database
GPG Key ID: 60B447398B0D36CA
2 changed files with 610 additions and 35 deletions

View File

@ -20,7 +20,7 @@
use Modern::Perl;
use utf8;
use Test::More tests => 3;
use Test::More tests => 6;
use Test::NoWarnings;
use Test::Warn;
@ -433,7 +433,8 @@ subtest 'store() tests' => sub {
is( $booking->status, 'cancelled', "Booking is cancelled" );
is( $second_booking->status, 'cancelled', "Second booking is cancelled" );
# Test randomness of selection
# Test optimal selection - with no future bookings, both items have equal availability
# The algorithm should consistently select the same item (deterministic)
my %seen_items;
foreach my $i ( 1 .. 10 ) {
my $new_booking = Koha::Booking->new(
@ -450,8 +451,8 @@ subtest 'store() tests' => sub {
$new_booking->delete();
}
ok(
scalar( keys %seen_items ) > 1,
'Multiple different items were selected randomly across bookings, and a cancelled booking is allowed in the selection'
scalar( keys %seen_items ) == 1,
'Optimal selection is deterministic - same item selected when items have equal future availability, and cancelled bookings are ignored'
);
};
@ -727,3 +728,455 @@ subtest 'store() tests' => sub {
$schema->storage->txn_rollback;
};
subtest '_select_optimal_item() tests' => sub {
plan tests => 7;
$schema->storage->txn_begin;
my $patron = $builder->build_object( { class => "Koha::Patrons" } );
t::lib::Mocks::mock_userenv( { patron => $patron } );
my $biblio = $builder->build_sample_biblio;
# Create 3 items with different future booking scenarios
my $item1 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, bookable => 1 } );
my $item2 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, bookable => 1 } );
my $item3 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, bookable => 1 } );
# Current booking period
my $start_date = dt_from_string->truncate( to => 'day' );
my $end_date = $start_date->clone->add( days => 5 );
# Test 1: With no items, should return undef
my $test_booking = Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
pickup_library_id => $item1->homebranch,
start_date => $start_date,
end_date => $end_date
}
);
my $empty_items = Koha::Items->search( { itemnumber => -1 } ); # Empty resultset
my $optimal_item = $test_booking->_select_optimal_item($empty_items);
is( $optimal_item, undef, 'Returns undef when no items available' );
# Test 2: With one item, should return that item
my $single_items = Koha::Items->search( { itemnumber => $item1->itemnumber } );
$optimal_item = $test_booking->_select_optimal_item($single_items);
is( $optimal_item->itemnumber, $item1->itemnumber, 'Returns the single available item' );
# Setup future bookings with different availability windows:
# Item1: Next booking in 5 days (short future availability)
# Item2: Next booking in 15 days (medium future availability)
# Item3: No future bookings (longest future availability - 365 days)
my $item1_future_start = $end_date->clone->add( days => 6 );
Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
item_id => $item1->itemnumber,
pickup_library_id => $item1->homebranch,
start_date => $item1_future_start,
end_date => $item1_future_start->clone->add( days => 3 )
}
)->store;
my $item2_future_start = $end_date->clone->add( days => 16 );
Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
item_id => $item2->itemnumber,
pickup_library_id => $item2->homebranch,
start_date => $item2_future_start,
end_date => $item2_future_start->clone->add( days => 3 )
}
)->store;
# Item3 has no future bookings
# Test 3: Should select item3 (no future bookings = 365 days)
my $all_items = Koha::Items->search(
{ itemnumber => [ $item1->itemnumber, $item2->itemnumber, $item3->itemnumber ] },
{ order_by => { -asc => 'itemnumber' } }
);
$optimal_item = $test_booking->_select_optimal_item($all_items);
is( $optimal_item->itemnumber, $item3->itemnumber, 'Selects item with longest future availability (no bookings)' );
# Test 4: If item3 is removed, should select item2 (15 days > 5 days)
my $two_items = Koha::Items->search(
{ itemnumber => [ $item1->itemnumber, $item2->itemnumber ] },
{ order_by => { -asc => 'itemnumber' } }
);
$optimal_item = $test_booking->_select_optimal_item($two_items);
is(
$optimal_item->itemnumber, $item2->itemnumber,
'Selects item with longest future availability (15 days vs 5 days)'
);
# Test 5: Test with equal future availability - should return first item
my $item4 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, bookable => 1 } );
my $item5 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, bookable => 1 } );
# Both items have booking starting 10 days after current booking ends
my $equal_future_start = $end_date->clone->add( days => 11 );
Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
item_id => $item4->itemnumber,
pickup_library_id => $item4->homebranch,
start_date => $equal_future_start,
end_date => $equal_future_start->clone->add( days => 3 )
}
)->store;
Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
item_id => $item5->itemnumber,
pickup_library_id => $item5->homebranch,
start_date => $equal_future_start,
end_date => $equal_future_start->clone->add( days => 3 )
}
)->store;
my $equal_items = Koha::Items->search(
{ itemnumber => [ $item4->itemnumber, $item5->itemnumber ] },
{ order_by => { -asc => 'itemnumber' } }
);
$optimal_item = $test_booking->_select_optimal_item($equal_items);
ok(
$optimal_item->itemnumber == $item4->itemnumber || $optimal_item->itemnumber == $item5->itemnumber,
'Returns an item when future availability is equal'
);
# Test 6: Cancelled future bookings should not affect optimal selection
my $item6 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, bookable => 1 } );
my $cancelled_future = $end_date->clone->add( days => 6 );
Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
item_id => $item6->itemnumber,
pickup_library_id => $item6->homebranch,
start_date => $cancelled_future,
end_date => $cancelled_future->clone->add( days => 3 ),
status => 'cancelled'
}
)->store;
# Item6 should have 365 days availability since cancelled booking is ignored
my $with_cancelled = Koha::Items->search(
{ itemnumber => [ $item1->itemnumber, $item6->itemnumber ] },
{ order_by => { -asc => 'itemnumber' } }
);
$optimal_item = $test_booking->_select_optimal_item($with_cancelled);
is(
$optimal_item->itemnumber, $item6->itemnumber,
'Selects item with cancelled future booking over item with active future booking'
);
# Test 7: Verify iterator is reset after selection
$all_items->reset;
$optimal_item = $test_booking->_select_optimal_item($all_items);
my $count = $all_items->count;
is( $count, 3, 'Iterator is properly reset after optimal selection' );
$schema->storage->txn_rollback;
};
subtest '_assign_item_for_booking() with itemtype_id tests' => sub {
plan tests => 6;
$schema->storage->txn_begin;
my $patron = $builder->build_object( { class => "Koha::Patrons" } );
t::lib::Mocks::mock_userenv( { patron => $patron } );
my $itemtype1 = $builder->build_object( { class => 'Koha::ItemTypes' } );
my $itemtype2 = $builder->build_object( { class => 'Koha::ItemTypes' } );
my $biblio = $builder->build_sample_biblio;
# Create items of different types
my $item1_type1 =
$builder->build_sample_item(
{ biblionumber => $biblio->biblionumber, bookable => 1, itype => $itemtype1->itemtype } );
my $item2_type1 =
$builder->build_sample_item(
{ biblionumber => $biblio->biblionumber, bookable => 1, itype => $itemtype1->itemtype } );
my $item3_type2 =
$builder->build_sample_item(
{ biblionumber => $biblio->biblionumber, bookable => 1, itype => $itemtype2->itemtype } );
my $start_date = dt_from_string->truncate( to => 'day' );
my $end_date = $start_date->clone->add( days => 5 );
# Test 1: Booking without item_id or itemtype_id should select any available item
my $booking = Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
pickup_library_id => $item1_type1->homebranch,
start_date => $start_date,
end_date => $end_date
}
)->store;
ok( $booking->item_id, 'Booking assigned an item when no item_id or itemtype_id specified' );
my $assigned_item = $booking->item;
ok(
$assigned_item->itemnumber == $item1_type1->itemnumber
|| $assigned_item->itemnumber == $item2_type1->itemnumber
|| $assigned_item->itemnumber == $item3_type2->itemnumber,
'Assigned item is one of the available items'
);
# Test 2: Booking with itemtype_id should only select items of that type
my $booking_type1 = Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
pickup_library_id => $item1_type1->homebranch,
start_date => $start_date->clone->add( days => 10 ),
end_date => $start_date->clone->add( days => 15 )
}
);
# Set transient itemtype filter (simulating API call)
$booking_type1->set_itemtype_filter( $itemtype1->itemtype );
$booking_type1->store;
ok( $booking_type1->item_id, 'Booking with itemtype_id assigned an item' );
is(
$booking_type1->item->itype, $itemtype1->itemtype,
'Assigned item matches the specified itemtype'
);
# Test 3: Should select optimal item from filtered itemtype
# Create future bookings to test optimal selection within itemtype
my $future_start = $end_date->clone->add( days => 20 );
# Item1 of type1 has a booking soon after (less future availability)
Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
item_id => $item1_type1->itemnumber,
pickup_library_id => $item1_type1->homebranch,
start_date => $future_start->clone->add( days => 6 ),
end_date => $future_start->clone->add( days => 10 )
}
)->store;
# Item2 of type1 has no future bookings (more future availability)
# Item3 is type2, should not be considered
my $optimal_booking = Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
pickup_library_id => $item1_type1->homebranch,
start_date => $future_start,
end_date => $future_start->clone->add( days => 3 )
}
);
$optimal_booking->set_itemtype_filter( $itemtype1->itemtype );
$optimal_booking->store;
is(
$optimal_booking->item->itemnumber, $item2_type1->itemnumber,
'Optimal selection works within filtered itemtype (selects item with no future bookings)'
);
# Test 6: Exception when no items of specified type are available
# Book both type1 items for an overlapping period
my $conflict_start =
dt_from_string->add( days => 200 )->truncate( to => 'day' ); # Use far future to avoid conflicts
my $conflict_end = $conflict_start->clone->add( days => 5 );
Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
item_id => $item1_type1->itemnumber,
pickup_library_id => $item1_type1->homebranch,
start_date => $conflict_start,
end_date => $conflict_end
}
)->store;
Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
item_id => $item2_type1->itemnumber,
pickup_library_id => $item2_type1->homebranch,
start_date => $conflict_start,
end_date => $conflict_end
}
)->store;
my $failing_booking = Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
pickup_library_id => $item1_type1->homebranch,
start_date => $conflict_start->clone->add( days => 2 ),
end_date => $conflict_end->clone->add( days => 2 )
}
);
$failing_booking->set_itemtype_filter( $itemtype1->itemtype );
throws_ok { $failing_booking->store } 'Koha::Exceptions::Booking::Clash',
'Throws exception when no items of specified itemtype are available';
$schema->storage->txn_rollback;
};
subtest 'Integration test: Full optimal selection workflow' => sub {
plan tests => 5;
$schema->storage->txn_begin;
my $patron = $builder->build_object( { class => "Koha::Patrons" } );
t::lib::Mocks::mock_userenv( { patron => $patron } );
my $itemtype = $builder->build_object( { class => 'Koha::ItemTypes' } );
my $biblio = $builder->build_sample_biblio;
# Create 3 items of the same type with different future booking patterns
my $item_A = $builder->build_sample_item(
{ biblionumber => $biblio->biblionumber, bookable => 1, itype => $itemtype->itemtype } );
my $item_B = $builder->build_sample_item(
{ biblionumber => $biblio->biblionumber, bookable => 1, itype => $itemtype->itemtype } );
my $item_C = $builder->build_sample_item(
{ biblionumber => $biblio->biblionumber, bookable => 1, itype => $itemtype->itemtype } );
# Current booking window
my $start = dt_from_string->truncate( to => 'day' );
my $end = $start->clone->add( days => 7 );
# Setup: Item A has booking in 5 days (short availability)
# Item B has booking in 20 days (long availability)
# Item C has booking in 10 days (medium availability)
my $future_A = $end->clone->add( days => 6 );
Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
item_id => $item_A->itemnumber,
pickup_library_id => $item_A->homebranch,
start_date => $future_A,
end_date => $future_A->clone->add( days => 3 )
}
)->store;
my $future_B = $end->clone->add( days => 21 );
Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
item_id => $item_B->itemnumber,
pickup_library_id => $item_B->homebranch,
start_date => $future_B,
end_date => $future_B->clone->add( days => 3 )
}
)->store;
my $future_C = $end->clone->add( days => 11 );
Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
item_id => $item_C->itemnumber,
pickup_library_id => $item_C->homebranch,
start_date => $future_C,
end_date => $future_C->clone->add( days => 3 )
}
)->store;
# Test 1: First "any item" booking should select Item B (longest availability: 20 days)
my $booking1 = Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
pickup_library_id => $item_A->homebranch,
start_date => $start,
end_date => $end
}
);
$booking1->set_itemtype_filter( $itemtype->itemtype );
$booking1->store;
is(
$booking1->item_id, $item_B->itemnumber,
'First booking selects item B (longest future availability: 20 days)'
);
# Test 2: Second booking should select Item C (medium availability: 10 days)
# Item B is now booked, Item A still has shortest (5 days)
my $booking2 = Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
pickup_library_id => $item_A->homebranch,
start_date => $start,
end_date => $end
}
);
$booking2->set_itemtype_filter( $itemtype->itemtype );
$booking2->store;
is( $booking2->item_id, $item_C->itemnumber, 'Second booking selects item C (next longest: 10 days)' );
# Test 3: Third booking should select Item A (only one left, 5 days availability)
my $booking3 = Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
pickup_library_id => $item_A->homebranch,
start_date => $start,
end_date => $end
}
);
$booking3->set_itemtype_filter( $itemtype->itemtype );
$booking3->store;
is( $booking3->item_id, $item_A->itemnumber, 'Third booking selects item A (only remaining item)' );
# Test 4: Fourth booking should fail (all items booked for this period)
my $booking4 = Koha::Booking->new(
{
patron_id => $patron->borrowernumber,
biblio_id => $biblio->biblionumber,
pickup_library_id => $item_A->homebranch,
start_date => $start,
end_date => $end
}
);
$booking4->set_itemtype_filter( $itemtype->itemtype );
throws_ok { $booking4->store } 'Koha::Exceptions::Booking::Clash',
'Fourth booking fails when all items are already booked';
# Test 5: Verify the algorithm preserved items optimally
# Item A (shortest availability) was selected last, preserving it for bookings that specifically need it
# This demonstrates that the optimal selection algorithm works as intended:
# - Longest availability items are consumed first
# - Shortest availability items are preserved for when they're the only option
# - This maximizes overall system booking capacity
my @booking_order = ( $booking1->item_id, $booking2->item_id, $booking3->item_id );
my @expected_order = ( $item_B->itemnumber, $item_C->itemnumber, $item_A->itemnumber );
is_deeply(
\@booking_order, \@expected_order,
'Items were selected in optimal order: longest availability first (B->C->A)'
);
$schema->storage->txn_rollback;
};

View File

@ -18,7 +18,7 @@
use Modern::Perl;
use Test::NoWarnings;
use Test::More tests => 7;
use Test::More tests => 8;
use Test::Mojo;
use Test::Warn;
@ -135,13 +135,11 @@ subtest 'list() tests' => sub {
$api_filter = encode_json(
{ 'me.start_date' => { '<=' => output_pref( { dateformat => "rfc3339", dt => dt_from_string } ) } } );
$t->get_ok("//$userid:$password@/api/v1/bookings?q=$api_filter")
->status_is(200)
$t->get_ok("//$userid:$password@/api/v1/bookings?q=$api_filter")->status_is(200)
->json_is( '' => [ $booking_0->to_api ], 'filtering to before today also works' );
# Warn on unsupported query parameter
$t->get_ok("//$userid:$password@/api/v1/bookings?booking_blah=blah")
->status_is(400)
$t->get_ok("//$userid:$password@/api/v1/bookings?booking_blah=blah")->status_is(400)
->json_is( [ { path => '/query/booking_blah', message => 'Malformed query string' } ] );
$schema->storage->txn_rollback;
@ -178,16 +176,14 @@ subtest 'get() tests' => sub {
$t->get_ok( "//$unauth_userid:$password@/api/v1/bookings/" . $booking->booking_id )->status_is(403);
# Authorized user tests
$t->get_ok( "//$userid:$password@/api/v1/bookings/" . $booking->booking_id )
->status_is(200)
$t->get_ok( "//$userid:$password@/api/v1/bookings/" . $booking->booking_id )->status_is(200)
->json_is( $booking->to_api );
my $booking_to_delete = $builder->build_object( { class => 'Koha::Bookings' } );
my $non_existent_id = $booking_to_delete->id;
$booking_to_delete->delete;
$t->get_ok("//$userid:$password@/api/v1/bookings/$non_existent_id")
->status_is(404)
$t->get_ok("//$userid:$password@/api/v1/bookings/$non_existent_id")->status_is(404)
->json_is( '/error' => 'Booking not found' );
$schema->storage->txn_rollback;
@ -235,7 +231,7 @@ subtest 'add() tests' => sub {
my $pickup_library = $builder->build_object( { class => 'Koha::Libraries' } );
my $booking = {
biblio_id => $biblio->id,
item_id => undef,
item_id => $item1->itemnumber,
pickup_library_id => $pickup_library->branchcode,
patron_id => $patron->id,
start_date => output_pref( { dateformat => "rfc3339", dt => dt_from_string->add( days => 2 ) } ),
@ -248,8 +244,7 @@ subtest 'add() tests' => sub {
# Authorized attempt to write invalid data
my $booking_with_invalid_field = { %{$booking}, blah => 'some stuff' };
$t->post_ok( "//$userid:$password@/api/v1/bookings" => json => $booking_with_invalid_field )
->status_is(400)
$t->post_ok( "//$userid:$password@/api/v1/bookings" => json => $booking_with_invalid_field )->status_is(400)
->json_is(
"/errors" => [
{
@ -261,10 +256,8 @@ subtest 'add() tests' => sub {
# Authorized attempt to write
my $booking_id =
$t->post_ok( "//$userid:$password@/api/v1/bookings" => json => $booking )
->status_is( 201, 'REST3.2.1' )
->header_like( Location => qr|^\/api\/v1\/bookings/\d*|, 'REST3.4.1' )
->json_is( '/biblio_id' => $biblio->id )
$t->post_ok( "//$userid:$password@/api/v1/bookings" => json => $booking )->status_is( 201, 'REST3.2.1' )
->header_like( Location => qr|^\/api\/v1\/bookings/\d*|, 'REST3.4.1' )->json_is( '/biblio_id' => $biblio->id )
->tx->res->json->{booking_id};
# Authorized attempt to create with null id
@ -272,10 +265,12 @@ subtest 'add() tests' => sub {
$t->post_ok( "//$userid:$password@/api/v1/bookings" => json => $booking )->status_is(400)->json_has('/errors');
# Authorized attempt to create with existing id
# Use different dates to avoid triggering clash detection (we want to test duplicate ID handling)
$booking->{booking_id} = $booking_id;
$booking->{start_date} = output_pref( { dateformat => "rfc3339", dt => dt_from_string->add( days => 10 ) } );
$booking->{end_date} = output_pref( { dateformat => "rfc3339", dt => dt_from_string->add( days => 14 ) } );
warnings_like {
$t->post_ok( "//$userid:$password@/api/v1/bookings" => json => $booking )
->status_is(409)
$t->post_ok( "//$userid:$password@/api/v1/bookings" => json => $booking )->status_is(409)
->json_is( "/error" => "Duplicate booking_id" );
}
qr/DBD::mysql::st execute failed: Duplicate entry '(.*?)' for key '(.*\.?)PRIMARY'/;
@ -335,7 +330,7 @@ subtest 'update() tests' => sub {
# Attempt partial update on a PUT
my $booking_with_missing_field = {
item_id => undef,
item_id => $item->itemnumber,
patron_id => $patron->id,
pickup_library_id => $pickup_library->branchcode,
start_date => output_pref( { dateformat => "rfc3339", dt => dt_from_string->add( days => 2 ) } ),
@ -343,13 +338,12 @@ subtest 'update() tests' => sub {
};
$t->put_ok( "//$userid:$password@/api/v1/bookings/$booking_id" => json => $booking_with_missing_field )
->status_is(400)
->json_is( "/errors" => [ { message => "Missing property.", path => "/body/biblio_id" } ] );
->status_is(400)->json_is( "/errors" => [ { message => "Missing property.", path => "/body/biblio_id" } ] );
# Full object update on PUT
my $booking_with_updated_field = {
biblio_id => $biblio->id,
item_id => undef,
item_id => $item->itemnumber,
pickup_library_id => $pickup_library->branchcode,
patron_id => $patron->id,
start_date => output_pref( { dateformat => "rfc3339", dt => dt_from_string->add( days => 2 ) } ),
@ -357,14 +351,13 @@ subtest 'update() tests' => sub {
};
$t->put_ok( "//$userid:$password@/api/v1/bookings/$booking_id" => json => $booking_with_updated_field )
->status_is(200)
->json_is( '/biblio_id' => $biblio->id );
->status_is(200)->json_is( '/biblio_id' => $biblio->id );
# Authorized attempt to write invalid data
my $booking_with_invalid_field = {
blah => "Booking Blah",
biblio_id => $biblio->id,
item_id => undef,
item_id => $item->itemnumber,
pickup_library_id => $pickup_library->branchcode,
patron_id => $patron->id,
start_date => output_pref( { dateformat => "rfc3339", dt => dt_from_string->add( days => 2 ) } ),
@ -372,8 +365,7 @@ subtest 'update() tests' => sub {
};
$t->put_ok( "//$userid:$password@/api/v1/bookings/$booking_id" => json => $booking_with_invalid_field )
->status_is(400)
->json_is(
->status_is(400)->json_is(
"/errors" => [
{
message => "Properties not allowed: blah.",
@ -442,8 +434,7 @@ subtest 'delete() tests' => sub {
# Unauthorized attempt to delete
$t->delete_ok("//$unauth_userid:$password@/api/v1/bookings/$booking_id")->status_is(403);
$t->delete_ok("//$userid:$password@/api/v1/bookings/$booking_id")
->status_is( 204, 'REST3.2.4' )
$t->delete_ok("//$userid:$password@/api/v1/bookings/$booking_id")->status_is( 204, 'REST3.2.4' )
->content_is( '', 'REST3.3.4' );
$t->delete_ok("//$userid:$password@/api/v1/bookings/$booking_id")->status_is(404);
@ -503,8 +494,7 @@ subtest 'patch() tests' => sub {
};
$t->patch_ok( "//$userid:$password@/api/v1/bookings/$booking_id" => json => $booking_with_invalid_field )
->status_is(400)
->json_is(
->status_is(400)->json_is(
"/errors" => [
{
message => "Properties not allowed: blah.",
@ -515,3 +505,135 @@ subtest 'patch() tests' => sub {
$schema->storage->txn_rollback;
};
subtest 'add() with itemtype_id tests' => sub {
plan tests => 16;
$schema->storage->txn_begin;
my $librarian = $builder->build_object(
{
class => 'Koha::Patrons',
value => { flags => 0 } # no additional permissions
}
);
$builder->build(
{
source => 'UserPermission',
value => {
borrowernumber => $librarian->borrowernumber,
module_bit => 1,
code => 'manage_bookings',
},
}
);
my $password = 'thePassword123';
$librarian->set_password( { password => $password, skip_validation => 1 } );
my $userid = $librarian->userid;
my $patron = $builder->build_object(
{
class => 'Koha::Patrons',
value => { flags => 0 }
}
);
$patron->set_password( { password => $password, skip_validation => 1 } );
# Create itemtype and items
my $itemtype = $builder->build_object( { class => 'Koha::ItemTypes' } );
my $biblio = $builder->build_sample_biblio;
my $item1 = $builder->build_sample_item(
{
biblionumber => $biblio->id,
bookable => 1,
itype => $itemtype->itemtype
}
);
my $item2 = $builder->build_sample_item(
{
biblionumber => $biblio->id,
bookable => 1,
itype => $itemtype->itemtype
}
);
my $pickup_library = $builder->build_object( { class => 'Koha::Libraries' } );
# Test 1: Booking with itemtype_id instead of item_id should work
my $booking_with_itemtype = {
biblio_id => $biblio->id,
itemtype_id => $itemtype->itemtype,
pickup_library_id => $pickup_library->branchcode,
patron_id => $patron->id,
start_date => output_pref( { dateformat => "rfc3339", dt => dt_from_string->add( days => 2 ) } ),
end_date => output_pref( { dateformat => "rfc3339", dt => dt_from_string->add( days => 6 ) } ),
};
my $tx =
$t->post_ok( "//$userid:$password@/api/v1/bookings" => json => $booking_with_itemtype )
->status_is( 201, 'Created booking with itemtype_id' )->json_has( '/item_id', 'Server assigned an item_id' )
->json_is( '/biblio_id' => $biblio->id );
my $assigned_item_id = $tx->tx->res->json->{item_id};
ok(
$assigned_item_id == $item1->itemnumber || $assigned_item_id == $item2->itemnumber,
'Assigned item is one of the items of the specified itemtype'
);
# Test 2: Booking with both item_id and itemtype_id should fail
my $booking_with_both = {
biblio_id => $biblio->id,
item_id => $item1->itemnumber,
itemtype_id => $itemtype->itemtype,
pickup_library_id => $pickup_library->branchcode,
patron_id => $patron->id,
start_date => output_pref( { dateformat => "rfc3339", dt => dt_from_string->add( days => 10 ) } ),
end_date => output_pref( { dateformat => "rfc3339", dt => dt_from_string->add( days => 14 ) } ),
};
$t->post_ok( "//$userid:$password@/api/v1/bookings" => json => $booking_with_both )->status_is(400)
->json_is( '/error' => 'Cannot specify both item_id and itemtype_id' );
# Test 3: Booking with neither item_id nor itemtype_id should fail
my $booking_with_neither = {
biblio_id => $biblio->id,
pickup_library_id => $pickup_library->branchcode,
patron_id => $patron->id,
start_date => output_pref( { dateformat => "rfc3339", dt => dt_from_string->add( days => 20 ) } ),
end_date => output_pref( { dateformat => "rfc3339", dt => dt_from_string->add( days => 24 ) } ),
};
$t->post_ok( "//$userid:$password@/api/v1/bookings" => json => $booking_with_neither )->status_is(400)
->json_is( '/error' => 'Either item_id or itemtype_id must be provided' );
# Test 4: Verify optimal selection - book all items, then try to book again
# Book item2 for the same period as the first booking
my $booking_item2 = {
biblio_id => $biblio->id,
item_id => $item2->itemnumber,
pickup_library_id => $pickup_library->branchcode,
patron_id => $patron->id,
start_date => output_pref( { dateformat => "rfc3339", dt => dt_from_string->add( days => 2 ) } ),
end_date => output_pref( { dateformat => "rfc3339", dt => dt_from_string->add( days => 6 ) } ),
};
$t->post_ok( "//$userid:$password@/api/v1/bookings" => json => $booking_item2 )->status_is(201);
# Now try to create another booking with itemtype_id for the same period
# Should fail since both items are booked
my $booking_should_fail = {
biblio_id => $biblio->id,
itemtype_id => $itemtype->itemtype,
pickup_library_id => $pickup_library->branchcode,
patron_id => $patron->id,
start_date => output_pref( { dateformat => "rfc3339", dt => dt_from_string->add( days => 3 ) } ),
end_date => output_pref( { dateformat => "rfc3339", dt => dt_from_string->add( days => 5 ) } ),
};
$t->post_ok( "//$userid:$password@/api/v1/bookings" => json => $booking_should_fail )->status_is(400)
->json_is( '/error' => 'Booking would conflict' );
$schema->storage->txn_rollback;
};