Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions directory/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1758,6 +1758,7 @@ components:
duplicateCheckWindowHours: { type: integer, format: int32, minimum: 0, nullable: true }
defaultLoanPeriod: { type: integer, format: int32, minimum: 1, nullable: true }
maxRequestsPerPatron: { type: integer, format: int32, minimum: 0, nullable: true }
minimumCost: { type: number, format: double, minimum: 0, nullable: true }

ImportCatalogConfig:
type: object
Expand Down Expand Up @@ -2242,6 +2243,12 @@ components:
nullable: true
minimum: 0
description: Maximum number of active requests allowed per patron. 0, omitted, or null disables the limit.
minimumCost:
type: number
format: double
nullable: true
minimum: 0
description: Minimum cost configured for ILL requests. Omitted or null means no minimum cost is configured.
iso18626Url:
type: string
description: URL of the ISO18626 service. When set, used instead of an ISO18626 service from endpoints.
Expand Down
13 changes: 13 additions & 0 deletions directory/api/catalog_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ func illConfigToDBParams(entryID uuid.UUID, cfg IllConfig) db.UpsertIllConfigPar
DuplicateCheckWindowHours: cfg.DuplicateCheckWindowHours,
DefaultLoanPeriod: nullableLoanPeriod(cfg),
MaxRequestsPerPatron: nullableMaxRequestsPerPatron(cfg),
MinimumCost: nullableMinimumCost(cfg),
}
if cfg.Iso18626Vendor != nil {
vendor := string(*cfg.Iso18626Vendor)
Expand All @@ -278,6 +279,7 @@ func illConfigPatchToDBParams(entryID uuid.UUID, cfg IllConfig, original db.IllC
DuplicateCheckWindowHours: original.DuplicateCheckWindowHours,
DefaultLoanPeriod: original.DefaultLoanPeriod,
MaxRequestsPerPatron: original.MaxRequestsPerPatron,
MinimumCost: original.MinimumCost,
}

params.IsPickupLocation = derefOrDefaultPtr(cfg.IsPickupLocation, params.IsPickupLocation)
Expand All @@ -303,6 +305,9 @@ func illConfigPatchToDBParams(entryID uuid.UUID, cfg IllConfig, original db.IllC
if cfg.MaxRequestsPerPatron.IsSpecified() {
params.MaxRequestsPerPatron = nullableMaxRequestsPerPatron(cfg)
}
if cfg.MinimumCost.IsSpecified() {
params.MinimumCost = nullableMinimumCost(cfg)
}
return params
}

Expand All @@ -322,6 +327,14 @@ func nullableMaxRequestsPerPatron(cfg IllConfig) *int32 {
return &value
}

func nullableMinimumCost(cfg IllConfig) *float64 {
value, err := cfg.MinimumCost.Get()
if err != nil {
return nil
}
return &value
}

func boolPtr(value bool) *bool {
return &value
}
Expand Down
3 changes: 2 additions & 1 deletion directory/api/entries.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,8 @@ func buildEntrySQL(whereClause string) string {
'supplierPatronPattern', i.supplier_patron_pattern,
'duplicateCheckWindowHours', i.duplicate_check_window_hours,
'defaultLoanPeriod', i.default_loan_period,
'maxRequestsPerPatron', i.max_requests_per_patron
'maxRequestsPerPatron', i.max_requests_per_patron,
'minimumCost', i.minimum_cost
)) FROM ill_configs i WHERE i.entry = e.id) as ill_config,
(
SELECT
Expand Down
24 changes: 23 additions & 1 deletion directory/api/loan_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,26 @@ func TestMaxRequestsPerPatronPatch(t *testing.T) {
assert.Nil(t, result.MaxRequestsPerPatron, "no implicit default")
}

func int32Pointer(value int32) *int32 { return &value }
func TestMinimumCostPatch(t *testing.T) {
original := 1.5
for _, tc := range []struct {
body string
want *float64
}{
{`{}`, &original},
{`{"minimumCost":null}`, nil},
{`{"minimumCost":0}`, float64Pointer(0)},
{`{"minimumCost":2.75}`, float64Pointer(2.75)},
} {
var config IllConfig
require.NoError(t, json.Unmarshal([]byte(tc.body), &config))
result := illConfigPatchToDBParams(uuid.New(), config, db.IllConfig{MinimumCost: &original})
assert.Equal(t, tc.want, result.MinimumCost)
}

result := illConfigToDBParams(uuid.New(), IllConfig{})
assert.Nil(t, result.MinimumCost, "no implicit default")
}

func int32Pointer(value int32) *int32 { return &value }
func float64Pointer(value float64) *float64 { return &value }
1 change: 1 addition & 0 deletions directory/import/db/entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@ func replaceILLConfig(ctx context.Context, queries *db.Queries, entryID uuid.UUI
DuplicateCheckWindowHours: config.DuplicateCheckWindowHours,
DefaultLoanPeriod: config.DefaultLoanPeriod,
MaxRequestsPerPatron: config.MaxRequestsPerPatron,
MinimumCost: config.MinimumCost,
})
return err
}
Expand Down
7 changes: 6 additions & 1 deletion directory/import/db/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ func TestImportEntryCreatesCompleteAggregateWithGeneratedIDs(t *testing.T) {
var maxRequestsPerPatron int32
require.NoError(t, testPool.QueryRow(context.Background(), `SELECT max_requests_per_patron FROM ill_configs WHERE entry=$1`, entryID).Scan(&maxRequestsPerPatron))
require.Zero(t, maxRequestsPerPatron)
var minimumCost float64
require.NoError(t, testPool.QueryRow(context.Background(), `SELECT minimum_cost FROM ill_configs WHERE entry=$1`, entryID).Scan(&minimumCost))
require.Equal(t, 1.25, minimumCost)
}

