Bug 32121: Show an alert when adding a checked out item to an item bundle
[srvgit] / t / db_dependent / Koha / Item.t
index d44c2ff..6f9d7ce 100755 (executable)
 # along with Koha; if not, see <http://www.gnu.org/licenses>.
 
 use Modern::Perl;
+use utf8;
 
-use Test::More tests => 12;
+use Test::More tests => 28;
 use Test::Exception;
+use Test::MockModule;
 
 use C4::Biblio qw( GetMarcSubfieldStructure );
 use C4::Circulation qw( AddIssue AddReturn );
 
+use Koha::Caches;
 use Koha::Items;
 use Koha::Database;
-use Koha::DateUtils;
+use Koha::DateUtils qw( dt_from_string );
 use Koha::Old::Items;
+use Koha::Recalls;
 
 use List::MoreUtils qw(all);
 
 use t::lib::TestBuilder;
 use t::lib::Mocks;
+use t::lib::Dates;
 
 my $schema  = Koha::Database->new->schema;
 my $builder = t::lib::TestBuilder->new;
 
+subtest 'return_claims relationship' => sub {
+    plan tests => 3;
+
+    $schema->storage->txn_begin;
+
+    my $biblio = $builder->build_sample_biblio();
+    my $item   = $builder->build_sample_item({
+        biblionumber => $biblio->biblionumber,
+    });
+    my $return_claims = $item->return_claims;
+    is( ref($return_claims), 'Koha::Checkouts::ReturnClaims', 'return_claims returns a Koha::Checkouts::ReturnClaims object set' );
+    is($item->return_claims->count, 0, "Empty Koha::Checkouts::ReturnClaims set returned if no return_claims");
+    my $claim1 = $builder->build({ source => 'ReturnClaim', value => { itemnumber => $item->itemnumber }});
+    my $claim2 = $builder->build({ source => 'ReturnClaim', value => { itemnumber => $item->itemnumber }});
+
+    is($item->return_claims()->count,2,"Two ReturnClaims found for item");
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'return_claim accessor' => sub {
+    plan tests => 5;
+
+    $schema->storage->txn_begin;
+
+    my $biblio = $builder->build_sample_biblio();
+    my $item   = $builder->build_sample_item({
+        biblionumber => $biblio->biblionumber,
+    });
+    my $return_claim = $item->return_claim;
+    is( $return_claim, undef, 'return_claim returned undefined if there are no claims for this item' );
+
+    my $claim1 = $builder->build_object(
+        {
+            class => 'Koha::Checkouts::ReturnClaims',
+            value => { itemnumber => $item->itemnumber, resolution => undef, created_on => dt_from_string()->subtract( minutes => 10 ) }
+        }
+    );
+    my $claim2 = $builder->build_object(
+        {
+            class => 'Koha::Checkouts::ReturnClaims',
+            value  => { itemnumber => $item->itemnumber, resolution => undef, created_on => dt_from_string()->subtract( minutes => 5 ) }
+        }
+    );
+
+    $return_claim = $item->return_claim;
+    is( ref($return_claim), 'Koha::Checkouts::ReturnClaim', 'return_claim returned a Koha::Checkouts::ReturnClaim object' );
+    is( $return_claim->id, $claim2->id, 'return_claim returns the most recent unresolved claim');
+
+    $claim2->resolution('test')->store();
+    $return_claim = $item->return_claim;
+    is( $return_claim->id, $claim1->id, 'return_claim returns the only unresolved claim');
+
+    $claim1->resolution('test')->store();
+    $return_claim = $item->return_claim;
+    is( $return_claim, undef, 'return_claim returned undefined if there are no active claims for this item' );
+
+    $schema->storage->txn_rollback;
+};
+
 subtest 'tracked_links relationship' => sub {
     plan tests => 3;
 
@@ -54,6 +119,176 @@ subtest 'tracked_links relationship' => sub {
     is($item->tracked_links()->count,2,"Two tracked links found");
 };
 
+subtest 'is_bundle tests' => sub {
+    plan tests => 2;
+
+    $schema->storage->txn_begin;
+
+    my $item   = $builder->build_sample_item();
+
+    my $is_bundle = $item->is_bundle;
+    is($is_bundle, 0, 'is_bundle returns 0 when there are no items attached');
+
+    my $item2 = $builder->build_sample_item();
+    $schema->resultset('ItemBundle')
+      ->create( { host => $item->itemnumber, item => $item2->itemnumber } );
+
+    $is_bundle = $item->is_bundle;
+    is($is_bundle, 1, 'is_bundle returns 1 when there is at least one item attached');
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'in_bundle tests' => sub {
+    plan tests => 2;
+
+    $schema->storage->txn_begin;
+
+    my $item   = $builder->build_sample_item();
+
+    my $in_bundle = $item->in_bundle;
+    is($in_bundle, 0, 'in_bundle returns 0 when the item is not in a bundle');
+
+    my $host_item = $builder->build_sample_item();
+    $schema->resultset('ItemBundle')
+      ->create( { host => $host_item->itemnumber, item => $item->itemnumber } );
+
+    $in_bundle = $item->in_bundle;
+    is($in_bundle, 1, 'in_bundle returns 1 when the item is in a bundle');
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'bundle_items tests' => sub {
+    plan tests => 3;
+
+    $schema->storage->txn_begin;
+
+    my $host_item = $builder->build_sample_item();
+    my $bundle_items = $host_item->bundle_items;
+    is( ref($bundle_items), 'Koha::Items',
+        'bundle_items returns a Koha::Items object set' );
+    is( $bundle_items->count, 0,
+        'bundle_items set is empty when no items are bundled' );
+
+    my $bundle_item1 = $builder->build_sample_item();
+    my $bundle_item2 = $builder->build_sample_item();
+    my $bundle_item3 = $builder->build_sample_item();
+    $schema->resultset('ItemBundle')
+      ->create(
+        { host => $host_item->itemnumber, item => $bundle_item1->itemnumber } );
+    $schema->resultset('ItemBundle')
+      ->create(
+        { host => $host_item->itemnumber, item => $bundle_item2->itemnumber } );
+    $schema->resultset('ItemBundle')
+      ->create(
+        { host => $host_item->itemnumber, item => $bundle_item3->itemnumber } );
+
+    $bundle_items = $host_item->bundle_items;
+    is( $bundle_items->count, 3,
+        'bundle_items returns all the bundled items in the set' );
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'bundle_host tests' => sub {
+    plan tests => 3;
+
+    $schema->storage->txn_begin;
+
+    my $host_item = $builder->build_sample_item();
+    my $bundle_item1 = $builder->build_sample_item();
+    my $bundle_item2 = $builder->build_sample_item();
+    $schema->resultset('ItemBundle')
+      ->create(
+        { host => $host_item->itemnumber, item => $bundle_item2->itemnumber } );
+
+    my $bundle_host = $bundle_item1->bundle_host;
+    is( $bundle_host, undef, 'bundle_host returns undefined when the item it not part of a bundle');
+    $bundle_host = $bundle_item2->bundle_host;
+    is( ref($bundle_host), 'Koha::Item', 'bundle_host returns a Koha::Item object when the item is in a bundle');
+    is( $bundle_host->id, $host_item->id, 'bundle_host returns the host item when called against an item in a bundle');
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'add_to_bundle tests' => sub {
+    plan tests => 7;
+
+    $schema->storage->txn_begin;
+
+    t::lib::Mocks::mock_preference( 'BundleNotLoanValue', 1 );
+
+    my $library = $builder->build_object({ class => 'Koha::Libraries' });
+    t::lib::Mocks::mock_userenv({
+        branchcode => $library->branchcode
+    });
+
+    my $host_item = $builder->build_sample_item();
+    my $bundle_item1 = $builder->build_sample_item();
+    my $bundle_item2 = $builder->build_sample_item();
+
+    throws_ok { $host_item->add_to_bundle($host_item) }
+    'Koha::Exceptions::Item::Bundle::IsBundle',
+      'Exception thrown if you try to add the item to itself';
+
+    ok($host_item->add_to_bundle($bundle_item1), 'bundle_item1 added to bundle');
+    is($bundle_item1->notforloan, 1, 'add_to_bundle sets notforloan to BundleNotLoanValue');
+
+    throws_ok { $host_item->add_to_bundle($bundle_item1) }
+    'Koha::Exceptions::Object::DuplicateID',
+      'Exception thrown if you try to add the same item twice';
+
+    throws_ok { $bundle_item1->add_to_bundle($bundle_item2) }
+    'Koha::Exceptions::Item::Bundle::IsBundle',
+      'Exception thrown if you try to add an item to a bundled item';
+
+    throws_ok { $bundle_item2->add_to_bundle($host_item) }
+    'Koha::Exceptions::Item::Bundle::IsBundle',
+      'Exception thrown if you try to add a bundle host to a bundle item';
+
+    my $patron = $builder->build_object( { class => 'Koha::Patrons' } );
+    C4::Circulation::AddIssue( $patron->unblessed, $bundle_item2->barcode );
+    throws_ok { $host_item->add_to_bundle($bundle_item2) }
+    'Koha::Exceptions::Item::Bundle::ItemIsCheckedOut',
+      'Exception thrown if you try to add a checked out item';
+
+    $bundle_item2->withdrawn(1)->store;
+    t::lib::Mocks::mock_preference( 'BlockReturnOfWithdrawnItems', 1 );
+    throws_ok { $host_item->add_to_bundle( $bundle_item2, { force_checkin => 1 } ) }
+    'Koha::Exceptions::Checkin::FailedCheckin',
+      'Exception thrown if you try to add a checked out item using
+      "force_checkin" and the return is not possible';
+
+    $bundle_item2->withdrawn(0)->store;
+    lives_ok { $host_item->add_to_bundle( $bundle_item2, { force_checkin => 1 } ) }
+    'No exception if you try to add a checked out item using "force_checkin" and the return is possible';
+
+    $bundle_item2->discard_changes;
+    ok( !$bundle_item2->checkout, 'Item is not checked out after being added to a bundle' );
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'remove_from_bundle tests' => sub {
+    plan tests => 3;
+
+    $schema->storage->txn_begin;
+
+    my $host_item = $builder->build_sample_item();
+    my $bundle_item1 = $builder->build_sample_item({ notforloan => 1 });
+    $schema->resultset('ItemBundle')
+      ->create(
+        { host => $host_item->itemnumber, item => $bundle_item1->itemnumber } );
+
+    is($bundle_item1->remove_from_bundle(), 1, 'remove_from_bundle returns 1 when item is removed from a bundle');
+    is($bundle_item1->notforloan, 0, 'remove_from_bundle resets notforloan to 0');
+    $bundle_item1 = $bundle_item1->get_from_storage;
+    is($bundle_item1->remove_from_bundle(), 0, 'remove_from_bundle returns 0 when item is not in a bundle');
+
+    $schema->storage->txn_rollback;
+};
+
 subtest 'hidden_in_opac() tests' => sub {
 
     plan tests => 4;
@@ -107,11 +342,12 @@ subtest 'has_pending_hold() tests' => sub {
 subtest "as_marc_field() tests" => sub {
 
     my $mss = C4::Biblio::GetMarcSubfieldStructure( '' );
+    my ( $itemtag, $itemtagsubfield) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
 
     my @schema_columns = $schema->resultset('Item')->result_source->columns;
     my @mapped_columns = grep { exists $mss->{'items.'.$_} } @schema_columns;
 
-    plan tests => 2 * (scalar @mapped_columns + 1) + 2;
+    plan tests => 2 * (scalar @mapped_columns + 1) + 4;
 
     $schema->storage->txn_begin;
 
@@ -124,7 +360,7 @@ subtest "as_marc_field() tests" => sub {
 
     is(
         $marc_field->tag,
-        $mss->{'items.itemnumber'}[0]->{tagfield},
+        $itemtag,
         'Generated field set the right tag number'
     );
 
@@ -139,7 +375,7 @@ subtest "as_marc_field() tests" => sub {
 
     is(
         $marc_field->tag,
-        $mss->{'items.itemnumber'}[0]->{tagfield},
+        $itemtag,
         'Generated field set the right tag number'
     );
 
@@ -152,25 +388,45 @@ subtest "as_marc_field() tests" => sub {
     my $unmapped_subfield = Koha::MarcSubfieldStructure->new(
         {
             frameworkcode => '',
-            tagfield      => $mss->{'items.itemnumber'}[0]->{tagfield},
+            tagfield      => $itemtag,
             tagsubfield   => 'X',
         }
     )->store;
+    Koha::MarcSubfieldStructure->new(
+        {
+            frameworkcode => '',
+            tagfield      => $itemtag,
+            tagsubfield   => 'Y',
+            kohafield     => '',
+        }
+    )->store;
 
-    $mss = C4::Biblio::GetMarcSubfieldStructure( '' );
     my @unlinked_subfields;
-    push @unlinked_subfields, X => 'Something weird';
+    push @unlinked_subfields, X => 'Something weird', Y => 'Something else';
     $item->more_subfields_xml( C4::Items::_get_unlinked_subfields_xml( \@unlinked_subfields ) )->store;
 
+    Koha::Caches->get_instance->clear_from_cache( "MarcStructure-1-" );
+    Koha::MarcSubfieldStructures->search(
+        { frameworkcode => '', tagfield => $itemtag } )
+      ->update( { display_order => \['FLOOR( 1 + RAND( ) * 10 )'] } );
+
     $marc_field = $item->as_marc_field;
 
+    my $tagslib = C4::Biblio::GetMarcStructure(1, '');
     my @subfields = $marc_field->subfields;
     my $result = all { defined $_->[1] } @subfields;
     ok( $result, 'There are no undef subfields' );
+    my @ordered_subfields = sort {
+            $tagslib->{$itemtag}->{ $a->[0] }->{display_order}
+        <=> $tagslib->{$itemtag}->{ $b->[0] }->{display_order}
+    } @subfields;
+    is_deeply(\@subfields, \@ordered_subfields);
 
-    is( scalar $marc_field->subfield('X'), 'Something weird', 'more_subfield_xml is considered' );
+    is( scalar $marc_field->subfield('X'), 'Something weird', 'more_subfield_xml is considered when kohafield is NULL' );
+    is( scalar $marc_field->subfield('Y'), 'Something else', 'more_subfield_xml is considered when kohafield = ""' );
 
     $schema->storage->txn_rollback;
+    Koha::Caches->get_instance->clear_from_cache( "MarcStructure-1-" );
 };
 
 subtest 'pickup_locations' => sub {
@@ -557,7 +813,7 @@ subtest 'request_transfer' => sub {
 };
 
 subtest 'deletion' => sub {
-    plan tests => 12;
+    plan tests => 15;
 
     $schema->storage->txn_begin;
 
@@ -568,9 +824,13 @@ subtest 'deletion' => sub {
             biblionumber => $biblio->biblionumber,
         }
     );
+    is( $item->deleted_on, undef, 'deleted_on not set for new item' );
 
-    is( ref( $item->move_to_deleted ), 'Koha::Schema::Result::Deleteditem', 'Koha::Item->move_to_deleted should return the Deleted item' )
+    my $deleted_item = $item->move_to_deleted;
+    is( ref( $deleted_item ), 'Koha::Schema::Result::Deleteditem', 'Koha::Item->move_to_deleted should return the Deleted item' )
       ;    # FIXME This should be Koha::Deleted::Item
+    is( t::lib::Dates::compare( $deleted_item->deleted_on, dt_from_string() ), 0 );
+
     is( Koha::Old::Items->search({itemnumber => $item->itemnumber})->count, 1, '->move_to_deleted must have moved the item to deleteditem' );
     $item = $builder->build_sample_item(
         {
@@ -592,33 +852,36 @@ subtest 'deletion' => sub {
     C4::Circulation::AddIssue( $patron->unblessed, $item->barcode );
 
     is(
-        $item->safe_to_delete,
+        @{$item->safe_to_delete->messages}[0]->message,
         'book_on_loan',
         'Koha::Item->safe_to_delete reports item on loan',
     );
 
     is(
-        $item->safe_delete,
+        @{$item->safe_to_delete->messages}[0]->message,
         'book_on_loan',
         'item that is on loan cannot be deleted',
     );
 
-    AddReturn( $item->barcode, $library->branchcode );
+    ok(
+        ! $item->safe_to_delete,
+        'Koha::Item->safe_to_delete shows item NOT safe to delete'
+    );
 
-    # book_reserved is tested in t/db_dependent/Reserves.t
+    AddReturn( $item->barcode, $library->branchcode );
 
     # not_same_branch
     t::lib::Mocks::mock_preference('IndependentBranches', 1);
     my $item_2 = $builder->build_sample_item({ library => $library_2->branchcode });
 
     is(
-        $item_2->safe_to_delete,
+        @{$item_2->safe_to_delete->messages}[0]->message,
         'not_same_branch',
         'Koha::Item->safe_to_delete reports IndependentBranches restriction',
     );
 
     is(
-        $item_2->safe_delete,
+        @{$item_2->safe_to_delete->messages}[0]->message,
         'not_same_branch',
         'IndependentBranches prevents deletion at another branch',
     );
@@ -632,30 +895,21 @@ subtest 'deletion' => sub {
 
         $item->discard_changes;
         is(
-            $item->safe_to_delete,
+            @{$item->safe_to_delete->messages}[0]->message,
             'linked_analytics',
             'Koha::Item->safe_to_delete reports linked analytics',
         );
 
         is(
-            $item->safe_delete,
+            @{$item->safe_to_delete->messages}[0]->message,
             'linked_analytics',
             'Linked analytics prevents deletion of item',
         );
 
     }
 
-    { # last_item_for_hold
-        C4::Reserves::AddReserve({ branchcode => $patron->branchcode, borrowernumber => $patron->borrowernumber, biblionumber => $item->biblionumber });
-        is( $item->safe_to_delete, 'last_item_for_hold', 'Item cannot be deleted if a biblio-level is placed on the biblio and there is only 1 item attached to the biblio' );
-
-        # With another item attached to the biblio, the item can be deleted
-        $builder->build_sample_item({ biblionumber => $item->biblionumber });
-    }
-
-    is(
+    ok(
         $item->safe_to_delete,
-        1,
         'Koha::Item->safe_to_delete shows item safe to delete'
     );
 
@@ -667,6 +921,73 @@ subtest 'deletion' => sub {
         "Koha::Item->safe_delete should delete item if safe_to_delete returns true"
     );
 
+    subtest 'holds tests' => sub {
+
+        plan tests => 9;
+
+        # to avoid noise
+        t::lib::Mocks::mock_preference( 'IndependentBranches', 0 );
+
+        $schema->storage->txn_begin;
+
+        my $item = $builder->build_sample_item;
+
+        my $processing     = $builder->build_object( { class => 'Koha::Holds', value => { itemnumber => $item->id, itemnumber => $item->id, found => 'P' } } );
+        my $safe_to_delete = $item->safe_to_delete;
+
+        ok( !$safe_to_delete, 'Cannot delete' );
+        is(
+            @{ $safe_to_delete->messages }[0]->message,
+            'book_reserved',
+            'Koha::Item->safe_to_delete reports a in processing hold blocks deletion'
+        );
+
+        $processing->delete;
+
+        my $in_transit = $builder->build_object( { class => 'Koha::Holds', value => { itemnumber => $item->id, itemnumber => $item->id, found => 'T' } } );
+        $safe_to_delete = $item->safe_to_delete;
+
+        ok( !$safe_to_delete, 'Cannot delete' );
+        is(
+            @{ $safe_to_delete->messages }[0]->message,
+            'book_reserved',
+            'Koha::Item->safe_to_delete reports a in transit hold blocks deletion'
+        );
+
+        $in_transit->delete;
+
+        my $waiting = $builder->build_object( { class => 'Koha::Holds', value => { itemnumber => $item->id, itemnumber => $item->id, found => 'W' } } );
+        $safe_to_delete = $item->safe_to_delete;
+
+        ok( !$safe_to_delete, 'Cannot delete' );
+        is(
+            @{ $safe_to_delete->messages }[0]->message,
+            'book_reserved',
+            'Koha::Item->safe_to_delete reports a waiting hold blocks deletion'
+        );
+
+        $waiting->delete;
+
+        # Add am unfilled biblio-level hold to catch the 'last_item_for_hold' use case
+        $builder->build_object( { class => 'Koha::Holds', value => { biblionumber => $item->biblionumber, itemnumber => undef, found => undef } } );
+
+        $safe_to_delete = $item->safe_to_delete;
+
+        ok( !$safe_to_delete );
+
+        is(
+            @{ $safe_to_delete->messages}[0]->message,
+            'last_item_for_hold',
+            'Item cannot be deleted if a biblio-level is placed on the biblio and there is only 1 item attached to the biblio'
+        );
+
+        my $extra_item = $builder->build_sample_item({ biblionumber => $item->biblionumber });
+
+        ok( $item->safe_to_delete );
+
+        $schema->storage->txn_rollback;
+    };
+
     $schema->storage->txn_rollback;
 };
 
@@ -1050,3 +1371,781 @@ subtest 'move_to_biblio() tests' => sub {
 
     $schema->storage->txn_rollback;
 };
+
+subtest 'columns_to_str' => sub {
+    plan tests => 4;
+
+    $schema->storage->txn_begin;
+
+    my ( $itemtag, $itemsubfield ) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
+
+    my $cache = Koha::Caches->get_instance();
+    $cache->clear_from_cache("MarcStructure-0-");
+    $cache->clear_from_cache("MarcStructure-1-");
+    $cache->clear_from_cache("MarcSubfieldStructure-");
+    $cache->clear_from_cache("libraries:name");
+    $cache->clear_from_cache("itemtype:description:en");
+    $cache->clear_from_cache("cn_sources:description");
+    $cache->clear_from_cache("AV_descriptions:LOST");
+
+    # Creating subfields 'é', 'è' that are not linked with a kohafield
+    Koha::MarcSubfieldStructures->search(
+        {
+            frameworkcode => '',
+            tagfield => $itemtag,
+            tagsubfield => ['é', 'è'],
+        }
+    )->delete;    # In case it exist already
+
+    # Ã© is not linked with a AV
+    # Ã¨ is linked with AV branches
+    Koha::MarcSubfieldStructure->new(
+        {
+            frameworkcode => '',
+            tagfield      => $itemtag,
+            tagsubfield   => 'é',
+            kohafield     => undef,
+            repeatable    => 1,
+            defaultvalue  => 'ééé',
+            tab           => 10,
+        }
+    )->store;
+    Koha::MarcSubfieldStructure->new(
+        {
+            frameworkcode    => '',
+            tagfield         => $itemtag,
+            tagsubfield      => 'è',
+            kohafield        => undef,
+            repeatable       => 1,
+            defaultvalue     => 'èèè',
+            tab              => 10,
+            authorised_value => 'branches',
+        }
+    )->store;
+
+    my $biblio = $builder->build_sample_biblio({ frameworkcode => '' });
+    my $item = $builder->build_sample_item({ biblionumber => $biblio->biblionumber });
+    my $lost_av = $builder->build_object({ class => 'Koha::AuthorisedValues', value => { category => 'LOST', authorised_value => '42' }});
+    my $dateaccessioned = '2020-12-15';
+    my $library = Koha::Libraries->search->next;
+    my $branchcode = $library->branchcode;
+
+    my $some_marc_xml = qq{<?xml version="1.0" encoding="UTF-8"?>
+<collection
+  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xsi:schemaLocation="http://www.loc.gov/MARC21/slim http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd"
+  xmlns="http://www.loc.gov/MARC21/slim">
+
+<record>
+  <leader>         a              </leader>
+  <datafield tag="999" ind1=" " ind2=" ">
+    <subfield code="é">value Ã©</subfield>
+    <subfield code="è">$branchcode</subfield>
+  </datafield>
+</record>
+
+</collection>};
+
+    $item->update(
+        {
+            itemlost           => $lost_av->authorised_value,
+            dateaccessioned    => $dateaccessioned,
+            more_subfields_xml => $some_marc_xml,
+        }
+    );
+
+    $item = $item->get_from_storage;
+
+    my $s = $item->columns_to_str;
+    is( $s->{itemlost}, $lost_av->lib, 'Attributes linked with AV replaced with description' );
+    is( $s->{dateaccessioned}, '2020-12-15', 'Date attributes iso formatted');
+    is( $s->{'é'}, 'value Ã©', 'subfield ok with more than a-Z');
+    is( $s->{'è'}, $library->branchname );
+
+    $cache->clear_from_cache("MarcStructure-0-");
+    $cache->clear_from_cache("MarcStructure-1-");
+    $cache->clear_from_cache("MarcSubfieldStructure-");
+    $cache->clear_from_cache("libraries:name");
+    $cache->clear_from_cache("itemtype:description:en");
+    $cache->clear_from_cache("cn_sources:description");
+    $cache->clear_from_cache("AV_descriptions:LOST");
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'strings_map() tests' => sub {
+
+    plan tests => 6;
+
+    $schema->storage->txn_begin;
+
+    my ( $itemtag, $itemsubfield ) = C4::Biblio::GetMarcFromKohaField("items.itemnumber");
+
+    my $cache = Koha::Caches->get_instance();
+    $cache->clear_from_cache("MarcStructure-0-");
+    $cache->clear_from_cache("MarcStructure-1-");
+    $cache->clear_from_cache("MarcSubfieldStructure-");
+    $cache->clear_from_cache("libraries:name");
+    $cache->clear_from_cache("itemtype:description:en");
+    $cache->clear_from_cache("cn_sources:description");
+    $cache->clear_from_cache("AV_descriptions:LOST");
+
+    # Recreating subfields just to be sure tests will be ok
+    # 1 => av (LOST)
+    # 3 => no link
+    # a => branches
+    # y => itemtypes
+    Koha::MarcSubfieldStructures->search(
+        {
+            frameworkcode => '',
+            tagfield      => $itemtag,
+            tagsubfield   => [ '1', '2', '3', 'a', 'y' ],
+        }
+    )->delete;    # In case it exist already
+
+    Koha::MarcSubfieldStructure->new(
+        {
+            authorised_value => 'LOST',
+            defaultvalue     => '',
+            frameworkcode    => '',
+            kohafield        => 'items.itemlost',
+            repeatable       => 1,
+            tab              => 10,
+            tagfield         => $itemtag,
+            tagsubfield      => '1',
+        }
+    )->store;
+    Koha::MarcSubfieldStructure->new(
+        {
+            authorised_value => 'cn_source',
+            defaultvalue     => '',
+            frameworkcode    => '',
+            kohafield        => 'items.cn_source',
+            repeatable       => 1,
+            tab              => 10,
+            tagfield         => $itemtag,
+            tagsubfield      => '2',
+        }
+    )->store;
+    Koha::MarcSubfieldStructure->new(
+        {
+            authorised_value => '',
+            defaultvalue     => '',
+            frameworkcode    => '',
+            kohafield        => 'items.materials',
+            repeatable       => 1,
+            tab              => 10,
+            tagfield         => $itemtag,
+            tagsubfield      => '3',
+        }
+    )->store;
+    Koha::MarcSubfieldStructure->new(
+        {
+            authorised_value => 'branches',
+            defaultvalue     => '',
+            frameworkcode    => '',
+            kohafield        => 'items.homebranch',
+            repeatable       => 1,
+            tab              => 10,
+            tagfield         => $itemtag,
+            tagsubfield      => 'a',
+        }
+    )->store;
+    Koha::MarcSubfieldStructure->new(
+        {
+            authorised_value => 'itemtypes',
+            defaultvalue     => '',
+            frameworkcode    => '',
+            kohafield        => 'items.itype',
+            repeatable       => 1,
+            tab              => 10,
+            tagfield         => $itemtag,
+            tagsubfield      => 'y',
+        }
+    )->store;
+
+    my $itype   = $builder->build_object( { class => 'Koha::ItemTypes' } );
+    my $library = $builder->build_object( { class => 'Koha::Libraries' } );
+    my $biblio  = $builder->build_sample_biblio( { frameworkcode => '' } );
+    my $item    = $builder->build_sample_item(
+        {
+            biblionumber => $biblio->id,
+            library      => $library->id
+        }
+    );
+
+    Koha::AuthorisedValues->search( { authorised_value => 3, category => 'LOST' } )->delete;
+    my $lost_av = $builder->build_object(
+        {
+            class => 'Koha::AuthorisedValues',
+            value => {
+                authorised_value => 3,
+                category         => 'LOST',
+                lib              => 'internal description',
+                lib_opac         => 'public description',
+            }
+        }
+    );
+
+    my $class_sort_rule  = $builder->build_object( { class => 'Koha::ClassSortRules', value => { sort_routine => 'Generic' } } );
+    my $class_split_rule = $builder->build_object( { class => 'Koha::ClassSplitRules' } );
+    my $class_source     = $builder->build_object(
+        {
+            class => 'Koha::ClassSources',
+            value => {
+                class_sort_rule  => $class_sort_rule->class_sort_rule,
+                class_split_rule => $class_split_rule->class_split_rule,
+            }
+        }
+    )->store();
+
+    $item->set(
+        {
+            cn_source => $class_source->id,
+            itemlost  => $lost_av->authorised_value,
+            itype     => $itype->itemtype,
+            materials => 'Suff',
+        }
+    )->store->discard_changes;
+
+    my $strings = $item->strings_map;
+
+    subtest 'unmapped field tests' => sub {
+
+        plan tests => 1;
+
+        ok( !exists $strings->{materials}, "Unmapped field not present" );
+    };
+
+    subtest 'av handling' => sub {
+
+        plan tests => 4;
+
+        ok( exists $strings->{itemlost}, "'itemlost' entry exists" );
+        is( $strings->{itemlost}->{str},      $lost_av->lib, "'str' set to av->lib" );
+        is( $strings->{itemlost}->{type},     'av',          "'type' is 'av'" );
+        is( $strings->{itemlost}->{category}, 'LOST',        "'category' exists and set to 'LOST'" );
+    };
+
+    subtest 'cn_source handling' => sub {
+
+        plan tests => 3;
+
+        ok( exists $strings->{cn_source}, "'cn_source' entry exists" );
+        is( $strings->{cn_source}->{str},  $class_source->description,    "'str' set to \$class_source->description" );
+        is( $strings->{cn_source}->{type}, 'call_number_source', "type is 'library'" );
+    };
+
+    subtest 'branches handling' => sub {
+
+        plan tests => 3;
+
+        ok( exists $strings->{homebranch}, "'homebranch' entry exists" );
+        is( $strings->{homebranch}->{str},  $library->branchname, "'str' set to 'branchname'" );
+        is( $strings->{homebranch}->{type}, 'library',            "type is 'library'" );
+    };
+
+    subtest 'itemtype handling' => sub {
+
+        plan tests => 3;
+
+        ok( exists $strings->{itype}, "'itype' entry exists" );
+        is( $strings->{itype}->{str},  $itype->description, "'str' correctly set" );
+        is( $strings->{itype}->{type}, 'item_type',         "'type' is 'item_type'" );
+    };
+
+    subtest 'public flag tests' => sub {
+
+        plan tests => 4;
+
+        $strings = $item->strings_map( { public => 1 } );
+
+        ok( exists $strings->{itemlost}, "'itemlost' entry exists" );
+        is( $strings->{itemlost}->{str},      $lost_av->lib_opac, "'str' set to av->lib" );
+        is( $strings->{itemlost}->{type},     'av',               "'type' is 'av'" );
+        is( $strings->{itemlost}->{category}, 'LOST',             "'category' exists and set to 'LOST'" );
+    };
+
+    $cache->clear_from_cache("MarcStructure-0-");
+    $cache->clear_from_cache("MarcStructure-1-");
+    $cache->clear_from_cache("MarcSubfieldStructure-");
+    $cache->clear_from_cache("libraries:name");
+    $cache->clear_from_cache("itemtype:description:en");
+    $cache->clear_from_cache("cn_sources:description");
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'store() tests' => sub {
+
+    plan tests => 3;
+
+    subtest 'dateaccessioned handling' => sub {
+
+        plan tests => 3;
+
+        $schema->storage->txn_begin;
+
+        my $item = $builder->build_sample_item;
+
+        ok( defined $item->dateaccessioned, 'dateaccessioned is set' );
+
+        # reset dateaccessioned on the DB
+        $schema->resultset('Item')->find({ itemnumber => $item->id })->update({ dateaccessioned => undef });
+        $item->discard_changes;
+
+        ok( !defined $item->dateaccessioned );
+
+        # update something
+        $item->replacementprice(100)->store->discard_changes;
+
+        ok( !defined $item->dateaccessioned, 'dateaccessioned not set on update if undefined' );
+
+        $schema->storage->txn_rollback;
+    };
+
+    subtest '_set_found_trigger() tests' => sub {
+
+        plan tests => 9;
+
+        $schema->storage->txn_begin;
+
+        my $patron = $builder->build_object({ class => 'Koha::Patrons' });
+        my $item   = $builder->build_sample_item({ itemlost => 1, itemlost_on => dt_from_string() });
+
+        # Add a lost item debit
+        my $debit = $patron->account->add_debit(
+            {
+                amount    => 10,
+                type      => 'LOST',
+                item_id   => $item->id,
+                interface => 'intranet',
+            }
+        );
+
+        # Add a lost item processing fee
+        my $processing_debit = $patron->account->add_debit(
+            {
+                amount    => 2,
+                type      => 'PROCESSING',
+                item_id   => $item->id,
+                interface => 'intranet',
+            }
+        );
+
+        my $lostreturn_policy = {
+            lostreturn       => 'charge',
+            processingreturn => 'refund'
+        };
+
+        my $mocked_circ_rules = Test::MockModule->new('Koha::CirculationRules');
+        $mocked_circ_rules->mock( 'get_lostreturn_policy', sub { return $lostreturn_policy; } );
+
+        # simulate it was found
+        $item->set( { itemlost => 0 } )->store;
+
+        my $messages = $item->object_messages;
+
+        my $message_1 = $messages->[0];
+
+        is( $message_1->type,    'info',          'type is correct' );
+        is( $message_1->message, 'lost_refunded', 'message is correct' );
+
+        # Find the refund credit
+        my $credit = $debit->credits->next;
+
+        is_deeply(
+            $message_1->payload,
+            { credit_id => $credit->id },
+            'type is correct'
+        );
+
+        my $message_2 = $messages->[1];
+
+        is( $message_2->type,    'info',        'type is correct' );
+        is( $message_2->message, 'lost_charge', 'message is correct' );
+        is( $message_2->payload, undef,         'no payload' );
+
+        my $message_3 = $messages->[2];
+        is( $message_3->message, 'processing_refunded', 'message is correct' );
+
+        my $processing_credit = $processing_debit->credits->next;
+        is_deeply(
+            $message_3->payload,
+            { credit_id => $processing_credit->id },
+            'type is correct'
+        );
+
+        # Let's build a new item
+        $item   = $builder->build_sample_item({ itemlost => 1, itemlost_on => dt_from_string() });
+        $item->set( { itemlost => 0 } )->store;
+
+        $messages = $item->object_messages;
+        is( scalar @{$messages}, 0, 'This item has no history, no associated lost fines, presumed not lost by patron, no messages returned');
+
+        $schema->storage->txn_rollback;
+    };
+
+    subtest 'holds_queue update tests' => sub {
+
+        plan tests => 2;
+
+        $schema->storage->txn_begin;
+
+        my $biblio = $builder->build_sample_biblio;
+
+        my $mock = Test::MockModule->new('Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue');
+        $mock->mock( 'enqueue', sub {
+            my ( $self, $args ) = @_;
+            is_deeply(
+                $args->{biblio_ids},
+                [ $biblio->id ],
+                '->store triggers a holds queue update for the related biblio'
+            );
+        } );
+
+        t::lib::Mocks::mock_preference( 'RealTimeHoldsQueue', 1 );
+
+        # new item
+        my $item = $builder->build_sample_item({ biblionumber => $biblio->id });
+
+        # updated item
+        $item->set({ reserves => 1 })->store;
+
+        t::lib::Mocks::mock_preference( 'RealTimeHoldsQueue', 0 );
+        # updated item
+        $item->set({ reserves => 0 })->store;
+
+        $schema->storage->txn_rollback;
+    };
+};
+
+subtest 'Recalls tests' => sub {
+
+    plan tests => 22;
+
+    $schema->storage->txn_begin;
+
+    my $item1 = $builder->build_sample_item;
+    my $biblio = $item1->biblio;
+    my $branchcode = $item1->holdingbranch;
+    my $patron1 = $builder->build_object({ class => 'Koha::Patrons', value => { branchcode => $branchcode } });
+    my $patron2 = $builder->build_object({ class => 'Koha::Patrons', value => { branchcode => $branchcode } });
+    my $patron3 = $builder->build_object({ class => 'Koha::Patrons', value => { branchcode => $branchcode } });
+    my $item2 = $builder->build_object(
+        {   class => 'Koha::Items',
+            value => { holdingbranch => $branchcode, homebranch => $branchcode, biblionumber => $biblio->biblionumber, itype => $item1->effective_itemtype }
+        }
+    );
+
+    t::lib::Mocks::mock_userenv( { patron => $patron1 } );
+    t::lib::Mocks::mock_preference('UseRecalls', 1);
+
+    my $recall1 = Koha::Recall->new(
+        {   patron_id         => $patron1->borrowernumber,
+            created_date      => \'NOW()',
+            biblio_id         => $biblio->biblionumber,
+            pickup_library_id => $branchcode,
+            item_id           => $item1->itemnumber,
+            expiration_date   => undef,
+            item_level        => 1
+        }
+    )->store;
+    my $recall2 = Koha::Recall->new(
+        {   patron_id         => $patron2->borrowernumber,
+            created_date      => \'NOW()',
+            biblio_id         => $biblio->biblionumber,
+            pickup_library_id => $branchcode,
+            item_id           => $item1->itemnumber,
+            expiration_date   => undef,
+            item_level        => 1
+        }
+    )->store;
+
+    is( $item1->recall->patron_id, $patron1->borrowernumber, 'Correctly returns most relevant recall' );
+
+    $recall2->set_cancelled;
+
+    t::lib::Mocks::mock_preference('UseRecalls', 0);
+    is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall with UseRecalls disabled" );
+
+    t::lib::Mocks::mock_preference("UseRecalls", 1);
+
+    $item1->update({ notforloan => 1 });
+    is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall that is not for loan" );
+    $item1->update({ notforloan => 0, itemlost => 1 });
+    is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall that is marked lost" );
+    $item1->update({ itemlost => 0, withdrawn => 1 });
+    is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall that is withdrawn" );
+    is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall item if not checked out" );
+
+    $item1->update({ withdrawn => 0 });
+    C4::Circulation::AddIssue( $patron2->unblessed, $item1->barcode );
+
+    Koha::CirculationRules->set_rules({
+        branchcode => $branchcode,
+        categorycode => $patron1->categorycode,
+        itemtype => $item1->effective_itemtype,
+        rules => {
+            recalls_allowed => 0,
+            recalls_per_record => 1,
+            on_shelf_recalls => 'all',
+        },
+    });
+    is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if recalls_allowed = 0" );
+
+    Koha::CirculationRules->set_rules({
+        branchcode => $branchcode,
+        categorycode => $patron1->categorycode,
+        itemtype => $item1->effective_itemtype,
+        rules => {
+            recalls_allowed => 1,
+            recalls_per_record => 1,
+            on_shelf_recalls => 'all',
+        },
+    });
+    is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if patron has more existing recall(s) than recalls_allowed" );
+    is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if patron has more existing recall(s) than recalls_per_record" );
+    is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if patron has already recalled this item" );
+
+    my $reserve_id = C4::Reserves::AddReserve({ branchcode => $branchcode, borrowernumber => $patron1->borrowernumber, biblionumber => $item1->biblionumber, itemnumber => $item1->itemnumber });
+    is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall item if patron has already reserved it" );
+    C4::Reserves::ModReserve({ rank => 'del', reserve_id => $reserve_id, branchcode => $branchcode, itemnumber => $item1->itemnumber, borrowernumber => $patron1->borrowernumber, biblionumber => $item1->biblionumber });
+
+    $recall1->set_cancelled;
+    is( $item1->can_be_recalled({ patron => $patron2 }), 0, "Can't recall if patron has already checked out an item attached to this biblio" );
+
+    is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if on_shelf_recalls = all and items are still available" );
+
+    Koha::CirculationRules->set_rules({
+        branchcode => $branchcode,
+        categorycode => $patron1->categorycode,
+        itemtype => $item1->effective_itemtype,
+        rules => {
+            recalls_allowed => 1,
+            recalls_per_record => 1,
+            on_shelf_recalls => 'any',
+        },
+    });
+    C4::Circulation::AddReturn( $item1->barcode, $branchcode );
+    is( $item1->can_be_recalled({ patron => $patron1 }), 0, "Can't recall if no items are checked out" );
+
+    C4::Circulation::AddIssue( $patron2->unblessed, $item1->barcode );
+    is( $item1->can_be_recalled({ patron => $patron1 }), 1, "Can recall item" );
+
+    $recall1 = Koha::Recall->new(
+        {   patron_id         => $patron1->borrowernumber,
+            created_date      => \'NOW()',
+            biblio_id         => $biblio->biblionumber,
+            pickup_library_id => $branchcode,
+            item_id           => undef,
+            expiration_date   => undef,
+            item_level        => 0
+        }
+    )->store;
+
+    # Patron2 has Item1 checked out. Patron1 has placed a biblio-level recall on Biblio1, so check if Item1 can fulfill Patron1's recall.
+
+    Koha::CirculationRules->set_rules({
+        branchcode => undef,
+        categorycode => undef,
+        itemtype => $item1->effective_itemtype,
+        rules => {
+            recalls_allowed => 0,
+            recalls_per_record => 1,
+            on_shelf_recalls => 'any',
+        },
+    });
+    is( $item1->can_be_waiting_recall, 0, "Recalls not allowed for this itemtype" );
+
+    Koha::CirculationRules->set_rules({
+        branchcode => undef,
+        categorycode => undef,
+        itemtype => $item1->effective_itemtype,
+        rules => {
+            recalls_allowed => 1,
+            recalls_per_record => 1,
+            on_shelf_recalls => 'any',
+        },
+    });
+    is( $item1->can_be_waiting_recall, 1, "Recalls are allowed for this itemtype" );
+
+    # check_recalls tests
+
+    $recall1 = Koha::Recall->new(
+        {   patron_id         => $patron2->borrowernumber,
+            created_date      => \'NOW()',
+            biblio_id         => $biblio->biblionumber,
+            pickup_library_id => $branchcode,
+            item_id           => $item1->itemnumber,
+            expiration_date   => undef,
+            item_level        => 1
+        }
+    )->store;
+    $recall2 = Koha::Recall->new(
+        {   patron_id         => $patron1->borrowernumber,
+            created_date      => \'NOW()',
+            biblio_id         => $biblio->biblionumber,
+            pickup_library_id => $branchcode,
+            item_id           => undef,
+            expiration_date   => undef,
+            item_level        => 0
+        }
+    )->store;
+    $recall2->set_waiting( { item => $item1 } );
+    is( $item1->has_pending_recall, 1, 'Item has pending recall' );
+
+    # return a waiting recall
+    my $check_recall = $item1->check_recalls;
+    is( $check_recall->patron_id, $patron1->borrowernumber, "Waiting recall is highest priority and returned" );
+
+    $recall2->revert_waiting;
+
+    is( $item1->has_pending_recall, 0, 'Item does not have pending recall' );
+
+    # return recall based on recalldate
+    $check_recall = $item1->check_recalls;
+    is( $check_recall->patron_id, $patron1->borrowernumber, "No waiting recall, so oldest recall is returned" );
+
+    $recall1->set_cancelled;
+
+    # return a biblio-level recall
+    $check_recall = $item1->check_recalls;
+    is( $check_recall->patron_id, $patron1->borrowernumber, "Only remaining recall is returned" );
+
+    $recall2->set_cancelled;
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'Notforloan tests' => sub {
+
+    plan tests => 3;
+
+    $schema->storage->txn_begin;
+
+    my $item1 = $builder->build_sample_item;
+    $item1->update({ notforloan => 0 });
+    $item1->itemtype->notforloan(0);
+    is ( $item1->is_notforloan, 0, 'Notforloan is correctly false by item status and item type');
+    $item1->update({ notforloan => 1 });
+    is ( $item1->is_notforloan, 1, 'Notforloan is correctly true by item status');
+    $item1->update({ notforloan => 0 });
+    $item1->itemtype->update({ notforloan => 1 });
+    is ( $item1->is_notforloan, 1, 'Notforloan is correctly true by item type');
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'item_group() tests' => sub {
+
+    plan tests => 4;
+
+    $schema->storage->txn_begin;
+
+    my $biblio = $builder->build_sample_biblio();
+    my $item_1 = $builder->build_sample_item({ biblionumber => $biblio->biblionumber });
+    my $item_2 = $builder->build_sample_item({ biblionumber => $biblio->biblionumber });
+
+    is( $item_1->item_group, undef, 'Item 1 has no item group');
+    is( $item_2->item_group, undef, 'Item 2 has no item group');
+
+    my $item_group_1 = Koha::Biblio::ItemGroup->new( { biblio_id => $biblio->id } )->store();
+    my $item_group_2 = Koha::Biblio::ItemGroup->new( { biblio_id => $biblio->id } )->store();
+
+    $item_group_1->add_item({ item_id => $item_1->id });
+    $item_group_2->add_item({ item_id => $item_2->id });
+
+    is( $item_1->item_group->id, $item_group_1->id, 'Got item group 1 correctly' );
+    is( $item_2->item_group->id, $item_group_2->id, 'Got item group 2 correctly' );
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'has_pending_recall() tests' => sub {
+
+    plan tests => 2;
+
+    $schema->storage->txn_begin;
+
+    my $library = $builder->build_object({ class => 'Koha::Libraries' });
+    my $item    = $builder->build_sample_item;
+    my $patron  = $builder->build_object({ class => 'Koha::Patrons' });
+
+    t::lib::Mocks::mock_userenv({ branchcode => $library->branchcode });
+    t::lib::Mocks::mock_preference( 'UseRecalls', 1 );
+
+    C4::Circulation::AddIssue( $patron->unblessed, $item->barcode );
+
+    my ($recall) = Koha::Recalls->add_recall({ biblio => $item->biblio, item => $item, patron => $patron });
+
+    ok( !$item->has_pending_recall, 'The item has no pending recalls' );
+
+    $recall->status('waiting')->store;
+
+    ok( $item->has_pending_recall, 'The item has a pending recall' );
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'is_denied_renewal' => sub {
+    plan tests => 11;
+
+    $schema->storage->txn_begin;
+
+    my $library = $builder->build_object({ class => 'Koha::Libraries'});
+
+    my $deny_book = $builder->build_object({ class => 'Koha::Items', value => {
+        homebranch => $library->branchcode,
+        withdrawn => 1,
+        itype => 'HIDE',
+        location => 'PROC',
+        itemcallnumber => undef,
+        itemnotes => "",
+        }
+    });
+
+    my $allow_book = $builder->build_object({ class => 'Koha::Items', value => {
+        homebranch => $library->branchcode,
+        withdrawn => 0,
+        itype => 'NOHIDE',
+        location => 'NOPROC'
+        }
+    });
+
+    my $idr_rules = "";
+    C4::Context->set_preference('ItemsDeniedRenewal', $idr_rules);
+    is( $deny_book->is_denied_renewal, 0, 'Renewal allowed when no rules' );
+
+    $idr_rules="withdrawn: [1]";
+    C4::Context->set_preference('ItemsDeniedRenewal', $idr_rules);
+    is( $deny_book->is_denied_renewal, 1, 'Renewal blocked when 1 rules (withdrawn)' );
+    is( $allow_book->is_denied_renewal, 0, 'Renewal allowed when 1 rules not matched (withdrawn)' );
+
+    $idr_rules="withdrawn: [1]\nitype: [HIDE,INVISIBLE]";
+    is( $deny_book->is_denied_renewal, 1, 'Renewal blocked when 2 rules matched (withdrawn, itype)' );
+    is( $allow_book->is_denied_renewal, 0, 'Renewal allowed when 2 rules not matched (withdrawn, itype)' );
+
+    $idr_rules="withdrawn: [1]\nitype: [HIDE,INVISIBLE]\nlocation: [PROC]";
+    C4::Context->set_preference('ItemsDeniedRenewal', $idr_rules);
+    is( $deny_book->is_denied_renewal, 1, 'Renewal blocked when 3 rules matched (withdrawn, itype, location)' );
+    is( $allow_book->is_denied_renewal, 0, 'Renewal allowed when 3 rules not matched (withdrawn, itype, location)' );
+
+    $idr_rules="itemcallnumber: [NULL]";
+    C4::Context->set_preference('ItemsDeniedRenewal', $idr_rules);
+    is( $deny_book->is_denied_renewal, 1, 'Renewal blocked for undef when NULL in pref' );
+
+    $idr_rules="itemcallnumber: ['']";
+    C4::Context->set_preference('ItemsDeniedRenewal', $idr_rules);
+    is( $deny_book->is_denied_renewal, 0, 'Renewal not blocked for undef when "" in pref' );
+
+    $idr_rules="itemnotes: [NULL]";
+    C4::Context->set_preference('ItemsDeniedRenewal', $idr_rules);
+    is( $deny_book->is_denied_renewal, 0, 'Renewal not blocked for "" when NULL in pref' );
+
+    $idr_rules="itemnotes: ['']";
+    C4::Context->set_preference('ItemsDeniedRenewal', $idr_rules);
+    is( $deny_book->is_denied_renewal, 1, 'Renewal blocked for empty string when "" in pref' );
+
+    $schema->storage->txn_rollback;
+};