From bbea7402ab9a16a8c3d678ea05297cdac983322b Mon Sep 17 00:00:00 2001
From: Agustin Moyano <agustinmoyano@theke.io>
Date: Tue, 20 Oct 2020 13:31:58 -0300
Subject: [PATCH] Bug 15522: (Vue alternative) Modify api endpoint for circ
 rules

---
 Koha/CirculationRules.pm                      | 192 ++++++++++++-
 Koha/Exceptions/Authorization.pm              |   3 +
 Koha/REST/V1/CirculationRules.pm              |  53 ++++
 api/v1/swagger/definitions.json               |   3 +
 .../swagger/definitions/circ-rule-kind.json   |  24 ++
 api/v1/swagger/definitions/circ-rule.json     |  31 ++
 api/v1/swagger/paths.json                     |   3 +
 api/v1/swagger/paths/circulation-rules.json   | 132 ++++++++-
 api/v1/swagger/x-primitives.json              |   4 +
 t/db_dependent/api/v1/circ_rules.t            | 270 ++++++++++++++++++
 10 files changed, 709 insertions(+), 6 deletions(-)
 create mode 100644 api/v1/swagger/definitions/circ-rule.json
 create mode 100644 t/db_dependent/api/v1/circ_rules.t

diff --git a/Koha/CirculationRules.pm b/Koha/CirculationRules.pm
index 05850bee0f..216357e8ae 100644
--- a/Koha/CirculationRules.pm
+++ b/Koha/CirculationRules.pm
@@ -39,123 +39,283 @@ Koha::CirculationRules - Koha CirculationRule Object set class
 
 =head3 rule_kinds
 
-This structure describes the possible rules that may be set, and what scopes they can be set at.
+This structure describes the possible rules that may be set, what scopes they can be set at and any
+default values.
 
-Any attempt to set a rule with a nonsensical scope (for instance, setting the C<patron_maxissueqty> for a branchcode and itemtype), is an error.
+Any attempt to set a rule with a nonsensical scope (for instance, setting the C<patron_maxissueqty>
+for a branchcode and itemtype), is an error.
+
+These default values correspond to what would be stored in the database if nothing was changed on
+the old circulation rules editor; this is a combination of the default values in the template and
+the issuingrules table.
 
 =cut
 
 our $RULE_KINDS = {
     refund => {
         scope => [ 'branchcode' ],
+        default_value => 1,
+        group => 'fines',
+        description => "Refund lost item fee",
+        choices => [
+            [ 0, "No" ],
+            [ 1, "Yes" ],
+        ],
     },
 
     patron_maxissueqty => {
         scope => [ 'branchcode', 'categorycode' ],
+        default_value => "",
+        group => 'circulation',
+        description => "Total current checkouts allowed",
     },
     patron_maxonsiteissueqty => {
         scope => [ 'branchcode', 'categorycode' ],
+        default_value => "",
+        group => 'circulation',
+        description => "Total current on-site checkouts allowed",
     },
     max_holds => {
         scope => [ 'branchcode', 'categorycode' ],
+        default_value => "",
+        group => 'holds',
+        description => "Maximum total holds allowed",
     },
 
     holdallowed => {
         scope => [ 'branchcode', 'itemtype' ],
         can_be_blank => 0,
+        default_value => 2,
+        group => 'holds',
+        description => "Hold policy",
+        choices => [
+            [ 2, "From any library" ],
+            [ 1, "From home library" ],
+            [ 0, "No holds allowed" ],
+        ],
     },
     hold_fulfillment_policy => {
         scope => [ 'branchcode', 'itemtype' ],
         can_be_blank => 0,
+        default_value => 'any',
+        group => 'holds',
+        description => "Hold pickup library match",
+        choices => [
+            [ "any", "any library" ],
+            [ "homebranch", "item's home library" ],
+            [ "holdingbranch", "item's holding library" ],
+        ],
     },
     returnbranch => {
         scope => [ 'branchcode', 'itemtype' ],
         can_be_blank => 0,
+        default_value => 'homebranch',
+        group => 'circulation',
+        description => "Return policy",
+        choices => [
+            [ "homebranch", "Item returns home" ],
+            [ "holdingbranch", "Item returns to issuing library" ],
+            [ "noreturn", "Item floats" ],
+        ],
     },
 
     article_requests => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => 'no',
+        group => 'circulation',
+        description => "Article requests",
+        choices => [
+            [ "no", "No" ],
+            [ "yes", "Yes" ],
+            [ "bib_only", "Record only" ],
+            [ "item_only", "Item only" ],
+        ],
     },
     auto_renew => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => 0,
