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:
parent
c7fa646929
commit
a6766a9e80
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue