diff --git a/broker/common/common.go b/broker/common/common.go index cd60018c..f8c6ba7e 100644 --- a/broker/common/common.go +++ b/broker/common/common.go @@ -40,6 +40,14 @@ func StructToMap(obj any) (map[string]any, error) { return result, nil } +func MapToStruct(obj map[string]any, v any) error { + b, err := json.Marshal(obj) + if err != nil { + return err + } + return json.Unmarshal(b, v) +} + func UnpackItemsNote(note string) ([][]string, int, int) { startIdx := strings.Index(note, MULTIPLE_ITEMS) endIdx := strings.Index(note, MULTIPLE_ITEMS_END) diff --git a/broker/common/common_test.go b/broker/common/common_test.go index cfaab9db..d8021778 100644 --- a/broker/common/common_test.go +++ b/broker/common/common_test.go @@ -85,6 +85,46 @@ func TestStructToMap(t *testing.T) { } } +func TestMapToStruct(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + want User + wantErr bool + }{ + { + name: "Basic map conversion", + input: map[string]interface{}{ + "id": float64(1), + "name": "Alice", + "Active": true, + }, + want: User{ID: 1, Name: &alice, Active: true}, + wantErr: false, + }, + { + name: "42 as string instead of int", + input: map[string]interface{}{"id": "42"}, + want: User{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got User + err := MapToStruct(tt.input, &got) + if (err != nil) != tt.wantErr { + t.Errorf("MapToStruct() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("MapToStruct() got = %v, want %v", got, tt.want) + } + }) + } +} + func TestUnpackItemsNote(t *testing.T) { // Just ID note := MULTIPLE_ITEMS + "\n1\n" + MULTIPLE_ITEMS_END diff --git a/broker/oapi/open-api.yaml b/broker/oapi/open-api.yaml index f1622594..9b58d81b 100644 --- a/broker/oapi/open-api.yaml +++ b/broker/oapi/open-api.yaml @@ -617,11 +617,11 @@ components: requesterActions: type: array items: - type: string + $ref: '#/components/schemas/ActionCapability' supplierActions: type: array items: - type: string + $ref: '#/components/schemas/ActionCapability' requesterMessageEvents: type: array items: @@ -637,7 +637,26 @@ components: - supplierActions - requesterMessageEvents - supplierMessageEvents - + ActionCapability: + title: ActionCapability + type: object + description: Definition of an action and its parameters supported by the broker. + Action parameters are defined separately from actions because the same parameters + can be used for multiple actions, for example note parameter is supported for + multiple actions but is not required for all of them. + additionalProperties: false + properties: + name: + type: string + description: Name of the action parameter + parameters: + type: array + description: List of parameter names for this action parameter + items: + type: string + required: + - name + - parameters ModelState: title: State type: object diff --git a/broker/patron_request/service/action.go b/broker/patron_request/service/action.go index 4e31a652..5a16d3c1 100644 --- a/broker/patron_request/service/action.go +++ b/broker/patron_request/service/action.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" "time" @@ -46,6 +47,14 @@ func (e *autoActionFailure) Error() string { return e.msg } +type actionParams struct { + Note string `json:"note,omitempty"` + LoanCondition string `json:"loanCondition,omitempty"` + Cost *float64 `json:"cost,omitempty"` + Currency string `json:"currency,omitempty"` + ReasonUnfilled string `json:"reasonUnfilled,omitempty"` +} + func CreatePatronRequestActionService(prRepo pr_db.PrRepo, eventBus events.EventBus, iso18626Handler handler.Iso18626HandlerInterface, lmsCreator lms.LmsCreator) *PatronRequestActionService { return &PatronRequestActionService{ prRepo: prRepo, @@ -267,7 +276,7 @@ func (a *PatronRequestActionService) handleBorrowingAction(ctx common.ExtendedCo } } -func (a *PatronRequestActionService) handleLenderAction(ctx common.ExtendedContext, action pr_db.PatronRequestAction, pr pr_db.PatronRequest, illRequest iso18626.Request, actionParams map[string]interface{}, eventID *string) actionExecutionResult { +func (a *PatronRequestActionService) handleLenderAction(ctx common.ExtendedContext, action pr_db.PatronRequestAction, pr pr_db.PatronRequest, illRequest iso18626.Request, actionCustomData map[string]any, eventID *string) actionExecutionResult { if !pr.SupplierSymbol.Valid { status, result := a.logErrorAndReturnResult(ctx, "missing supplier symbol", nil) return actionExecutionResult{status: status, result: result, pr: pr} @@ -291,21 +300,28 @@ func (a *PatronRequestActionService) handleLenderAction(ctx common.ExtendedConte ctx.Logger().Error("failed to create LMS log event", "error", createErr) } }) + + var params actionParams + err = common.MapToStruct(actionCustomData, ¶ms) + if err != nil { + status, result := a.logErrorAndReturnResult(ctx, "failed to unmarshal action parameters", err) + return actionExecutionResult{status: status, result: result, pr: pr} + } switch action { case LenderActionValidate: return a.validateLenderRequest(ctx, pr, lms) case LenderActionWillSupply: - return a.willSupplyLenderRequest(ctx, pr, lms, illRequest) + return a.willSupplyLenderRequest(ctx, pr, lms, illRequest, params) case LenderActionRejectCancel: return a.rejectCancelLenderRequest(ctx, pr) case LenderActionCannotSupply: - return a.cannotSupplyLenderRequest(ctx, pr) + return a.cannotSupplyLenderRequest(ctx, pr, params) case LenderActionAddCondition: - return a.addConditionsLenderRequest(ctx, pr, actionParams) + return a.addConditionsLenderRequest(ctx, pr, params) case LenderActionShip: - return a.shipLenderRequest(ctx, pr, lms, illRequest) + return a.shipLenderRequest(ctx, pr, lms, illRequest, params) case LenderActionMarkReceived: - return a.markReceivedLenderRequest(ctx, pr, lms, illRequest) + return a.markReceivedLenderRequest(ctx, pr, lms) case LenderActionAcceptCancel: return a.acceptCancelLenderRequest(ctx, pr) default: @@ -597,7 +613,7 @@ func (a *PatronRequestActionService) validateLenderRequest(ctx common.ExtendedCo return actionExecutionResult{status: events.EventStatusSuccess, pr: pr} } -func (a *PatronRequestActionService) willSupplyLenderRequest(ctx common.ExtendedContext, pr pr_db.PatronRequest, lmsAdapter lms.LmsAdapter, illRequest iso18626.Request) actionExecutionResult { +func (a *PatronRequestActionService) willSupplyLenderRequest(ctx common.ExtendedContext, pr pr_db.PatronRequest, lmsAdapter lms.LmsAdapter, illRequest iso18626.Request, params actionParams) actionExecutionResult { itemId := illRequest.BibliographicInfo.SupplierUniqueRecordId requestId := illRequest.Header.RequestingAgencyRequestId userId := lmsAdapter.InstitutionalPatron(pr.RequesterSymbol.String) @@ -625,28 +641,76 @@ func (a *PatronRequestActionService) willSupplyLenderRequest(ctx common.Extended return actionExecutionResult{status: status, result: result, pr: pr} } result := events.EventResult{} - status, eventResult, httpStatus := a.sendSupplyingAgencyMessage(ctx, pr, &result, iso18626.MessageInfo{ReasonForMessage: iso18626.TypeReasonForMessageStatusChange}, iso18626.StatusInfo{Status: iso18626.TypeStatusWillSupply}) + status, eventResult, httpStatus := a.sendSupplyingAgencyMessage(ctx, pr, &result, + iso18626.MessageInfo{ + ReasonForMessage: iso18626.TypeReasonForMessageStatusChange, + Note: params.Note, + }, + iso18626.StatusInfo{Status: iso18626.TypeStatusWillSupply}, + nil) return a.checkSupplyingResponse(status, eventResult, &result, httpStatus, pr) } -func (a *PatronRequestActionService) cannotSupplyLenderRequest(ctx common.ExtendedContext, pr pr_db.PatronRequest) actionExecutionResult { +func (a *PatronRequestActionService) cannotSupplyLenderRequest(ctx common.ExtendedContext, pr pr_db.PatronRequest, params actionParams) actionExecutionResult { result := events.EventResult{} - status, eventResult, httpStatus := a.sendSupplyingAgencyMessage(ctx, pr, &result, iso18626.MessageInfo{ReasonForMessage: iso18626.TypeReasonForMessageStatusChange}, iso18626.StatusInfo{Status: iso18626.TypeStatusUnfilled}) + var reasonUnfilled *iso18626.TypeSchemeValuePair + if params.ReasonUnfilled != "" { + reasonUnfilled = &iso18626.TypeSchemeValuePair{Text: params.ReasonUnfilled} + } + status, eventResult, httpStatus := a.sendSupplyingAgencyMessage(ctx, pr, &result, + iso18626.MessageInfo{ + ReasonForMessage: iso18626.TypeReasonForMessageStatusChange, + Note: params.Note, + ReasonUnfilled: reasonUnfilled, + }, + iso18626.StatusInfo{Status: iso18626.TypeStatusUnfilled}, + nil) return a.checkSupplyingResponse(status, eventResult, &result, httpStatus, pr) } -func (a *PatronRequestActionService) addConditionsLenderRequest(ctx common.ExtendedContext, pr pr_db.PatronRequest, actionParams map[string]interface{}) actionExecutionResult { +func (a *PatronRequestActionService) addConditionsLenderRequest(ctx common.ExtendedContext, pr pr_db.PatronRequest, params actionParams) actionExecutionResult { + var offeredCosts *iso18626.TypeCosts + if params.Cost != nil { + if params.Currency == "" { + status, result := a.logErrorAndReturnResult(ctx, "currency is required when cost is provided", nil) + return actionExecutionResult{status: status, result: result, pr: pr} + } + var monetaryValue utils.XSDDecimal + err := monetaryValue.UnmarshalText([]byte(strconv.FormatFloat(*params.Cost, 'f', -1, 64))) + if err != nil { + status, result := a.logErrorAndReturnResult(ctx, "failed to parse cost", err) + return actionExecutionResult{status: status, result: result, pr: pr} + } + offeredCosts = &iso18626.TypeCosts{ + CurrencyCode: iso18626.TypeSchemeValuePair{Text: params.Currency}, + MonetaryValue: monetaryValue, + } + } + var deliveryInfo *iso18626.DeliveryInfo + if params.LoanCondition != "" { + deliveryInfo = &iso18626.DeliveryInfo{ + LoanCondition: &iso18626.TypeSchemeValuePair{Text: params.LoanCondition}, + } + } result := events.EventResult{} + var note string + if params.Note == "" { + note = shim.RESHARE_ADD_LOAN_CONDITION + } else { + note = params.Note + "\n" + shim.RESHARE_ADD_LOAN_CONDITION + } status, eventResult, httpStatus := a.sendSupplyingAgencyMessage(ctx, pr, &result, iso18626.MessageInfo{ ReasonForMessage: iso18626.TypeReasonForMessageNotification, - Note: shim.RESHARE_ADD_LOAN_CONDITION, // TODO add action params + Note: note, + OfferedCosts: offeredCosts, }, - iso18626.StatusInfo{Status: iso18626.TypeStatusWillSupply}) + iso18626.StatusInfo{Status: iso18626.TypeStatusWillSupply}, + deliveryInfo) return a.checkSupplyingResponse(status, eventResult, &result, httpStatus, pr) } -func (a *PatronRequestActionService) shipLenderRequest(ctx common.ExtendedContext, pr pr_db.PatronRequest, lmsAdapter lms.LmsAdapter, illRequest iso18626.Request) actionExecutionResult { +func (a *PatronRequestActionService) shipLenderRequest(ctx common.ExtendedContext, pr pr_db.PatronRequest, lmsAdapter lms.LmsAdapter, illRequest iso18626.Request, params actionParams) actionExecutionResult { requestId := illRequest.Header.RequestingAgencyRequestId userId := lmsAdapter.InstitutionalPatron(pr.RequesterSymbol.String) externalReferenceValue := "" @@ -680,11 +744,20 @@ func (a *PatronRequestActionService) shipLenderRequest(ctx common.ExtendedContex } } } - note := encodeItemsNote(items) + var note string + if params.Note == "" { + note = encodeItemsNote(items) + } else { + note = params.Note + "\n" + encodeItemsNote(items) + } result := events.EventResult{} status, eventResult, httpStatus := a.sendSupplyingAgencyMessage(ctx, pr, &result, - iso18626.MessageInfo{ReasonForMessage: iso18626.TypeReasonForMessageStatusChange, Note: note}, - iso18626.StatusInfo{Status: iso18626.TypeStatusLoaned}) + iso18626.MessageInfo{ + ReasonForMessage: iso18626.TypeReasonForMessageStatusChange, + Note: note, + }, + iso18626.StatusInfo{Status: iso18626.TypeStatusLoaned}, + nil) return a.checkSupplyingResponse(status, eventResult, &result, httpStatus, pr) } @@ -704,7 +777,7 @@ func encodeItemsNote(items []pr_db.Item) string { return common.PackItemsNote(list) } -func (a *PatronRequestActionService) markReceivedLenderRequest(ctx common.ExtendedContext, pr pr_db.PatronRequest, lmsAdapter lms.LmsAdapter, illRequest iso18626.Request) actionExecutionResult { +func (a *PatronRequestActionService) markReceivedLenderRequest(ctx common.ExtendedContext, pr pr_db.PatronRequest, lmsAdapter lms.LmsAdapter) actionExecutionResult { items, err := a.getItems(ctx, pr) if err != nil { status, result := a.logErrorAndReturnResult(ctx, "no items for check-in in the request", err) @@ -718,7 +791,12 @@ func (a *PatronRequestActionService) markReceivedLenderRequest(ctx common.Extend } } result := events.EventResult{} - status, eventResult, httpStatus := a.sendSupplyingAgencyMessage(ctx, pr, &result, iso18626.MessageInfo{ReasonForMessage: iso18626.TypeReasonForMessageStatusChange}, iso18626.StatusInfo{Status: iso18626.TypeStatusLoanCompleted}) + status, eventResult, httpStatus := a.sendSupplyingAgencyMessage(ctx, pr, &result, + iso18626.MessageInfo{ + ReasonForMessage: iso18626.TypeReasonForMessageStatusChange, + }, + iso18626.StatusInfo{Status: iso18626.TypeStatusLoanCompleted}, + nil) return a.checkSupplyingResponse(status, eventResult, &result, httpStatus, pr) } @@ -730,7 +808,8 @@ func (a *PatronRequestActionService) rejectCancelLenderRequest(ctx common.Extend ReasonForMessage: iso18626.TypeReasonForMessageCancelResponse, AnswerYesNo: &no, }, - iso18626.StatusInfo{Status: iso18626.TypeStatusWillSupply}) + iso18626.StatusInfo{Status: iso18626.TypeStatusWillSupply}, + nil) return a.checkSupplyingResponse(status, eventResult, &result, httpStatus, pr) } @@ -742,11 +821,12 @@ func (a *PatronRequestActionService) acceptCancelLenderRequest(ctx common.Extend ReasonForMessage: iso18626.TypeReasonForMessageCancelResponse, AnswerYesNo: &yes, }, - iso18626.StatusInfo{Status: iso18626.TypeStatusCancelled}) + iso18626.StatusInfo{Status: iso18626.TypeStatusCancelled}, + nil) return a.checkSupplyingResponse(status, eventResult, &result, httpStatus, pr) } -func (a *PatronRequestActionService) sendSupplyingAgencyMessage(ctx common.ExtendedContext, pr pr_db.PatronRequest, result *events.EventResult, messageInfo iso18626.MessageInfo, statusInfo iso18626.StatusInfo) (events.EventStatus, *events.EventResult, *int) { +func (a *PatronRequestActionService) sendSupplyingAgencyMessage(ctx common.ExtendedContext, pr pr_db.PatronRequest, result *events.EventResult, messageInfo iso18626.MessageInfo, statusInfo iso18626.StatusInfo, deliveryInfo *iso18626.DeliveryInfo) (events.EventStatus, *events.EventResult, *int) { if !pr.RequesterSymbol.Valid { status, eventResult := a.logErrorAndReturnResult(ctx, "missing requester symbol", nil) return status, eventResult, nil @@ -773,8 +853,9 @@ func (a *PatronRequestActionService) sendSupplyingAgencyMessage(ctx common.Exten RequestingAgencyRequestId: pr.RequesterReqID.String, SupplyingAgencyRequestId: pr.ID, }, - MessageInfo: messageInfo, - StatusInfo: statusInfo, + MessageInfo: messageInfo, + StatusInfo: statusInfo, + DeliveryInfo: deliveryInfo, }, } if illMessage.SupplyingAgencyMessage.StatusInfo.LastChange.IsZero() { diff --git a/broker/patron_request/service/action_test.go b/broker/patron_request/service/action_test.go index c8f8bf4d..457c9b12 100644 --- a/broker/patron_request/service/action_test.go +++ b/broker/patron_request/service/action_test.go @@ -841,6 +841,13 @@ func TestHandleInvokeLenderActionWillSupplyUseIllTitleWhenRequestItemEmptyOK(t * assert.Equal(t, "1", mockPrRepo.savedItems[0].Barcode) assert.Equal(t, "2", mockPrRepo.savedItems[0].CallNumber.String) assert.Equal(t, "title1", mockPrRepo.savedItems[0].Title.String) + + if assert.NotNil(t, mockIso18626Handler.lastSupplyingAgencyMessage) { + assert.Equal(t, iso18626.TypeStatusWillSupply, mockIso18626Handler.lastSupplyingAgencyMessage.StatusInfo.Status) + assert.Equal(t, "", mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.Note) + assert.Nil(t, mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.OfferedCosts) + assert.Nil(t, mockIso18626Handler.lastSupplyingAgencyMessage.DeliveryInfo) + } } func TestHandleInvokeLenderActionWillSupplyUseRequestItemTitleWhenAvailableOK(t *testing.T) { @@ -854,7 +861,12 @@ func TestHandleInvokeLenderActionWillSupplyUseRequestItemTitleWhenAvailableOK(t illRequest := iso18626.Request{BibliographicInfo: iso18626.BibliographicInfo{Title: "title1"}} mockPrRepo.On("GetPatronRequestById", patronRequestId).Return(pr_db.PatronRequest{IllRequest: illRequest, State: LenderStateValidated, Side: SideLending, SupplierSymbol: getDbText("ISIL:SUP1"), RequesterSymbol: getDbText("ISIL:REQ1")}, nil) action := LenderActionWillSupply - status, resultData := prAction.handleInvokeAction(appCtx, events.Event{PatronRequestID: patronRequestId, EventData: events.EventData{CommonEventData: events.CommonEventData{Action: &action}}}) + status, resultData := prAction.handleInvokeAction(appCtx, events.Event{PatronRequestID: patronRequestId, EventData: events.EventData{ + CommonEventData: events.CommonEventData{Action: &action}, + CustomData: map[string]any{ + "note": "my note", + }, + }}) assert.Equal(t, events.EventStatusSuccess, status) assert.NotNil(t, resultData) @@ -863,6 +875,13 @@ func TestHandleInvokeLenderActionWillSupplyUseRequestItemTitleWhenAvailableOK(t assert.Equal(t, "1", mockPrRepo.savedItems[0].Barcode) assert.Equal(t, "2", mockPrRepo.savedItems[0].CallNumber.String) assert.Equal(t, "title2", mockPrRepo.savedItems[0].Title.String) + + if assert.NotNil(t, mockIso18626Handler.lastSupplyingAgencyMessage) { + assert.Equal(t, iso18626.TypeStatusWillSupply, mockIso18626Handler.lastSupplyingAgencyMessage.StatusInfo.Status) + assert.Equal(t, "my note", mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.Note) + assert.Nil(t, mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.OfferedCosts) + assert.Nil(t, mockIso18626Handler.lastSupplyingAgencyMessage.DeliveryInfo) + } } func TestHandleInvokeLenderActionRejectCancel(t *testing.T) { @@ -943,14 +962,56 @@ func TestHandleInvokeLenderActionCannotSupply(t *testing.T) { illRequest := iso18626.Request{} mockPrRepo.On("GetPatronRequestById", patronRequestId).Return(pr_db.PatronRequest{IllRequest: illRequest, State: LenderStateValidated, Side: SideLending, SupplierSymbol: getDbText("ISIL:SUP1"), RequesterSymbol: getDbText("ISIL:REQ1")}, nil) action := LenderActionCannotSupply - status, resultData := prAction.handleInvokeAction(appCtx, events.Event{PatronRequestID: patronRequestId, EventData: events.EventData{CommonEventData: events.CommonEventData{Action: &action}}}) + status, resultData := prAction.handleInvokeAction(appCtx, events.Event{PatronRequestID: patronRequestId, EventData: events.EventData{ + CommonEventData: events.CommonEventData{Action: &action}, + }}) + + assert.Equal(t, events.EventStatusSuccess, status) + assert.NotNil(t, resultData) + assert.Equal(t, LenderStateUnfilled, mockPrRepo.savedPr.State) + + if assert.NotNil(t, mockIso18626Handler.lastSupplyingAgencyMessage) { + assert.Equal(t, iso18626.TypeStatusUnfilled, mockIso18626Handler.lastSupplyingAgencyMessage.StatusInfo.Status) + assert.Equal(t, "", mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.Note) + assert.Nil(t, mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.OfferedCosts) + assert.Nil(t, mockIso18626Handler.lastSupplyingAgencyMessage.DeliveryInfo) + assert.Nil(t, mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.ReasonUnfilled) + } +} + +func TestHandleInvokeLenderActionCannotSupplyWithReason(t *testing.T) { + mockPrRepo := new(MockPrRepo) + lmsCreator := new(MockLmsCreator) + lmsCreator.On("GetAdapter", "ISIL:SUP1").Return(lms.CreateLmsAdapterMockOK(), nil) + mockIso18626Handler := new(MockIso18626Handler) + prAction := CreatePatronRequestActionService(mockPrRepo, *new(events.EventBus), mockIso18626Handler, lmsCreator) + illRequest := iso18626.Request{} + mockPrRepo.On("GetPatronRequestById", patronRequestId).Return(pr_db.PatronRequest{IllRequest: illRequest, State: LenderStateValidated, Side: SideLending, SupplierSymbol: getDbText("ISIL:SUP1"), RequesterSymbol: getDbText("ISIL:REQ1")}, nil) + action := LenderActionCannotSupply + status, resultData := prAction.handleInvokeAction(appCtx, events.Event{PatronRequestID: patronRequestId, EventData: events.EventData{ + CommonEventData: events.CommonEventData{Action: &action}, + CustomData: map[string]any{ + "note": "my note", + "reasonUnfilled": "my reason", + }, + }}) assert.Equal(t, events.EventStatusSuccess, status) assert.NotNil(t, resultData) assert.Equal(t, LenderStateUnfilled, mockPrRepo.savedPr.State) + + if assert.NotNil(t, mockIso18626Handler.lastSupplyingAgencyMessage) { + assert.Equal(t, iso18626.TypeStatusUnfilled, mockIso18626Handler.lastSupplyingAgencyMessage.StatusInfo.Status) + assert.Equal(t, "my note", mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.Note) + assert.Nil(t, mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.OfferedCosts) + assert.Nil(t, mockIso18626Handler.lastSupplyingAgencyMessage.DeliveryInfo) + if assert.NotNil(t, mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.ReasonUnfilled) { + assert.Equal(t, "my reason", mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.ReasonUnfilled.Text) + } + } } -func TestHandleInvokeLenderActionAddCondition(t *testing.T) { +func TestHandleInvokeLenderActionAddConditionOK(t *testing.T) { mockPrRepo := new(MockPrRepo) lmsCreator := new(MockLmsCreator) lmsCreator.On("GetAdapter", "ISIL:SUP1").Return(lms.CreateLmsAdapterMockOK(), nil) @@ -960,11 +1021,81 @@ func TestHandleInvokeLenderActionAddCondition(t *testing.T) { mockPrRepo.On("GetPatronRequestById", patronRequestId).Return(pr_db.PatronRequest{IllRequest: illRequest, State: LenderStateValidated, Side: SideLending, SupplierSymbol: getDbText("ISIL:SUP1"), RequesterSymbol: getDbText("ISIL:REQ1")}, nil) action := LenderActionAddCondition - status, resultData := prAction.handleInvokeAction(appCtx, events.Event{PatronRequestID: patronRequestId, EventData: events.EventData{CommonEventData: events.CommonEventData{Action: &action}}}) + status, resultData := prAction.handleInvokeAction(appCtx, events.Event{PatronRequestID: patronRequestId, EventData: events.EventData{ + CommonEventData: events.CommonEventData{Action: &action}, + }}) + assert.Equal(t, events.EventStatusSuccess, status) + assert.NotNil(t, resultData) + assert.Equal(t, LenderStateConditionPending, mockPrRepo.savedPr.State) + if assert.NotNil(t, mockIso18626Handler.lastSupplyingAgencyMessage) { + assert.Equal(t, iso18626.TypeStatusWillSupply, mockIso18626Handler.lastSupplyingAgencyMessage.StatusInfo.Status) + assert.Equal(t, "#ReShareAddLoanCondition#", mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.Note) + assert.Nil(t, mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.OfferedCosts) + assert.Nil(t, mockIso18626Handler.lastSupplyingAgencyMessage.DeliveryInfo) + } +} + +func TestHandleInvokeLenderActionAddConditionWithCurrency(t *testing.T) { + mockPrRepo := new(MockPrRepo) + lmsCreator := new(MockLmsCreator) + lmsCreator.On("GetAdapter", "ISIL:SUP1").Return(lms.CreateLmsAdapterMockOK(), nil) + mockIso18626Handler := new(MockIso18626Handler) + prAction := CreatePatronRequestActionService(mockPrRepo, *new(events.EventBus), mockIso18626Handler, lmsCreator) + illRequest := iso18626.Request{} + mockPrRepo.On("GetPatronRequestById", patronRequestId).Return(pr_db.PatronRequest{IllRequest: illRequest, State: LenderStateValidated, Side: SideLending, SupplierSymbol: getDbText("ISIL:SUP1"), RequesterSymbol: getDbText("ISIL:REQ1")}, nil) + action := LenderActionAddCondition + + status, resultData := prAction.handleInvokeAction(appCtx, events.Event{PatronRequestID: patronRequestId, EventData: events.EventData{ + CommonEventData: events.CommonEventData{Action: &action}, + CustomData: map[string]any{ + "loanCondition": "my condition", + "note": "Condition note", + "cost": 12.34, + "currency": "DKK", + }, + }}) assert.Equal(t, events.EventStatusSuccess, status) assert.NotNil(t, resultData) assert.Equal(t, LenderStateConditionPending, mockPrRepo.savedPr.State) + + if assert.NotNil(t, mockIso18626Handler.lastSupplyingAgencyMessage) { + assert.Equal(t, iso18626.TypeStatusWillSupply, mockIso18626Handler.lastSupplyingAgencyMessage.StatusInfo.Status) + assert.Equal(t, "Condition note\n#ReShareAddLoanCondition#", mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.Note) + if assert.NotNil(t, mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.OfferedCosts) { + assert.Equal(t, 1234, mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.OfferedCosts.MonetaryValue.Base) + assert.Equal(t, 2, mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.OfferedCosts.MonetaryValue.Exp) + assert.Equal(t, "DKK", mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.OfferedCosts.CurrencyCode.Text) + } + if assert.NotNil(t, mockIso18626Handler.lastSupplyingAgencyMessage.DeliveryInfo) { + assert.Equal(t, "my condition", mockIso18626Handler.lastSupplyingAgencyMessage.DeliveryInfo.LoanCondition.Text) + } + } +} + +func TestHandleInvokeLenderActionAddConditionMissingCurrency(t *testing.T) { + mockPrRepo := new(MockPrRepo) + lmsCreator := new(MockLmsCreator) + lmsCreator.On("GetAdapter", "ISIL:SUP1").Return(lms.CreateLmsAdapterMockOK(), nil) + mockIso18626Handler := new(MockIso18626Handler) + prAction := CreatePatronRequestActionService(mockPrRepo, *new(events.EventBus), mockIso18626Handler, lmsCreator) + illRequest := iso18626.Request{} + mockPrRepo.On("GetPatronRequestById", patronRequestId).Return(pr_db.PatronRequest{IllRequest: illRequest, State: LenderStateValidated, Side: SideLending, SupplierSymbol: getDbText("ISIL:SUP1"), RequesterSymbol: getDbText("ISIL:REQ1")}, nil) + action := LenderActionAddCondition + + status, resultData := prAction.handleInvokeAction(appCtx, events.Event{PatronRequestID: patronRequestId, EventData: events.EventData{ + CommonEventData: events.CommonEventData{Action: &action}, + CustomData: map[string]any{ + "loanCondition": "my condition", + "note": "Condition note", + "cost": 12.34, + }, + }}) + assert.Equal(t, events.EventStatusError, status) + assert.NotNil(t, resultData) + assert.Equal(t, LenderStateValidated, mockPrRepo.savedPr.State) + assert.Equal(t, "currency is required when cost is provided", resultData.EventError.Message) + assert.Nil(t, mockIso18626Handler.lastSupplyingAgencyMessage) } func TestHandleInvokeLenderActionShipOK(t *testing.T) { @@ -994,13 +1125,19 @@ func TestHandleInvokeLenderActionShipOK(t *testing.T) { action := LenderActionShip - status, resultData := prAction.handleInvokeAction(appCtx, events.Event{PatronRequestID: patronRequestId, EventData: events.EventData{CommonEventData: events.CommonEventData{Action: &action}}}) + status, resultData := prAction.handleInvokeAction(appCtx, events.Event{PatronRequestID: patronRequestId, EventData: events.EventData{ + CommonEventData: events.CommonEventData{Action: &action}, + CustomData: map[string]any{ + "note": "my note", + }, + }}) assert.Equal(t, events.EventStatusSuccess, status) assert.NotNil(t, resultData) assert.Equal(t, LenderStateShipped, mockPrRepo.savedPr.State) assert.Len(t, mockPrRepo.savedItems, 0) if assert.NotNil(t, mockIso18626Handler.lastSupplyingAgencyMessage) { assert.Equal(t, iso18626.TypeStatusLoaned, mockIso18626Handler.lastSupplyingAgencyMessage.StatusInfo.Status) + assert.Equal(t, "my note\n#MultipleItems#\n1234||\n5678||\n#MultipleItemsEnd#", mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.Note) assert.False(t, mockIso18626Handler.lastSupplyingAgencyMessage.StatusInfo.LastChange.IsZero()) if assert.NotNil(t, mockIso18626Handler.lastSupplyingAgencyMessage.DeliveryInfo) { assert.False(t, mockIso18626Handler.lastSupplyingAgencyMessage.DeliveryInfo.DateSent.IsZero()) @@ -1047,6 +1184,14 @@ func TestHandleInvokeLenderActionShipNewTitleOK(t *testing.T) { assert.Equal(t, "item2", mockPrRepo.savedItems[1].ID) assert.Equal(t, "5678", mockPrRepo.savedItems[1].Barcode) assert.Equal(t, "new title", mockPrRepo.savedItems[1].Title.String) + if assert.NotNil(t, mockIso18626Handler.lastSupplyingAgencyMessage) { + assert.Equal(t, iso18626.TypeStatusLoaned, mockIso18626Handler.lastSupplyingAgencyMessage.StatusInfo.Status) + assert.Equal(t, "#MultipleItems#\n1234||new title\n5678||new title\n#MultipleItemsEnd#", mockIso18626Handler.lastSupplyingAgencyMessage.MessageInfo.Note) + assert.False(t, mockIso18626Handler.lastSupplyingAgencyMessage.StatusInfo.LastChange.IsZero()) + if assert.NotNil(t, mockIso18626Handler.lastSupplyingAgencyMessage.DeliveryInfo) { + assert.False(t, mockIso18626Handler.lastSupplyingAgencyMessage.DeliveryInfo.DateSent.IsZero()) + } + } } func TestHandleInvokeLenderActionShipNewTitleFail(t *testing.T) { diff --git a/broker/patron_request/service/statemodel.go b/broker/patron_request/service/statemodel.go index 39565e50..a270235b 100644 --- a/broker/patron_request/service/statemodel.go +++ b/broker/patron_request/service/statemodel.go @@ -109,7 +109,7 @@ func ValidateStateModel(stateModel *proapi.StateModel) error { // Pass 2: validate actions/events and their transitions. for _, state := range stateModel.States { - var allowedActions []string + var allowedActions []proapi.ActionCapability var allowedEvents []string var allowedEventsSide string allowedTransitionTargets := definedStates[state.Side] @@ -124,7 +124,10 @@ func ValidateStateModel(stateModel *proapi.StateModel) error { } if state.Actions != nil { for _, action := range *state.Actions { - if !slices.Contains(allowedActions, action.Name) { + if !slices.ContainsFunc(allowedActions, + func(a proapi.ActionCapability) bool { + return a.Name == action.Name + }) { return fmt.Errorf("action %s in state %s is not a built-in %s action", action.Name, state.Name, strings.ToLower(string(state.Side))) } if err := validateActionTransitions(action, state.Name, allowedTransitionTargets); err != nil { diff --git a/broker/patron_request/service/statemodel_capabilities.go b/broker/patron_request/service/statemodel_capabilities.go index 717e2f6a..a81c797c 100644 --- a/broker/patron_request/service/statemodel_capabilities.go +++ b/broker/patron_request/service/statemodel_capabilities.go @@ -124,31 +124,81 @@ func supplierBuiltInStates() []string { }) } -func requesterBuiltInActions() []string { - return uniqueSorted([]string{ - string(BorrowerActionValidate), - string(BorrowerActionSendRequest), - string(BorrowerActionCancelRequest), - string(BorrowerActionAcceptCondition), - string(BorrowerActionRejectCondition), - string(BorrowerActionReceive), - string(BorrowerActionCheckOut), - string(BorrowerActionCheckIn), - string(BorrowerActionShipReturn), - }) +func requesterBuiltInActions() []proapi.ActionCapability { + return []proapi.ActionCapability{ + { + Name: string(BorrowerActionValidate), + }, + { + Name: string(BorrowerActionSendRequest), + }, + { + Name: string(BorrowerActionCancelRequest), + }, + { + Name: string(BorrowerActionAcceptCondition), + }, + { + Name: string(BorrowerActionRejectCondition), + }, + { + Name: string(BorrowerActionReceive), + }, + { + Name: string(BorrowerActionCheckOut), + }, + { + Name: string(BorrowerActionCheckIn), + }, + { + Name: string(BorrowerActionShipReturn), + }, + } } -func supplierBuiltInActions() []string { - return uniqueSorted([]string{ - string(LenderActionValidate), - string(LenderActionWillSupply), - string(LenderActionRejectCancel), - string(LenderActionCannotSupply), - string(LenderActionAddCondition), - string(LenderActionShip), - string(LenderActionMarkReceived), - string(LenderActionAcceptCancel), - }) +func supplierBuiltInActions() []proapi.ActionCapability { + return []proapi.ActionCapability{ + { + Name: string(LenderActionValidate), + }, + { + Name: string(LenderActionWillSupply), + Parameters: []string{ + "note", + }, + }, + { + Name: string(LenderActionRejectCancel), + }, + { + Name: string(LenderActionCannotSupply), + Parameters: []string{ + "reasonUnfilled", + "note", + }, + }, + { + Name: string(LenderActionAddCondition), + Parameters: []string{ + "note", + "loanCondition", + "cost", + "currency", + }, + }, + { + Name: string(LenderActionShip), + Parameters: []string{ + "note", + }, + }, + { + Name: string(LenderActionMarkReceived), + }, + { + Name: string(LenderActionAcceptCancel), + }, + } } func requesterBuiltInMessageEvents() []string { diff --git a/broker/patron_request/service/statemodel_test.go b/broker/patron_request/service/statemodel_test.go index c0f18e74..92c13fad 100644 --- a/broker/patron_request/service/statemodel_test.go +++ b/broker/patron_request/service/statemodel_test.go @@ -14,9 +14,21 @@ func TestBuiltInStateModelCapabilities(t *testing.T) { assert.True(t, slices.Contains(c.RequesterStates, string(BorrowerStateValidated))) assert.True(t, slices.Contains(c.SupplierStates, string(LenderStateValidated))) assert.True(t, slices.Contains(c.SupplierStates, string(LenderStateReceived))) - assert.True(t, slices.Contains(c.RequesterActions, string(BorrowerActionSendRequest))) - assert.True(t, slices.Contains(c.SupplierActions, string(LenderActionWillSupply))) - assert.True(t, slices.Contains(c.SupplierActions, string(LenderActionRejectCancel))) + + assert.True(t, slices.ContainsFunc(c.RequesterActions, func(a proapi.ActionCapability) bool { + return a.Name == string(BorrowerActionValidate) + })) + assert.True(t, slices.ContainsFunc(c.RequesterActions, func(a proapi.ActionCapability) bool { + return a.Name == string(BorrowerActionReceive) + })) + + assert.True(t, slices.ContainsFunc(c.SupplierActions, func(a proapi.ActionCapability) bool { + return a.Name == string(LenderActionWillSupply) + })) + assert.True(t, slices.ContainsFunc(c.SupplierActions, func(a proapi.ActionCapability) bool { + return a.Name == string(LenderActionWillSupply) && slices.Contains(a.Parameters, "note") + })) + assert.True(t, slices.Contains(c.SupplierMessageEvents, string(SupplierWillSupply))) assert.True(t, slices.Contains(c.RequesterMessageEvents, string(RequesterCancelRequest))) assert.True(t, slices.Contains(c.RequesterMessageEvents, string(RequesterReceived))) diff --git a/broker/test/patron_request/api/api-handler_test.go b/broker/test/patron_request/api/api-handler_test.go index c7022b7e..aa5bce88 100644 --- a/broker/test/patron_request/api/api-handler_test.go +++ b/broker/test/patron_request/api/api-handler_test.go @@ -567,7 +567,6 @@ func TestGetStateModelCapabilities(t *testing.T) { err := json.Unmarshal(respBytes, &capabilities) assert.NoError(t, err, "failed to unmarshal state model capabilities") assert.True(t, slices.Contains(capabilities.RequesterStates, string(prservice.BorrowerStateValidated))) - assert.True(t, slices.Contains(capabilities.SupplierActions, string(prservice.LenderActionRejectCancel))) assert.True(t, slices.Contains(capabilities.SupplierMessageEvents, string(prservice.SupplierWillSupply))) assert.True(t, slices.Contains(capabilities.RequesterMessageEvents, string(prservice.RequesterCancelRequest))) assert.True(t, slices.Contains(capabilities.RequesterMessageEvents, string(prservice.RequesterReceived)))