+        group => 'circulation',
+        description => "Automatic renewal",
+        choices => [
+            [ 0, "No" ],
+            [ 1, "Yes" ],
+        ],
     },
     cap_fine_to_replacement_price => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => 0,
+        group => 'fines',
+        description => "Cap fine at replacement price",
+        choices => [
+            [ 0, "No" ],
+            [ 1, "Yes" ],
+        ],
     },
     chargeperiod => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => 0,
+        group => 'fines',
+        description => "Fine charging interval",
     },
     chargeperiod_charge_at => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => 0,
+        group => 'circulation',
+        description => "When to charge",
+        choices => [
+            [ 0, "End of interval" ],
+            [ 1, "Start of interval" ],
+        ],
     },
     fine => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => 0,
+        group => 'fines',
+        description => "Fine amount",
     },
     finedays => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => 0,
+        group => 'fines',
+        description => "Suspension in days",
     },
     firstremind => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => 0,
+        group => 'fines',
+        description => "Fine grace period",
     },
     hardduedate => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => "",
+        group => 'circulation',
+        description => "Hard due date",
+        type => "date",
     },
     hardduedatecompare => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => -1,
+        group => 'circulation',
+        description => "Hard due date type",
+        choices => [
+            [ -1, "Before" ],
+            [ 0, "Exactly on" ],
+            [ 1, "After" ],
+        ],
     },
     holds_per_day => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        group => 'holds',
     },
     holds_per_record => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => 1,
+        group => 'holds',
+        description => "Holds per record (count)",
     },
     issuelength => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => 0,
+        group => 'circulation',
+        description => "Loan period",
     },
     daysmode => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
     },
     lengthunit => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => 'days',
+        group => 'circulation',
+        description => "Loan period unit",
     },
-    maxissueqty => {
+    patron_maxissueqty => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => "",
+        group => 'circulation',
+        description => "Current checkouts allowed",
     },
-    maxonsiteissueqty => {
+    patron_maxonsiteissueqty => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => "",
+        group => 'circulation',
+        description => "Current on-site checkouts allowed",
     },
     maxsuspensiondays => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => "",
+        group => 'fines',
+        description => "Max. suspension duration (days)",
     },
     no_auto_renewal_after => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => "",
+        group => 'circulation',
+        description => "No automatic renewal after",
     },
     no_auto_renewal_after_hard_limit => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => "",
+        group => 'circulation',
+        description => "No automatic renewal after (hard limit)",
+        type => "date",
     },
     norenewalbefore => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => "",
+        group => 'circulation',
+        description => "No renewal before",
     },
     onshelfholds => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => 0,
+        group => 'holds',
+        description => "On shelf holds allowed",
+        choices => [
+            [ 1, "Yes" ],
+            [ 0, "If any unavailable" ],
+            [ 2, "If all unavailable" ],
+        ],
     },
     opacitemholds => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => "N",
+        group => 'holds',
+        description => "Item-level holds",
+        choices => [
+            [ "N", "Don't allow" ],
+            [ "Y", "Allow" ],
+            [ "F", "Force" ],
+        ],
     },
     overduefinescap => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => "",
+        group => 'fines',
+        description => "Overdue fines cap (amount)",
     },
     renewalperiod => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => 0,