func TestImportEntryConflictPoliciesAndUpdateFullSynchronization(t *testing.T) {
Expand Down Expand Up @@ -1328,6 +1331,7 @@ func completeEntryAggregate(symbol string) model.EntryAggregate {
metadataMode := "replace"
truth := true
zero := int32(0)
minimumCost := 1.25
aggregate.Data.Endpoints = []model.ServiceEndpoint{{Name: "ISO", Type: "ISO18626", Address: "https://example.test/ill"}}
aggregate.Data.Addresses = []model.Address{{Type: "Default", Components: []model.AddressComponent{{Seq: 1, Type: "Locality", Value: "Riga"}}}}
aggregate.Data.Closures = []model.Closure{{StartDate: "2026-12-24", EndDate: "2026-12-26", Reason: "Holiday"}}
Expand All @@ -1339,7 +1343,8 @@ func completeEntryAggregate(symbol string) model.EntryAggregate {
HoldingsFormat: &model.HoldingsParserConfig{Marc: &model.MarcHoldingsParserConfig{MainField: &text}},
MetadataFormat: &model.MetadataParserConfig{Marc21: &model.MarcMetadataParserConfig{Title: &text}},
}
aggregate.Data.ILLConfig = &model.ILLConfig{IsPickupLocation: &truth, ISO18626URL: &text, LendersOfLastResort: []model.SymbolRef{}, IncludeSupplierInfo: &truth, MaxRequestsPerPatron: &zero}

aggregate.Data.ILLConfig = &model.ILLConfig{IsPickupLocation: &truth, ISO18626URL: &text, LendersOfLastResort: []model.SymbolRef{}, IncludeSupplierInfo: &truth, MaxRequestsPerPatron: &zero, MinimumCost: &minimumCost}
aggregate.Data.HoldingsPolicy = &model.HoldingsPolicy{
Locations: []model.HoldingsLocation{{Code: "MAIN", Name: "Main", SupplyPreference: 1}},
ShelvingLocations: []model.HoldingsShelvingLocation{},
Expand Down
1 change: 1 addition & 0 deletions directory/import/model/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type ILLConfig struct {
IsPickupLocation *bool `json:"isPickupLocation"`
DefaultLoanPeriod *int32 `json:"defaultLoanPeriod"`
MaxRequestsPerPatron *int32 `json:"maxRequestsPerPatron"`
MinimumCost *float64 `json:"minimumCost"`
ISO18626URL *string `json:"iso18626Url"`
ISO18626Vendor *string `json:"iso18626Vendor"`
LendersOfLastResort []SymbolRef `json:"lendersOfLastResort"`
Expand Down
4 changes: 3 additions & 1 deletion directory/import/service/importer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,14 +258,16 @@ func TestImportAcceptsMaxRequestsPerPatron(t *testing.T) {
require.NotNil(t, repo.entry.Data.ILLConfig)
require.NotNil(t, repo.entry.Data.ILLConfig.MaxRequestsPerPatron)
assert.Equal(t, int32(0), *repo.entry.Data.ILLConfig.MaxRequestsPerPatron)
require.NotNil(t, repo.entry.Data.ILLConfig.MinimumCost)
assert.Equal(t, 1.25, *repo.entry.Data.ILLConfig.MinimumCost)
}

func validLMSConfig() string {
return `"lmsConfig":{"vendor":null,"ncipNamespaceEnabled":null,"bibIdNormalization":null,"address":"https://example.test/ncip","fromAgency":"FROM","fromAgencyAuthentication":null,"toAgency":null,"lookupUserEnabled":true,"acceptItemEnabled":true,"checkInItemEnabled":true,"checkOutItemEnabled":true,"itemLocation":null,"requestItemRequestType":null,"requestItemRequestScopeType":null,"requestItemBibIdCode":null,"requestItemEnabled":true,"requestItemPickupLocationEnabled":true,"requesterPickupLocation":null,"supplierPickupLocation":null,"requesterPatronPattern":null,"patronProfiles":[{"code":"STAFF","canCreateRequests":true}]}`
}

func validILLConfig() string {
return `"illConfig":{"iso18626Url":null,"iso18626Vendor":null,"lendersOfLastResort":[],"includeRequestingAgencyInfo":null,"includeSupplierInfo":null,"includeReturnInfo":null,"includeVendorNote":null,"useOfferedCosts":null,"noteFieldSeparator":null,"supplierPatronPattern":null,"duplicateCheckWindowHours":null,"maxRequestsPerPatron":0}`
return `"illConfig":{"iso18626Url":null,"iso18626Vendor":null,"lendersOfLastResort":[],"includeRequestingAgencyInfo":null,"includeSupplierInfo":null,"includeReturnInfo":null,"includeVendorNote":null,"useOfferedCosts":null,"noteFieldSeparator":null,"supplierPatronPattern":null,"duplicateCheckWindowHours":null,"maxRequestsPerPatron":0,"minimumCost":1.25}`
}

func TestImportAccountsForSkippedAndRepositoryFailures(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions directory/migrations/013_minimum_cost.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE ill_configs DROP COLUMN minimum_cost;
2 changes: 2 additions & 0 deletions directory/migrations/013_minimum_cost.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE ill_configs
ADD COLUMN minimum_cost DOUBLE PRECISION CHECK (minimum_cost >= 0);
7 changes: 4 additions & 3 deletions directory/query.sql
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,13 @@ INSERT INTO ill_configs (
include_requesting_agency_info, include_supplier_info, include_return_info,
include_vendor_note, use_offered_costs, note_field_separator,
supplier_patron_pattern, duplicate_check_window_hours, default_loan_period,
max_requests_per_patron
max_requests_per_patron, minimum_cost
) VALUES (
@entry, @is_pickup_location, @iso18626_url, @iso18626_vendor, @lenders_of_last_resort,
@include_requesting_agency_info, @include_supplier_info, @include_return_info,
@include_vendor_note, @use_offered_costs, @note_field_separator,
@supplier_patron_pattern, @duplicate_check_window_hours, @default_loan_period,
@max_requests_per_patron
@max_requests_per_patron, @minimum_cost
)
ON CONFLICT (entry) DO UPDATE SET
is_pickup_location = COALESCE(@is_pickup_location, ill_configs.is_pickup_location),
Expand All @@ -180,7 +180,8 @@ ON CONFLICT (entry) DO UPDATE SET
supplier_patron_pattern = COALESCE(@supplier_patron_pattern, ill_configs.supplier_patron_pattern),
duplicate_check_window_hours = COALESCE(@duplicate_check_window_hours, ill_configs.duplicate_check_window_hours),
default_loan_period = @default_loan_period,
max_requests_per_patron = @max_requests_per_patron
max_requests_per_patron = @max_requests_per_patron,
minimum_cost = @minimum_cost
RETURNING *;

-- name: GetIllConfigByEntry :one
Expand Down
8 changes: 5 additions & 3 deletions directory/test/entries_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -903,7 +903,8 @@ func TestEntryDirectoryContractFieldsAndCatalogConfig(t *testing.T) {
"noteFieldSeparator":" | ",
"supplierPatronPattern":"PATRON-{requesterSymbol}",
"duplicateCheckWindowHours":24,
"maxRequestsPerPatron":0
"maxRequestsPerPatron":0,
"minimumCost":1.5
},
"symbols":[{"authority":"ISIL","symbol":"CONTRACT"}],
"catalogConfig":{
Expand Down Expand Up @@ -993,7 +994,8 @@ func TestEntryDirectoryContractFieldsAndCatalogConfig(t *testing.T) {
illConfig["noteFieldSeparator"] != " | " ||
illConfig["supplierPatronPattern"] != "PATRON-{requesterSymbol}" ||
illConfig["duplicateCheckWindowHours"] != float64(24) ||
illConfig["maxRequestsPerPatron"] != float64(0) {
illConfig["maxRequestsPerPatron"] != float64(0) ||
illConfig["minimumCost"] != 1.5 {
t.Fatalf("illConfig fields did not round-trip: %#v", illConfig)
}

Expand All @@ -1010,7 +1012,7 @@ func TestEntryDirectoryContractFieldsAndCatalogConfig(t *testing.T) {
t.Fatalf("failed to parse entry after illConfig PATCH: %v", err)
}
illConfig = entry["illConfig"].(map[string]any)
if illConfig["noteFieldSeparator"] != " / " || illConfig["useOfferedCosts"] != false || illConfig["iso18626Url"] != "https://iso.example.org/iso18626" || illConfig["maxRequestsPerPatron"] != float64(25) {
if illConfig["noteFieldSeparator"] != " / " || illConfig["useOfferedCosts"] != false || illConfig["iso18626Url"] != "https://iso.example.org/iso18626" || illConfig["maxRequestsPerPatron"] != float64(25) || illConfig["minimumCost"] != 1.5 {
t.Fatalf("partial illConfig PATCH did not merge fields: %#v", illConfig)
}
if _, ok := illConfig["lendersOfLastResort"]; ok {
Expand Down
25 changes: 25 additions & 0 deletions directory/test/loan_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,28 @@ func TestMaxRequestsPerPatronPersistenceAndValidation(t *testing.T) {
assert.Equal(t, tc.want, config["maxRequestsPerPatron"])
}
}

func TestMinimumCostPersistenceAndValidation(t *testing.T) {
resetDb()
headers := map[string]string{"X-Okapi-Tenant": "ANINST", "X-Okapi-Permissions": `["directory.consortium.all"]`}
path := "/entries/by-id/00000000-0000-0000-0000-000000000002"
for _, tc := range []struct {
value string
status int
want any
}{
{"0", http.StatusNoContent, float64(0)},
{"2.75", http.StatusNoContent, float64(2.75)},
{"-1", http.StatusBadRequest, float64(2.75)},
{"null", http.StatusNoContent, nil},
} {
response, body := jsonReq(t, http.MethodPatch, path, `{"illConfig":{"minimumCost":`+tc.value+`}}`, headers)
require.Equal(t, tc.status, response.StatusCode, body)
response, body = jsonReq(t, http.MethodGet, path, "", headers)
require.Equal(t, http.StatusOK, response.StatusCode, body)
var entry map[string]any
require.NoError(t, json.Unmarshal([]byte(body), &entry))
config := entry["illConfig"].(map[string]any)
assert.Equal(t, tc.want, config["minimumCost"])
}
}
Loading