+        group => 'circulation',
+        description => "Renewal period",
     },
     renewalsallowed => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => 0,
+        group => 'circulation',
+        description => "Renewals allowed (count)",
     },
     rentaldiscount => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => 0,
+        group => 'circulation',
+        description => "Rental discount (%)",
     },
     reservesallowed => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
+        default_value => 0,
+        group => 'holds',
+        description => "Holds allowed (count)",
     },
     suspension_chargeperiod => {
         scope => [ 'branchcode', 'categorycode', 'itemtype' ],
@@ -249,6 +409,25 @@ sub get_effective_rules {
 
 =head3 set_rule
 
+=over 4
+Koha::CirculationRules->set_rule( {
+    branchcode => $branchcode,
+    [ categorycode => $categorycode, ]
+    [ itemtype => $itemtype, ]
+    rule_name => $rule_name,
+    rule_value => $rule_value,
+    [ allow_null_out_of_scope => 1, ]
+} )
+=back
+
+Sets a single circulation rule.
+
+It is an error to specify a C<categorycode> or C<itemtype> unless the given C<rule_name> can be set
+at that scope; i.e., you cannot set a C<patron_maxissueqty> for an itemtype.
+
+If C<allow_null_out_of_scope> is passed, and any excess C<categorycode> or C<itemtype> is set to C<undef>,
+they are ignored.
+
 =cut
 
 sub set_rule {
@@ -272,7 +451,10 @@ sub set_rule {
                 unless exists $params->{$scope_level};
         } else {
             croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
-                if exists $params->{$scope_level};
+                unless !exists $params->{$scope_level} || (
+                    $params->{allow_null_out_of_scope} &&
+                    !defined $params->{$scope_level}
+                );
         }
     }
 
diff --git a/Koha/Exceptions/Authorization.pm b/Koha/Exceptions/Authorization.pm
index a3c625aca8..56c9d8b0cb 100644
--- a/Koha/Exceptions/Authorization.pm
+++ b/Koha/Exceptions/Authorization.pm
@@ -12,6 +12,9 @@ use Exception::Class (
         description => 'Unauthorized',
         fields => ['required_permissions']
     },
+    'Koha::Exceptions::Authorization::Restricted' => {
+        description => 'User can only modify settings for their branch'
+    },
 
 );
 
diff --git a/Koha/REST/V1/CirculationRules.pm b/Koha/REST/V1/CirculationRules.pm
index baa04b5211..2f30b12f65 100644
--- a/Koha/REST/V1/CirculationRules.pm
+++ b/Koha/REST/V1/CirculationRules.pm
@@ -19,7 +19,12 @@ use Modern::Perl;
 
 use Mojo::Base 'Mojolicious::Controller';
 
+use C4::Auth;
+use C4::Context;
+
 use Koha::CirculationRules;
+use Koha::Database;
+use Koha::Exceptions::Authorization;
 
 use Try::Tiny;
 
@@ -42,4 +47,52 @@ sub get_kinds {
     );
 }
 
+sub get_rules {
+    my $c = shift->openapi->valid_input or return;
+
+    return $c->render(
+        status => 200,
+        openapi => [ Koha::CirculationRules->search ],
+    );
+}
+
+sub save_rules {
+    my $c = shift->openapi->valid_input or return;
+
+    my $schema = Koha::Database->new->schema;
+
+    my $uid = $c->stash( 'koha.user' )->userid;
+    my $restricted_to_library = $uid && haspermission( $uid, { parameters => 'manage_circ_rules_from_any_libraries' } ) ? "" : $c->stash( 'koha.user' )->branchcode;
+
+    return try {
+        my $rules = $c->req->json;
+
+        $schema->storage->txn_do( sub {
+            foreach my $rule ( @$rules ) {
+                if ( $restricted_to_library && ( !$rule->{branchcode} || $rule->{branchcode} ne $restricted_to_library ) ) {
+                    Koha::Exceptions::Authorization::Restricted->throw(
+                        error => 'User can only modify settings for their branch.'
+                    );
+                }
+
+                Koha::CirculationRules->set_rule( { %$rule, allow_null_out_of_scope => 1 } );
+            }
+        } );
+
+        return $c->render( status => 200, openapi => "" );
+    }
+    catch {
+        if ( $_->isa('Koha::Exceptions::Authorization::Restricted') ) {
+            return $c->render( status  => 403,
+                               openapi => { error => $_->message } );
+        } else {
+            warn $_;
+
+            return $c->render( status => 500,
+                openapi => { error => "Something went wrong, check the logs."} );
+        }
+    };
+
+}
+
 1;
diff --git a/api/v1/swagger/definitions.json b/api/v1/swagger/definitions.json
index 61e6c15306..20ec77940a 100644
--- a/api/v1/swagger/definitions.json
+++ b/api/v1/swagger/definitions.json
@@ -5,6 +5,9 @@
   "basket": {
     "$ref": "definitions/basket.json"
   },
+  "circ-rule": {
+    "$ref": "definitions/circ-rule.json"
+  },
   "circ-rule-kind": {
     "$ref": "definitions/circ-rule-kind.json"
   },
diff --git a/api/v1/swagger/definitions/circ-rule-kind.json b/api/v1/swagger/definitions/circ-rule-kind.json
index 90b956f9f6..cdfa08b015 100644
--- a/api/v1/swagger/definitions/circ-rule-kind.json
+++ b/api/v1/swagger/definitions/circ-rule-kind.json
@@ -8,6 +8,30 @@
         "type": "string",
         "enum": [ "branchcode", "categorycode", "itemtype" ]
       }
+    },
+    "default_value": {
+      "description": "effective default if no rules are set",
+      "type": [ "string", "null" ]
+    },
+    "can_be_blank": {
+      "description": "if rule can be blank",
+      "type": [ "boolean", "null"]
+    },
+    "group": {
+      "description": "which group this rule belongs to",
+      "type": "string"
+    },
+    "description": {
+      "description": "text describing this rule",
+      "type": "string"
+    },
+    "choices": {
+      "description": "predefined set of values this rule can be",
+      "type": "array"
+    },
+    "type": {
+      "description": "type of rule data",
+      "type": "string"
     }
   },
   "additionalProperties": false,
diff --git a/api/v1/swagger/definitions/circ-rule.json b/api/v1/swagger/definitions/circ-rule.json
new file mode 100644
index 0000000000..3125fecd10
--- /dev/null
+++ b/api/v1/swagger/definitions/circ-rule.json
@@ -0,0 +1,31 @@
+{
+  "type": "object",
+  "properties": {
+    "id": {
+      "description": "internal identifier of this rule",
+      "type": "integer"
+    },
+    "branchcode": {
+      "type": [ "string", "null" ],
+      "description": "branch that this rule applies to"
+    },
+    "categorycode": {
+      "type": [ "string", "null" ],
+      "description": "category that this rule applies to"
+    },
+    "itemtype": {
+      "type": [ "string", "null" ],
+      "description": "itemtype that this rule applies to"
+    },
+    "rule_name": {
+      "type": "string",
+      "description": "name of the kind of this rule"
+    },
+    "rule_value": {
+      "type": [ "string", "null" ],
+      "description": "value of the rule"
+    }
+  },
+  "additionalProperties": false,
+  "required": [ "branchcode", "rule_name", "rule_value" ]
+}
diff --git a/api/v1/swagger/paths.json b/api/v1/swagger/paths.json
index 8ba95189dc..31869b7d5b 100644
--- a/api/v1/swagger/paths.json
+++ b/api/v1/swagger/paths.json
@@ -26,6 +26,9 @@
   "/checkouts/{checkout_id}/renewal": {
     "$ref": "paths/checkouts.json#/~1checkouts~1{checkout_id}~1renewal"
   },
+  "/circulation-rules": {
+    "$ref": "paths/circulation-rules.json#/~1circulation-rules"
+  },
   "/circulation-rules/kinds": {
     "$ref": "paths/circulation-rules.json#/~1circulation-rules~1kinds"
   },
diff --git a/api/v1/swagger/paths/circulation-rules.json b/api/v1/swagger/paths/circulation-rules.json
index 0f118d344e..f772a70a5c 100644
--- a/api/v1/swagger/paths/circulation-rules.json
+++ b/api/v1/swagger/paths/circulation-rules.json
@@ -1,9 +1,122 @@
 {
+  "/circulation-rules": {
+    "get": {
+      "x-mojo-to": "CirculationRules#get_rules",
+      "operationId": "getCirculationRules",
+      "tags": ["circulation-rules"],
+      "produces": [
+        "application/json"
+      ],
+      "responses": {
+        "200": {
+          "description": "A list of defined rules",
+          "schema": {
+            "type": "array",
+            "items": {
+              "$ref": "../definitions.json#/circ-rule"
+            }
+          }
+        },
+        "400": {
+          "description": "Bad request",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "403": {
+          "description": "Access forbidden",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "500": {
+          "description": "Internal error",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "503": {
+          "description": "Under maintenance",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        }
+      },
+      "x-koha-authorization": {
+        "permissions": {
+          "manage_circ_rules": "1"
+        }
+      }
+    },
+    "post": {
+      "x-mojo-to": "CirculationRules#save_rules",
+      "operationId": "saveCirculationRules",
+      "tags": ["circulation-rules"],
+      "produces": [
+        "application/json"
+      ],
+      "parameters": [{
+        "name": "body",
+        "in": "body",
+        "description": "A JSON object containing rules to set; null for a rule_value means to delete the rule",
+        "required": true,
+        "schema": {
+          "type": "array",
+          "items": {
+            "$ref": "../definitions.json#/circ-rule"
+          }
+        }
+      }],
+      "responses": {
+        "200": {
+          "description": "Rules saved",
+          "schema": {
+            "type": "string"
+          }
+        },
+        "400": {
+          "description": "Bad request",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "401": {
+          "description": "Authentication required",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "403": {
+          "description": "Access forbidden",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "500": {
+          "description": "Internal error",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
+        "503": {
+          "description": "Under maintenance",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        }
+      },
+      "x-koha-authorization": {
+        "permissions": {
+          "manage_circ_rules": "1"
+        }
+      }
+    }
+  },
   "/circulation-rules/kinds": {
     "get": {
       "x-mojo-to": "CirculationRules#get_kinds",
       "operationId": "getCirculationRuleKinds",
-      "tags": ["cities"],
+      "tags": ["circulation-rules"],
       "produces": [
         "application/json"
       ],
@@ -17,6 +130,12 @@
             }
           }
         },
+        "400": {
+          "description": "Bad request",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        },
         "403": {
           "description": "Access forbidden",
           "schema": {
@@ -28,6 +147,17 @@
           "schema": {
             "$ref": "../definitions.json#/error"
           }
+        },
+        "503": {
+          "description": "Under maintenance",
+          "schema": {
+            "$ref": "../definitions.json#/error"
+          }
+        }
+      },
+      "x-koha-authorization": {
+        "permissions": {
+          "manage_circ_rules": "1"
         }
       }
     }
diff --git a/api/v1/swagger/x-primitives.json b/api/v1/swagger/x-primitives.json
index 0594264be9..dbc234913f 100644
--- a/api/v1/swagger/x-primitives.json
+++ b/api/v1/swagger/x-primitives.json
@@ -18,6 +18,10 @@
     "maxLength": 10,
     "minLength": 1
   },
+  "branchcode": {
+    "type": "string",
+    "description": "library assigned branch identifier"
+  },
   "cardnumber": {
     "type": ["string", "null"],
     "description": "library assigned user identifier"
diff --git a/t/db_dependent/api/v1/circ_rules.t b/t/db_dependent/api/v1/circ_rules.t
new file mode 100644
index 0000000000..24bbb3cd72
--- /dev/null
+++ b/t/db_dependent/api/v1/circ_rules.t
@@ -0,0 +1,270 @@
+#!/usr/bin/env perl
+
+# This file is part of Koha.
+#
+# Koha is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3 of the License, or (at your option) any later
+# version.
+#
+# Koha is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with Koha; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+use Modern::Perl;
+
+use Test::More tests => 5;
+use Test::Deep qw( cmp_deeply set superhashof );
+use Test::Mojo;
+use Test::Warn;
+
+use t::lib::TestBuilder;
+use t::lib::Mocks;
+
+use C4::Auth;
+use Koha::Acquisition::Booksellers;
+use Koha::CirculationRules;
+use Koha::Database;
+
+my $schema  = Koha::Database->new->schema;
+my $builder = t::lib::TestBuilder->new;
+
+# FIXME: sessionStorage defaults to mysql, but it seems to break transaction handling
+# this affects the other REST api tests
+t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' );
+
+my $remote_address = '127.0.0.1';
+my $t              = Test::Mojo->new('Koha::REST::V1');
+
+sub tx_maker {
+    my $args = shift;
+
+    my ( $borrowernumber, $session_id ) = create_user_and_session( $args );
+
+    return sub {
+        my $tx = shift->ua->build_tx( @_ );
+        $tx->req->cookies( { name => 'CGISESSID', value => $session_id } );
+        $tx->req->env( { REMOTE_ADDR => $remote_address } );
+
+        return $tx;
+    }
+}
+
+subtest 'rule_kinds' => sub {
+    plan tests => 3;
+
+    my $tx_maker = tx_maker( { permissions => 'authorized' } );
+
+    my $tx = $tx_maker->( $t, GET => '/api/v1/circulation-rules/kinds' );
+    $t->request_ok($tx)
+      ->status_is(200)
+      ->json_is( Koha::CirculationRules->rule_kinds );
+};
+
+subtest 'get_rules' => sub {
+    plan tests => 6;
+
+    $schema->storage->txn_begin;
+
+    Koha::CirculationRules->search->delete;
+
+    my $tx_maker = tx_maker( { permissions => 'authorized' } );
+
+    my $tx = $tx_maker->( $t, GET => '/api/v1/circulation-rules' );
+    $t->request_ok($tx)
+      ->status_is(200)
+      ->json_is( [] );
+
+    my $branchcode = $builder->build({ source => 'Branch' })->{'branchcode'};
+    my @rules = (
+        {
+            branchcode => undef,
+            categorycode => undef,
+            itemtype => undef,
+            rule_name => 'maxissueqty',
+            rule_value => 10,
+        },
+        {
+            branchcode => $branchcode,
+            categorycode => undef,
+            itemtype => undef,
+            rule_name => 'maxissueqty',
+            rule_value => 20,
+        },
+    );
+
+    Koha::CirculationRules->set_rule( $_ ) for ( @rules );
+
+    $tx = $tx_maker->( $t, GET => '/api/v1/circulation-rules' );
+    $t->request_ok($tx)
+      ->status_is(200);
+    cmp_deeply( $tx->res->json, set( map { superhashof( $_ ) } @rules ), 'rules retrieved correctly' );
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'set_rules | unauthorized' => sub {
+    plan tests => 2;
+
+    $schema->storage->txn_begin;
+
+    Koha::CirculationRules->search->delete;
+
+    my $tx_maker = tx_maker( { permissions => 'none' } );
+
+    my $rule = {
+        branchcode => undef,
+        categorycode => undef,
+        itemtype => undef,
+        rule_name => 'maxissueqty',
+        rule_value => 10,
+    };
+
+    my $tx = $tx_maker->( $t, POST => '/api/v1/circulation-rules', json => $rule );
+    $t->request_ok($tx)
+      ->status_is(403);
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'set_rules' => sub {
+    plan tests => 3;
+
+    $schema->storage->txn_begin;
+
+    Koha::CirculationRules->search->delete;
+
+    my $tx_maker = tx_maker( { permissions => 'authorized' } );
+
+    my @rules = (
+        {
+            branchcode => undef,
+            categorycode => undef,
+            itemtype => undef,
+            rule_name => 'maxissueqty',
+            rule_value => 10,
+        },
+    );
+
+    my $tx = $tx_maker->( $t, POST => '/api/v1/circulation-rules', json => \@rules );
+    $t->request_ok($tx)
+      ->status_is(200);
+
+    my $stored_rule = Koha::CirculationRules->search( {
+        branchcode => undef,
+        categorycode => undef,
+        itemtype => undef,
+    } )->next;
+
+    cmp_deeply(
+        $stored_rule && $stored_rule->unblessed,
+        superhashof( $rules[0] ),
+        'rule stored correctly'
+    );
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'set_rules | restricted' => sub {
+    plan tests => 6;
+
+    $schema->storage->txn_begin;
+
+    Koha::CirculationRules->search->delete;
+
+    my $branchcode = $builder->build({ source => 'Branch' })->{'branchcode'};
+    my $other_branchcode = $builder->build({ source => 'Branch' })->{'branchcode'};
+    my $tx_maker = tx_maker( { permissions => 'authorized_restricted', branchcode => $branchcode } );
+
+    my @allowed_rules = (
+        {
+            branchcode => $branchcode,
+            categorycode => undef,
+            itemtype => undef,
+            rule_name => 'maxissueqty',
+            rule_value => 10,
+        },
+    );
+
+    my @forbidden_rules = (
+        {
+            branchcode => undef,
+            categorycode => undef,
+            itemtype => undef,
+            rule_name => 'maxissueqty',
+            rule_value => 10,
+        },
+        {
+            branchcode => $other_branchcode,
+            categorycode => undef,
+            itemtype => undef,
+            rule_name => 'maxissueqty',
+            rule_value => 20,
+        },
+    );
+
+    for my $rule ( @allowed_rules ) {
+        my $tx = $tx_maker->( $t, POST => '/api/v1/circulation-rules', json => [ $rule ] );
+        $t->request_ok($tx)
+          ->status_is(200);
+    }
+
+    for my $rule ( @forbidden_rules ) {
+        my $tx = $tx_maker->( $t, POST => '/api/v1/circulation-rules', json => [ $rule ] );
+        $t->request_ok($tx)
+          ->status_is(403);
+    }
+
+    $schema->storage->txn_rollback;
+};
+
+sub create_user_and_session {
+
+    my $args = shift;
+    my $flags = {
+        none => 0,
+        # catalogue and parameters permissions
+        authorized => (1 << 2) | (1 << 3),
+        # Just catalogue
+        authorized_restricted => (1 << 2),
+    }->{ $args->{permissions} || ( $args->{authorized} ? 'authorized' : 'none' ) };
+
+    my %branch_flags;
+    $branch_flags{branchcode} = $args->{branchcode} if ( $args->{branchcode} );
+
+    # my $flags = ( $args->{authorized} ) ? $args->{authorized} : 0;
+    my $dbh = C4::Context->dbh;
+
+    my $user = $builder->build(
+        {   source => 'Borrower',
+            value  => { flags => $flags, %branch_flags }
+        }
+    );
+
+    # Create a session for the authorized user
+    my $session = C4::Auth::get_session('');
+    $session->param( 'number',   $user->{borrowernumber} );
+    $session->param( 'id',       $user->{userid} );
+    $session->param( 'ip',       '127.0.0.1' );
+    $session->param( 'lasttime', time() );
+    $session->flush;
+
+    # Allow circulation rules management for librarian's home library, but not
+    # other libraries
+    if ( $args->{permissions} eq 'authorized_restricted' ) {
+        $dbh->do(
+            q{
+            INSERT INTO user_permissions (borrowernumber,module_bit,code)
+            VALUES (?,3,'manage_circ_rules')},
+            undef, $user->{borrowernumber}, $user->{borrowernumber}
+        );
+    }
+
+    return ( $user->{borrowernumber}, $session->id );
+}
+
+1;
-- 
2.25.0