From c229d491ba5743d3a4dc3fd4a76a26704a5b14e5 Mon Sep 17 00:00:00 2001 From: dinama Date: Thu, 19 Nov 2020 22:51:23 +0300 Subject: [PATCH] FF-219: wapi getP2PTransferEvents via thrift backend (#322) (#336) --- apps/ff_cth/src/ct_helper.erl | 10 +- apps/ff_server/src/ff_p2p_session_codec.erl | 11 + apps/ff_server/src/ff_p2p_session_handler.erl | 9 + .../test/ff_p2p_transfer_handler_SUITE.erl | 9 + apps/wapi/src/wapi_access_backend.erl | 4 +- apps/wapi/src/wapi_p2p_template_backend.erl | 36 +- apps/wapi/src/wapi_p2p_transfer_backend.erl | 596 +++++++++++++++++- apps/wapi/src/wapi_w2w_backend.erl | 4 +- apps/wapi/src/wapi_wallet_thrift_handler.erl | 22 + apps/wapi/test/wapi_SUITE.erl | 34 +- .../test/wapi_p2p_transfer_tests_SUITE.erl | 73 ++- apps/wapi/test/wapi_thrift_SUITE.erl | 197 ++---- apps/wapi/test/wapi_w2w_tests_SUITE.erl | 6 +- apps/wapi/test/wapi_wallet_dummy_data.hrl | 29 + .../src/wapi_woody_client.erl | 6 +- config/sys.config | 16 +- rebar.config | 5 + rebar.lock | 29 +- 18 files changed, 860 insertions(+), 236 deletions(-) diff --git a/apps/ff_cth/src/ct_helper.erl b/apps/ff_cth/src/ct_helper.erl index 4475374..1632ddf 100644 --- a/apps/ff_cth/src/ct_helper.erl +++ b/apps/ff_cth/src/ct_helper.erl @@ -117,7 +117,8 @@ start_app(wapi = AppName) -> validation_opts => #{ custom_validator => wapi_swagger_validator } - }} + }}, + {events_fetch_limit, 32} ]), #{}}; start_app(wapi_woody_client = AppName) -> @@ -129,10 +130,11 @@ start_app(wapi_woody_client = AppName) -> fistful_wallet => "http://localhost:8022/v1/wallet", fistful_identity => "http://localhost:8022/v1/identity", fistful_destination => "http://localhost:8022/v1/destination", - w2w_transfer => "http://localhost:8022/v1/w2w_transfer", - p2p_transfer => "http://localhost:8022/v1/p2p_transfer", fistful_withdrawal => "http://localhost:8022/v1/withdrawal", - fistful_p2p_template => "http://localhost:8022/v1/p2p_template" + fistful_w2w_transfer => "http://localhost:8022/v1/w2w_transfer", + fistful_p2p_template => "http://localhost:8022/v1/p2p_template", + fistful_p2p_transfer => "http://localhost:8022/v1/p2p_transfer", + fistful_p2p_session => "http://localhost:8022/v1/p2p_transfer/session" }}, {service_retries, #{ fistful_stat => #{ diff --git a/apps/ff_server/src/ff_p2p_session_codec.erl b/apps/ff_server/src/ff_p2p_session_codec.erl index c616ca0..efee20d 100644 --- a/apps/ff_server/src/ff_p2p_session_codec.erl +++ b/apps/ff_server/src/ff_p2p_session_codec.erl @@ -8,6 +8,7 @@ -export([marshal_state/2]). +-export([marshal_event/1]). -export([marshal/2]). -export([unmarshal/2]). @@ -26,6 +27,16 @@ marshal_state(State, Context) -> context = marshal(ctx, Context) }. +-spec marshal_event(p2p_transfer_machine:event()) -> + ff_proto_p2p_session_thrift:'Event'(). + +marshal_event({EventID, {ev, Timestamp, Change}}) -> + #p2p_session_Event{ + event = ff_codec:marshal(event_id, EventID), + occured_at = ff_codec:marshal(timestamp, Timestamp), + change = marshal(change, Change) + }. + -spec marshal(ff_codec:type_name(), ff_codec:decoded_value()) -> ff_codec:encoded_value(). diff --git a/apps/ff_server/src/ff_p2p_session_handler.erl b/apps/ff_server/src/ff_p2p_session_handler.erl index 578f73c..339b430 100644 --- a/apps/ff_server/src/ff_p2p_session_handler.erl +++ b/apps/ff_server/src/ff_p2p_session_handler.erl @@ -41,4 +41,13 @@ handle_function_('GetContext', [ID], _Opts) -> {ok, ff_codec:marshal(context, Context)}; {error, {unknown_p2p_session, _Ref}} -> woody_error:raise(business, #fistful_P2PSessionNotFound{}) + end; + +handle_function_('GetEvents', [ID, EventRange], _Opts) -> + ok = scoper:add_meta(#{id => ID}), + case p2p_session_machine:events(ID, ff_codec:unmarshal(event_range, EventRange)) of + {ok, Events} -> + {ok, lists:map(fun ff_p2p_session_codec:marshal_event/1, Events)}; + {error, {unknown_p2p_session, _Ref}} -> + woody_error:raise(business, #fistful_P2PSessionNotFound{}) end. diff --git a/apps/ff_server/test/ff_p2p_transfer_handler_SUITE.erl b/apps/ff_server/test/ff_p2p_transfer_handler_SUITE.erl index 96102e7..a6a895b 100644 --- a/apps/ff_server/test/ff_p2p_transfer_handler_SUITE.erl +++ b/apps/ff_server/test/ff_p2p_transfer_handler_SUITE.erl @@ -15,6 +15,7 @@ -export([end_per_testcase/2]). %% Tests +-export([get_p2p_session_events_ok_test/1]). -export([get_p2p_session_context_ok_test/1]). -export([get_p2p_session_ok_test/1]). -export([create_adjustment_ok_test/1]). @@ -40,6 +41,7 @@ all() -> groups() -> [ {default, [parallel], [ + get_p2p_session_events_ok_test, get_p2p_session_context_ok_test, get_p2p_session_ok_test, create_adjustment_ok_test, @@ -92,6 +94,13 @@ end_per_testcase(_Name, _C) -> %% Tests +-spec get_p2p_session_events_ok_test(config()) -> test_return(). +get_p2p_session_events_ok_test(C) -> + #{ + session_id := ID + } = prepare_standard_environment(C), + {ok, [#p2p_session_Event{change = {created, _}} | _Rest]} = call_p2p_session('GetEvents', [ID, #'EventRange'{}]). + -spec get_p2p_session_context_ok_test(config()) -> test_return(). get_p2p_session_context_ok_test(C) -> #{ diff --git a/apps/wapi/src/wapi_access_backend.erl b/apps/wapi/src/wapi_access_backend.erl index adea469..eb1d841 100644 --- a/apps/wapi/src/wapi_access_backend.erl +++ b/apps/wapi/src/wapi_access_backend.erl @@ -88,7 +88,7 @@ get_context_by_id(p2p_template, TemplateID, WoodyCtx) -> {error, notfound} end; get_context_by_id(w2w_transfer, W2WTransferID, WoodyCtx) -> - Request = {w2w_transfer, 'GetContext', [W2WTransferID]}, + Request = {fistful_w2w_transfer, 'GetContext', [W2WTransferID]}, case wapi_handler_utils:service_call(Request, WoodyCtx) of {ok, Context} -> Context; @@ -96,7 +96,7 @@ get_context_by_id(w2w_transfer, W2WTransferID, WoodyCtx) -> {error, notfound} end; get_context_by_id(p2p_transfer, P2PTransferID, WoodyCtx) -> - Request = {p2p_transfer, 'GetContext', [P2PTransferID]}, + Request = {fistful_p2p_transfer, 'GetContext', [P2PTransferID]}, case wapi_handler_utils:service_call(Request, WoodyCtx) of {ok, Context} -> Context; diff --git a/apps/wapi/src/wapi_p2p_template_backend.erl b/apps/wapi/src/wapi_p2p_template_backend.erl index e4e7b64..c18b5ec 100644 --- a/apps/wapi/src/wapi_p2p_template_backend.erl +++ b/apps/wapi/src/wapi_p2p_template_backend.erl @@ -192,19 +192,24 @@ quote_transfer(ID, Params, HandlerContext) -> {error, {token, _}} | {error, {external_id_conflict, _}}. -create_transfer(ID, #{quote_token := Token} = Params, HandlerContext) -> +create_transfer(ID, #{<<"quoteToken">> := Token} = Params, HandlerContext) -> case uac_authorizer_jwt:verify(Token, #{}) of {ok, {_, _, VerifiedToken}} -> case decode_and_validate_token_payload(VerifiedToken, ID, HandlerContext) of {ok, Quote} -> - create_transfer(ID, Params#{<<"quote">> => Quote}, HandlerContext); + do_create_transfer(ID, Params#{<<"quote">> => Quote}, HandlerContext); {error, token_expired} -> - {error, {token, expired}} + {error, {token, expired}}; + {error, Error} -> + {error, {token, {not_verified, Error}}} end; {error, Error} -> {error, {token, {not_verified, Error}}} end; create_transfer(ID, Params, HandlerContext) -> + do_create_transfer(ID, Params, HandlerContext). + +do_create_transfer(ID, Params, HandlerContext) -> case wapi_access_backend:check_resource_by_id(p2p_template, ID, HandlerContext) of ok -> TransferID = context_transfer_id(HandlerContext), @@ -212,7 +217,7 @@ create_transfer(ID, Params, HandlerContext) -> ok -> MarshaledContext = marshal_context(wapi_backend_utils:make_ctx(Params, HandlerContext)), MarshaledParams = marshal_transfer_params(Params#{<<"id">> => TransferID}), - create_transfer(ID, MarshaledParams, MarshaledContext, HandlerContext); + call_create_transfer(ID, MarshaledParams, MarshaledContext, HandlerContext); {error, {external_id_conflict, _}} = Error -> Error end; @@ -221,7 +226,8 @@ create_transfer(ID, Params, HandlerContext) -> {error, notfound} -> {error, {p2p_template, notfound}} end. -create_transfer(ID, MarshaledParams, MarshaledContext, HandlerContext) -> + +call_create_transfer(ID, MarshaledParams, MarshaledContext, HandlerContext) -> Request = {fistful_p2p_template, 'CreateTransfer', [ID, MarshaledParams, MarshaledContext]}, case wapi_handler_utils:service_call(Request, HandlerContext) of {ok, Transfer} -> @@ -466,10 +472,10 @@ validate_transfer_id(TransferID, Params, #{woody_context := WoodyContext} = Hand validate_identity_id(IdentityID, TemplateID, HandlerContext) -> case get(TemplateID, HandlerContext) of - {ok, #{identity_id := IdentityID}} -> + {ok, #{<<"identityID">> := IdentityID}} -> ok; - {ok, _ } -> - {error, {token, {not_verified, identity_mismatch}}}; + {ok, _Template} -> + {error, identity_mismatch}; Error -> Error end. @@ -591,23 +597,27 @@ marshal_transfer_params(#{ marshal_sender(#{ <<"token">> := Token, + <<"authData">> := AuthData, <<"contactInfo">> := ContactInfo }) -> - Resource = case wapi_crypto:decrypt_bankcard_token(Token) of + ResourceBankCard = case wapi_crypto:decrypt_bankcard_token(Token) of unrecognized -> BankCard = wapi_utils:base64url_to_map(Token), - {bank_card, #'ResourceBankCard'{ + #'ResourceBankCard'{ bank_card = #'BankCard'{ token = maps:get(<<"token">>, BankCard), bin = maps:get(<<"bin">>, BankCard), masked_pan = maps:get(<<"lastDigits">>, BankCard) } - }}; + }; {ok, BankCard} -> - {bank_card, #'ResourceBankCard'{bank_card = BankCard}} + #'ResourceBankCard'{bank_card = BankCard} end, + ResourceBankCardAuth = ResourceBankCard#'ResourceBankCard'{ + auth_data = {session_data, #'SessionAuthData'{id = AuthData}} + }, {resource, #p2p_transfer_RawResource{ - resource = Resource, + resource = {bank_card, ResourceBankCardAuth}, contact_info = marshal_contact_info(ContactInfo) }}. diff --git a/apps/wapi/src/wapi_p2p_transfer_backend.erl b/apps/wapi/src/wapi_p2p_transfer_backend.erl index f4ad2c3..b1f5dec 100644 --- a/apps/wapi/src/wapi_p2p_transfer_backend.erl +++ b/apps/wapi/src/wapi_p2p_transfer_backend.erl @@ -35,13 +35,31 @@ | {p2p_transfer, notfound} . +-type error_get_events() + :: error_get() + | {token, {unsupported_version, _}} + | {token, {not_verified, _}} + . + -export([create_transfer/2]). --export([quote_transfer/2]). -export([get_transfer/2]). +-export([quote_transfer/2]). +-export([get_transfer_events/3]). -import(ff_pipeline, [do/1, unwrap/1]). +-include_lib("fistful_proto/include/ff_proto_base_thrift.hrl"). -include_lib("fistful_proto/include/ff_proto_p2p_transfer_thrift.hrl"). +-include_lib("fistful_proto/include/ff_proto_p2p_session_thrift.hrl"). + +-define(DEFAULT_EVENTS_LIMIT, 50). +-define(CONTINUATION_TRANSFER, <<"p2p_transfer_event_id">>). +-define(CONTINUATION_SESSION, <<"p2p_session_event_id">>). + +-type event() :: #p2p_transfer_Event{} | #p2p_session_Event{}. +-type event_service() :: fistful_p2p_transfer | fistful_p2p_session. +-type event_range() :: #'EventRange'{}. +-type event_id() :: ff_proto_base_thrift:'EventID'() | undefined. -spec create_transfer(req_data(), handler_context()) -> {ok, response_data()} | {error, error_create()}. @@ -63,7 +81,7 @@ create_transfer(Params = #{<<"identityID">> := IdentityID}, HandlerContext) -> -spec get_transfer(req_data(), handler_context()) -> {ok, response_data()} | {error, error_get()}. get_transfer(ID, HandlerContext) -> - Request = {p2p_transfer, 'Get', [ID, #'EventRange'{}]}, + Request = {fistful_p2p_transfer, 'Get', [ID, #'EventRange'{}]}, case service_call(Request, HandlerContext) of {ok, TransferThrift} -> case wapi_access_backend:check_resource(p2p_transfer, TransferThrift, HandlerContext) of @@ -89,10 +107,23 @@ quote_transfer(Params = #{<<"identityID">> := IdentityID}, HandlerContext) -> {error, {identity, notfound}} end. +-spec get_transfer_events(id(), binary() | undefined, handler_context()) -> + {ok, response_data()} | {error, error_get_events()}. + +get_transfer_events(ID, Token, HandlerContext) -> + case wapi_access_backend:check_resource_by_id(p2p_transfer, ID, HandlerContext) of + ok -> + do_get_events(ID, Token, HandlerContext); + {error, unauthorized} -> + {error, {p2p_transfer, unauthorized}}; + {error, notfound} -> + {error, {p2p_transfer, notfound}} + end. + %% Internal do_quote_transfer(Params, HandlerContext) -> - Request = {p2p_transfer, 'GetQuote', [marshal_quote_params(Params)]}, + Request = {fistful_p2p_transfer, 'GetQuote', [marshal_quote_params(Params)]}, case service_call(Request, HandlerContext) of {ok, Quote} -> PartyID = wapi_handler_utils:get_owner(HandlerContext), @@ -125,7 +156,7 @@ do_create_transfer(ID, Params, HandlerContext) -> do(fun() -> Context = wapi_backend_utils:make_ctx(Params, HandlerContext), TransferParams = unwrap(build_transfer_params(Params#{<<"id">> => ID})), - Request = {p2p_transfer, 'Create', [TransferParams, marshal(context, Context)]}, + Request = {fistful_p2p_transfer, 'Create', [TransferParams, marshal(context, Context)]}, unwrap(process_p2p_transfer_call(Request, HandlerContext)) end). @@ -174,6 +205,202 @@ authorize_p2p_quote_token(_Quote, _IdentityID) -> service_call(Params, HandlerContext) -> wapi_handler_utils:service_call(Params, HandlerContext). +%% @doc +%% The function returns the list of events for the specified Transfer. +%% +%% First get Transfer for extract the Session ID. +%% +%% Then, the Continuation Token is verified. Latest EventIDs of Transfer and +%% Session are stored in the token for possibility partial load of events. +%% +%% The events are retrieved no lesser ID than those stored in the token, and count +%% is limited by wapi.events_fetch_limit option or ?DEFAULT_EVENTS_LIMIT +%% +%% The received events are then mixed and ordered by the time of occurrence. +%% The resulting set is returned to the client. +%% +%% @todo Now there is always only zero or one session. But there may be more than one +%% session in the future, so the code of polling sessions and mixing results +%% will need to be rewrited. + +-spec do_get_events(id(), binary() | undefined, handler_context()) -> + {ok, response_data()} | {error, error_get_events()}. + +do_get_events(ID, Token, HandlerContext) -> + do(fun() -> + PartyID = wapi_handler_utils:get_owner(HandlerContext), + SessionID = unwrap(request_session_id(ID, HandlerContext)), + + DecodedToken = unwrap(continuation_token_unpack(Token, PartyID)), + PrevTransferCursor = continuation_token_cursor(p2p_transfer, DecodedToken), + PrevSessionCursor = continuation_token_cursor(p2p_session, DecodedToken), + + {TransferEvents, TransferCursor} = unwrap(events_collect( + fistful_p2p_transfer, + ID, + events_range(PrevTransferCursor), + HandlerContext, + [] + )), + + {SessionEvents, SessionCursor} = unwrap(events_collect( + fistful_p2p_session, + SessionID, + events_range(PrevSessionCursor), + HandlerContext, + [] + )), + + NewTransferCursor = events_max(PrevTransferCursor, TransferCursor), + NewSessionCursor = events_max(PrevSessionCursor, SessionCursor), + NewToken = unwrap(continuation_token_pack(NewTransferCursor, NewSessionCursor, PartyID)), + + Events = {NewToken, events_merge([TransferEvents, SessionEvents])}, + unmarshal_events(Events) + end). + +%% get p2p_transfer from backend and return last sesssion ID + +-spec request_session_id(id(), handler_context()) -> + {ok, undefined | id ()} | {error, {p2p_transfer, notfound}}. + +request_session_id(ID, HandlerContext) -> + Request = {fistful_p2p_transfer, 'Get', [ID, #'EventRange'{}]}, + case service_call(Request, HandlerContext) of + {ok, #p2p_transfer_P2PTransferState{sessions = []}} -> + {ok, undefined}; + {ok, #p2p_transfer_P2PTransferState{sessions = Sessions}} -> + Session = lists:last(Sessions), + {ok, Session#p2p_transfer_SessionState.id}; + {exception, #fistful_P2PNotFound{}} -> + {error, {p2p_transfer, notfound}} + end. + +%% create and code a new continuation token + +continuation_token_pack(TransferCursor, SessionCursor, PartyID) -> + Token = genlib_map:compact(#{ + <<"version">> => 1, + ?CONTINUATION_TRANSFER => TransferCursor, + ?CONTINUATION_SESSION => SessionCursor + }), + uac_authorizer_jwt:issue(wapi_utils:get_unique_id(), PartyID, Token, wapi_auth:get_signee()). + +%% verify, decode and check version of continuation token + +continuation_token_unpack(undefined, _PartyID) -> + {ok, #{}}; +continuation_token_unpack(Token, PartyID) -> + case uac_authorizer_jwt:verify(Token, #{}) of + {ok, {_, PartyID, #{<<"version">> := 1} = VerifiedToken}} -> + {ok, VerifiedToken}; + {ok, {_, PartyID, #{<<"version">> := Version}}} -> + {error, {token, {unsupported_version, Version}}}; + {ok, {_, WrongPatryID, _}} when WrongPatryID /= PartyID -> + {error, {token, {not_verified, wrong_party_id}}}; + {error, Error} -> + {error, {token, {not_verified, Error}}} + end. + +%% get cursor event id by entity + +continuation_token_cursor(p2p_transfer, DecodedToken) -> + maps:get(?CONTINUATION_TRANSFER, DecodedToken, undefined); +continuation_token_cursor(p2p_session, DecodedToken) -> + maps:get(?CONTINUATION_SESSION, DecodedToken, undefined). + +%% collect events from EventService backend + +-spec events_collect(event_service(), id() | undefined, event_range(), handler_context(), Acc0) -> + {ok, {Acc1, event_id()}} | {error, {p2p_transfer, notfound}} when + Acc0 :: [] | [event()], + Acc1 :: [] | [event()]. + +events_collect(fistful_p2p_session, undefined, #'EventRange'{'after' = Cursor}, _HandlerContext, Acc) -> + % no session ID is not an error + {ok, {Acc, Cursor}}; +events_collect(_EventService, _EntityID, #'EventRange'{'after' = Cursor, 'limit' = Limit}, _HandlerContext, Acc) + when Limit =< 0 -> + % Limit < 0 < undefined + {ok, {Acc, Cursor}}; +events_collect(EventService, EntityID, EventRange, HandlerContext, Acc) -> + #'EventRange'{'after' = Cursor, limit = Limit} = EventRange, + Request = {EventService, 'GetEvents', [EntityID, EventRange]}, + case events_request(Request, HandlerContext) of + {ok, {_Received, [], undefined}} -> + % the service has not returned any events, the previous cursor must be kept + {ok, {Acc, Cursor}}; + {ok, {Received, Events, NewCursor}} when Received < Limit -> + % service returned less events than requested + % or Limit is 'undefined' and service returned all events + {ok, {Acc ++ Events, NewCursor}}; + {ok, {_Received, Events, NewCursor}} -> + % Limit is reached but some events can be filtered out + NewEventRange = events_range(NewCursor, Limit - length(Events)), + events_collect(EventService, EntityID, NewEventRange, HandlerContext, Acc ++ Events); + {error, _} = Error -> + Error + end. + +-spec events_request(Request, handler_context()) -> + {ok, {integer(), [] | [event()], event_id()}} | {error, {p2p_transfer, notfound}} when + Request :: {event_service(), 'GetEvents', [id() | event_range()]}. + +events_request(Request, HandlerContext) -> + case service_call(Request, HandlerContext) of + {ok, []} -> + {ok, {0, [], undefined}}; + {ok, EventsThrift} -> + Cursor = events_cursor(lists:last(EventsThrift)), + Events = lists:filter(fun events_filter/1, EventsThrift), + {ok, {length(EventsThrift), Events, Cursor}}; + {exception, #fistful_P2PNotFound{}} -> + {error, {p2p_transfer, notfound}}; + {exception, #fistful_P2PSessionNotFound{}} -> + % P2PSessionNotFound not found - not error + {ok, {0, [], undefined}} + end. + +events_filter(#p2p_transfer_Event{change = {status_changed, _}}) -> + true; +events_filter(#p2p_session_Event{change = {ui, #p2p_session_UserInteractionChange{payload = Payload}}}) -> + case Payload of + {status_changed, #p2p_session_UserInteractionStatusChange{ + status = {pending, _} + }} -> + false; + _Other -> + % {created ...} + % {status_changed, ... status = {finished, ...}} + % take created & finished user interaction events + true + end; +events_filter(_Event) -> + false. + +events_merge(EventsList) -> + lists:sort(fun(Ev1, Ev2) -> events_timestamp(Ev1) < events_timestamp(Ev2) end, lists:append(EventsList)). + +events_cursor(#p2p_transfer_Event{event = ID}) -> + ID; +events_cursor(#p2p_session_Event{event = ID}) -> + ID. + +events_timestamp(#p2p_transfer_Event{occured_at = OccuredAt}) -> + OccuredAt; +events_timestamp(#p2p_session_Event{occured_at = OccuredAt}) -> + OccuredAt. + +events_range(CursorID) -> + events_range(CursorID, genlib_app:env(wapi, events_fetch_limit, ?DEFAULT_EVENTS_LIMIT)). +events_range(CursorID, Limit) -> + #'EventRange'{'after' = CursorID, 'limit' = Limit}. + +events_max(NewEventID, OldEventID) when is_integer(NewEventID) andalso is_integer(OldEventID) -> + erlang:max(NewEventID, OldEventID); +events_max(NewEventID, OldEventID) -> + genlib:define(NewEventID, OldEventID). + %% Marshal marshal_quote_params(#{ @@ -224,23 +451,27 @@ marshal_transfer_params(#{ marshal_sender(#{ <<"token">> := Token, + <<"authData">> := AuthData, <<"contactInfo">> := ContactInfo }) -> - Resource = case wapi_crypto:decrypt_bankcard_token(Token) of + ResourceBankCard = case wapi_crypto:decrypt_bankcard_token(Token) of unrecognized -> BankCard = wapi_utils:base64url_to_map(Token), - {bank_card, #'ResourceBankCard'{ + #'ResourceBankCard'{ bank_card = #'BankCard'{ token = maps:get(<<"token">>, BankCard), bin = maps:get(<<"bin">>, BankCard), masked_pan = maps:get(<<"lastDigits">>, BankCard) } - }}; + }; {ok, BankCard} -> - {bank_card, #'ResourceBankCard'{bank_card = BankCard}} + #'ResourceBankCard'{bank_card = BankCard} end, + ResourceBankCardAuth = ResourceBankCard#'ResourceBankCard'{ + auth_data = {session_data, #'SessionAuthData'{id = AuthData}} + }, {resource, #p2p_transfer_RawResource{ - resource = Resource, + resource = {bank_card, ResourceBankCardAuth}, contact_info = marshal_contact_info(ContactInfo) }}. @@ -377,6 +608,94 @@ unmarshal_transfer_status({failed, #p2p_status_Failed{failure = Failure}}) -> <<"failure">> => unmarshal(failure, Failure) }. +unmarshal_events({Token, Events}) -> + #{ + <<"continuationToken">> => unmarshal(string, Token), + <<"result">> => [unmarshal_event(Ev) || Ev <- Events] + }. + +unmarshal_event(#p2p_transfer_Event{ + occured_at = OccuredAt, + change = Change +}) -> + #{ + <<"createdAt">> => unmarshal(string, OccuredAt), + <<"change">> => unmarshal_event_change(Change) + }; +unmarshal_event(#p2p_session_Event{ + occured_at = OccuredAt, + change = Change +}) -> + #{ + <<"createdAt">> => unmarshal(string, OccuredAt), + <<"change">> => unmarshal_event_change(Change) + }. + +unmarshal_event_change({status_changed, #p2p_transfer_StatusChange{ + status = Status +}}) -> + ChangeType = #{ <<"changeType">> => <<"P2PTransferStatusChanged">>}, + TransferChange = unmarshal_transfer_status(Status), + maps:merge(ChangeType, TransferChange); +unmarshal_event_change({ui, #p2p_session_UserInteractionChange{ + id = ID, + payload = Payload +}}) -> + #{ + <<"changeType">> => <<"P2PTransferInteractionChanged">>, + <<"userInteractionID">> => unmarshal(id, ID), + <<"userInteractionChange">> => unmarshal_user_interaction_change(Payload) + }. + +unmarshal_user_interaction_change({created, #p2p_session_UserInteractionCreatedChange{ + ui = #p2p_session_UserInteraction{user_interaction = UserInteraction} +}}) -> + #{ + <<"changeType">> => <<"UserInteractionCreated">>, + <<"userInteraction">> => unmarshal_user_interaction(UserInteraction) + }; +unmarshal_user_interaction_change({status_changed, #p2p_session_UserInteractionStatusChange{ + status = {finished, _} % other statuses are skipped +}}) -> + #{ + <<"changeType">> => <<"UserInteractionFinished">> + }. + +unmarshal_user_interaction({redirect, Redirect}) -> + #{ + <<"interactionType">> => <<"Redirect">>, + <<"request">> => unmarshal_request(Redirect) + }. + +unmarshal_request({get_request, #ui_BrowserGetRequest{ + uri = URI +}}) -> + #{ + <<"requestType">> => <<"BrowserGetRequest">>, + <<"uriTemplate">> => unmarshal(string, URI) + }; +unmarshal_request({post_request, #ui_BrowserPostRequest{ + uri = URI, + form = Form +}}) -> + #{ + <<"requestType">> => <<"BrowserPostRequest">>, + <<"uriTemplate">> => unmarshal(string, URI), + <<"form">> => unmarshal_form(Form) + }. + +unmarshal_form(Form) -> + maps:fold( + fun (Key, Template, AccIn) -> + FormField = #{ + <<"key">> => unmarshal(string, Key), + <<"template">> => unmarshal(string, Template) + }, + [FormField | AccIn] + end, + [], Form + ). + unmarshal(T, V) -> ff_codec:unmarshal(T, V). @@ -384,3 +703,262 @@ maybe_unmarshal(_T, undefined) -> undefined; maybe_unmarshal(T, V) -> unmarshal(T, V). + +%% TESTS + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-spec test() -> _. + +-spec unmarshal_events_test_() -> + _. +unmarshal_events_test_() -> + + Form = fun() -> {fun unmarshal_form/1, + #{ <<"arg1">> => <<"value1">>, <<"arg2">> => <<"value2">> }, + [ + #{ <<"key">> => <<"arg2">>, <<"template">> => <<"value2">>}, + #{ <<"key">> => <<"arg1">>, <<"template">> => <<"value1">>} + ] + } end, + + Request = fun + ({_, Woody, Swag}) -> {fun unmarshal_request/1, + {post_request, #ui_BrowserPostRequest{ + uri = <<"uri://post">>, + form = Woody + }}, + #{ + <<"requestType">> => <<"BrowserPostRequest">>, + <<"uriTemplate">> => <<"uri://post">>, + <<"form">> => Swag + } + }; + (get_request) -> {fun unmarshal_request/1, + {get_request, #ui_BrowserGetRequest{ + uri = <<"uri://get">> + }}, + #{ + <<"requestType">> => <<"BrowserGetRequest">>, + <<"uriTemplate">> => <<"uri://get">> + } + } + end, + + UIRedirect = fun({_, Woody, Swag}) -> {fun unmarshal_user_interaction/1, + {redirect, Woody}, + #{ + <<"interactionType">> => <<"Redirect">>, + <<"request">> => Swag + } + } end, + + UIChangePayload = fun + ({_, Woody, Swag}) -> {fun unmarshal_user_interaction_change/1, + {created, #p2p_session_UserInteractionCreatedChange{ + ui = #p2p_session_UserInteraction{ + id = <<"id://p2p_session/ui">>, + user_interaction = Woody + } + }}, + #{ + <<"changeType">> => <<"UserInteractionCreated">>, + <<"userInteraction">> => Swag + } + }; + (ui_finished) -> {fun unmarshal_user_interaction_change/1, + {status_changed, #p2p_session_UserInteractionStatusChange{ + status = {finished, #p2p_session_UserInteractionStatusFinished{}} + }}, + #{ + <<"changeType">> => <<"UserInteractionFinished">> + } + } + end, + + EventChange = fun + ({_, Woody, Swag}) -> {fun unmarshal_event_change/1, + {ui, #p2p_session_UserInteractionChange{ + id = <<"id://p2p_session/change">>, payload = Woody + }}, + #{ + <<"changeType">> => <<"P2PTransferInteractionChanged">>, + <<"userInteractionID">> => <<"id://p2p_session/change">>, + <<"userInteractionChange">> => Swag + } + }; + (TransferStatus) -> {fun unmarshal_event_change/1, + {status_changed, #p2p_transfer_StatusChange{ + status = case TransferStatus of + pending -> {pending, #p2p_status_Pending{}}; + succeeded -> {succeeded, #p2p_status_Succeeded{}} + end + }}, + #{ + <<"changeType">> => <<"P2PTransferStatusChanged">>, + <<"status">> => case TransferStatus of + pending -> <<"Pending">>; + succeeded -> <<"Succeeded">> + end + } + } + end, + + Event = fun + ({_, {ui, _} = Woody, Swag}) -> {fun unmarshal_event/1, + #p2p_session_Event{ + event = 1, + occured_at = <<"2020-05-25T12:34:56.123456Z">>, + change = Woody + }, + #{ + <<"createdAt">> => <<"2020-05-25T12:34:56.123456Z">>, + <<"change">> => Swag + } + }; + ({_, {status_changed, _} = Woody, Swag}) -> {fun unmarshal_event/1, + #p2p_transfer_Event{ + event = 1, + occured_at = <<"2020-05-25T12:34:56.123456Z">>, + change = Woody + }, + #{ + <<"createdAt">> => <<"2020-05-25T12:34:56.123456Z">>, + <<"change">> => Swag + } + } + end, + + Events = fun(List) -> {fun unmarshal_events/1, + { + <<"token">>, + [Woody || {_, Woody, _} <- List] + }, + #{ + <<"continuationToken">> => <<"token">>, + <<"result">> => [Swag || {_, _, Swag} <- List] + } + } end, + + EvList = [E || + Type <- [Form(), get_request], + Change <- [UIChangePayload(UIRedirect(Request(Type))), pending, succeeded], + E <- [Event(EventChange(Change))] + ], + + [ + ?_assertEqual(ExpectedSwag, Unmarshal(Woody)) || + {Unmarshal, Woody, ExpectedSwag} <- [Events(EvList) | EvList] + ]. + +-spec events_collect_test_() -> + _. +events_collect_test_() -> + {setup, + fun() -> + % Construct acceptable event + Event = fun(EventID) -> #p2p_transfer_Event{ + event = EventID, + occured_at = <<"2020-05-25T12:34:56.123456Z">>, + change = {status_changed, #p2p_transfer_StatusChange{ + status = {succeeded, #p2p_status_Succeeded{}} + }} + } end, + % Construct rejectable event + Reject = fun(EventID) -> #p2p_transfer_Event{ + event = EventID, + occured_at = <<"2020-05-25T12:34:56.123456Z">>, + change = {route, #p2p_transfer_RouteChange{}} + } end, + meck:new([wapi_handler_utils], [passthrough]), + % + % mock Request: {Service, 'GetEvents', [EntityID, EventRange]}, + % use Service to select the desired 'GetEvents' result + % + meck:expect(wapi_handler_utils, service_call, fun + ({produce_empty, 'GetEvents', _Params}, _Context) -> + {ok, []}; + ({produce_triple, 'GetEvents', _Params}, _Context) -> + {ok, [Event(N) || N <- lists:seq(1, 3)]}; + ({produce_even, 'GetEvents', [_, EventRange]}, _Context) -> + #'EventRange'{'after' = After, limit = Limit} = EventRange, + {ok, [Event(N) || N <- lists:seq(After + 1, After + Limit), N rem 2 =:= 0]}; + ({produce_reject, 'GetEvents', [_, EventRange]}, _Context) -> + #'EventRange'{'after' = After, limit = Limit} = EventRange, + {ok, [ + case N rem 2 of + 0 -> Reject(N); + _ -> Event(N) + end || N <- lists:seq(After + 1, After + Limit) + ]}; + ({produce_range, 'GetEvents', [_, EventRange]}, _Context) -> + #'EventRange'{'after' = After, limit = Limit} = EventRange, + {ok, [Event(N) || N <- lists:seq(After + 1, After + Limit)]}; + ({transfer_not_found, 'GetEvents', _Params}, _Context) -> + {exception, #fistful_P2PNotFound{}}; + ({session_not_found, 'GetEvents', _Params}, _Context) -> + {exception, #fistful_P2PSessionNotFound{}} + end), + { + % Test generator - call 'events_collect' function and compare with 'Expected' result + fun _Collect(Service, EntityID, EventRange, Acc, Expected) -> + ?_assertEqual(Expected, events_collect(Service, EntityID, EventRange, #{}, Acc)) + end, + % Pass event constructor to test cases + Event + } + end, + fun(_) -> + meck:unload() + end, + fun({Collect, Event}) -> + [ + % SessionID undefined is not an error + Collect(fistful_p2p_session, undefined, events_range(1), [Event(0)], + {ok, {[Event(0)], 1}} + ), + % Limit < 0 < undefined + Collect(any, <<>>, events_range(1, 0), [], + {ok, {[], 1}} + ), + % Limit < 0 < undefined + Collect(any, <<>>, events_range(1, 0), [Event(0)], + {ok, {[Event(0)], 1}} + ), + % the service has not returned any events + Collect(produce_empty, <<>>, events_range(undefined), [], + {ok, {[], undefined}} + ), + % the service has not returned any events + Collect(produce_empty, <<>>, events_range(0, 1), [], + {ok, {[], 0}} + ), + % Limit is 'undefined' and service returned all events + Collect(produce_triple, <<>>, events_range(undefined), [Event(0)], + {ok, {[Event(0), Event(1), Event(2), Event(3)], 3}} + ), + % or service returned less events than requested + Collect(produce_even, <<>>, events_range(0, 4), [], + {ok, {[Event(2), Event(4)], 4}} + ), + % Limit is reached but some events can be filtered out + Collect(produce_reject, <<>>, events_range(0, 4), [], + {ok, {[Event(1), Event(3), Event(5), Event(7)], 7}} + ), + % Accumulate + Collect(produce_range, <<>>, events_range(1, 2), [Event(0)], + {ok, {[Event(0), Event(2), Event(3)], 3}} + ), + % transfer not found + Collect(transfer_not_found, <<>>, events_range(1), [], + {error, {p2p_transfer, notfound}} + ), + % P2PSessionNotFound not found - not error + Collect(session_not_found, <<>>, events_range(1), [], + {ok, {[], 1}} + ) + ] + end + }. + +-endif. diff --git a/apps/wapi/src/wapi_w2w_backend.erl b/apps/wapi/src/wapi_w2w_backend.erl index cfc8bbf..41fd6a6 100644 --- a/apps/wapi/src/wapi_w2w_backend.erl +++ b/apps/wapi/src/wapi_w2w_backend.erl @@ -39,7 +39,7 @@ create_transfer(Params = #{<<"sender">> := SenderID}, HandlerContext) -> create_transfer(ID, Params, Context, HandlerContext) -> TransferParams = marshal(transfer_params, Params#{<<"id">> => ID}), - Request = {w2w_transfer, 'Create', [TransferParams, marshal(context, Context)]}, + Request = {fistful_w2w_transfer, 'Create', [TransferParams, marshal(context, Context)]}, case service_call(Request, HandlerContext) of {ok, Transfer} -> {ok, unmarshal(transfer, Transfer)}; @@ -64,7 +64,7 @@ when get_transfer(ID, HandlerContext) -> EventRange = #'EventRange'{}, - Request = {w2w_transfer, 'Get', [ID, EventRange]}, + Request = {fistful_w2w_transfer, 'Get', [ID, EventRange]}, case service_call(Request, HandlerContext) of {ok, TransferThrift} -> case wapi_access_backend:check_resource(w2w_transfer, TransferThrift, HandlerContext) of diff --git a/apps/wapi/src/wapi_wallet_thrift_handler.erl b/apps/wapi/src/wapi_wallet_thrift_handler.erl index 8717183..0b1c939 100644 --- a/apps/wapi/src/wapi_wallet_thrift_handler.erl +++ b/apps/wapi/src/wapi_wallet_thrift_handler.erl @@ -599,6 +599,28 @@ process_request('GetP2PTransfer', #{p2pTransferID := ID}, Context, _Opts) -> wapi_handler_utils:reply_ok(404) end; +process_request('GetP2PTransferEvents', #{p2pTransferID := ID, continuationToken := CT}, Context, _Opts) -> + case wapi_p2p_transfer_backend:get_transfer_events(ID, CT, Context) of + {ok, P2PTransferEvents} -> + wapi_handler_utils:reply_ok(200, P2PTransferEvents); + {error, {p2p_transfer, unauthorized}} -> + wapi_handler_utils:reply_ok(404); + {error, {p2p_transfer, notfound}} -> + wapi_handler_utils:reply_ok(404); + {error, {token, {not_verified, _}}} -> + wapi_handler_utils:reply_error(400, #{ + <<"errorType">> => <<"InvalidToken">>, + <<"name">> => <<"continuationToken">>, + <<"description">> => <<"Token can't be verified">> + }); + {error, {token, {unsupported_version, _}}} -> + wapi_handler_utils:reply_error(400, #{ + <<"errorType">> => <<"InvalidToken">>, + <<"name">> => <<"continuationToken">>, + <<"description">> => <<"Token unsupported version">> + }) + end; + %% Webhooks process_request('CreateWebhook', #{'WebhookParams' := WebhookParams}, Context, _Opts) -> diff --git a/apps/wapi/test/wapi_SUITE.erl b/apps/wapi/test/wapi_SUITE.erl index e34fb38..95aaf2a 100644 --- a/apps/wapi/test/wapi_SUITE.erl +++ b/apps/wapi/test/wapi_SUITE.erl @@ -134,12 +134,31 @@ end_per_group(_, _) -> init_per_testcase(Name, C) -> C1 = ct_helper:makeup_cfg([ct_helper:test_case_name(Name), ct_helper:woody_ctx()], C), ok = ct_helper:set_context(C1), - C1. + case Name of + woody_retry_test -> + Save = application:get_env(wapi_woody_client, service_urls, undefined), + ok = application:set_env( + wapi_woody_client, + service_urls, + Save#{fistful_stat => "http://spanish.inquision/fistful_stat"} + ), + lists:keystore(service_urls, 1, C1, {service_urls, Save}); + _Other -> + C1 + end. -spec end_per_testcase(test_case_name(), config()) -> _. -end_per_testcase(_Name, _C) -> - ok = ct_helper:unset_context(). +end_per_testcase(_Name, C) -> + ok = ct_helper:unset_context(), + case lists:keysearch(service_urls, 1, C) of + {value, {_, undefined}} -> + application:unset_env(wapi_woody_client, service_urls); + {value, {_, Save}} -> + application:set_env(wapi_woody_client, service_urls, Save); + _ -> + ok + end. -define(ID_PROVIDER, <<"good-one">>). -define(ID_PROVIDER2, <<"good-two">>). @@ -538,12 +557,6 @@ quote_withdrawal_test(C) -> ok = check_withdrawal(WalletID, DestID, WithdrawalID, C). woody_retry_test(C) -> - Urls = application:get_env(wapi_woody_client, service_urls, #{}), - ok = application:set_env( - wapi_woody_client, - service_urls, - Urls#{fistful_stat => "http://spanish.inquision/fistful_stat"} - ), Params = #{ identityID => <<"12332">>, currencyID => <<"RUB">>, @@ -562,8 +575,7 @@ woody_retry_test(C) -> end, T2 = erlang:monotonic_time(), Time = erlang:convert_time_unit(T2 - T1, native, micro_seconds), - ?assert(Time > 3000000), - ok = application:set_env(wapi_woody_client, service_urls, Urls). + ?assert(Time > 3000000). -spec get_wallet_by_external_id(config()) -> test_return(). diff --git a/apps/wapi/test/wapi_p2p_transfer_tests_SUITE.erl b/apps/wapi/test/wapi_p2p_transfer_tests_SUITE.erl index 5547585..f0dbbb4 100644 --- a/apps/wapi/test/wapi_p2p_transfer_tests_SUITE.erl +++ b/apps/wapi/test/wapi_p2p_transfer_tests_SUITE.erl @@ -1,5 +1,6 @@ -module(wapi_p2p_transfer_tests_SUITE). +-include_lib("stdlib/include/assert.hrl"). -include_lib("common_test/include/ct.hrl"). -include_lib("stdlib/include/assert.hrl"). @@ -9,6 +10,8 @@ -include_lib("wapi_wallet_dummy_data.hrl"). -include_lib("fistful_proto/include/ff_proto_p2p_transfer_thrift.hrl"). +-include_lib("fistful_proto/include/ff_proto_p2p_session_thrift.hrl"). + -export([all/0]). -export([groups/0]). @@ -38,7 +41,9 @@ get_quote_fail_operation_not_permitted_test/1, get_quote_fail_no_resource_info_test/1, get_ok_test/1, - get_fail_p2p_notfound_test/1 + get_fail_p2p_notfound_test/1, + get_events_ok/1, + get_events_fail/1 ]). % common-api is used since it is the domain used in production RN @@ -87,7 +92,9 @@ groups() -> get_quote_fail_operation_not_permitted_test, get_quote_fail_no_resource_info_test, get_ok_test, - get_fail_p2p_notfound_test + get_fail_p2p_notfound_test, + get_events_ok, + get_events_fail ] } ]. @@ -159,6 +166,7 @@ end_per_testcase(_Name, C) -> wapi_ct_helper:stop_mocked_service_sup(?config(test_sup, C)), ok. + %%% Tests -spec create_ok_test(config()) -> @@ -278,8 +286,10 @@ create_with_quote_token_ok_test(C) -> {bender_thrift, fun('GenerateID', _) -> {ok, ?GENERATE_ID_RESULT} end}, {fistful_identity, fun('Get', _) -> {ok, ?IDENTITY(PartyID)}; ('GetContext', _) -> {ok, ?DEFAULT_CONTEXT(PartyID)} end}, - {p2p_transfer, fun('GetQuote', _) -> {ok, ?P2P_TRANSFER_QUOTE(IdentityID)}; - ('Create', _) -> {ok, ?P2P_TRANSFER(PartyID)} end} + {fistful_p2p_transfer, fun + ('GetQuote', _) -> {ok, ?P2P_TRANSFER_QUOTE(IdentityID)}; + ('Create', _) -> {ok, ?P2P_TRANSFER(PartyID)} + end} ], C), SenderToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), ReceiverToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), @@ -450,6 +460,42 @@ get_fail_p2p_notfound_test(C) -> get_call_api(C) ). +-spec get_events_ok(config()) -> + _. +get_events_ok(C) -> + PartyID = ?config(party, C), + wapi_ct_helper:mock_services([ + {fistful_p2p_transfer, fun + ('GetContext', _) -> {ok, ?DEFAULT_CONTEXT(PartyID)}; + ('Get', _) -> {ok, ?P2P_TRANSFER_SESSIONS(PartyID)}; + ('GetEvents', [_ID, #'EventRange'{limit = Limit}]) -> + {ok, [?P2P_TRANSFER_EVENT(EventID) || EventID <- lists:seq(1, Limit)]} + end}, + {fistful_p2p_session, fun('GetEvents', [_ID, #'EventRange'{limit = Limit}]) -> + {ok, [?P2P_SESSION_EVENT(EventID) || EventID <- lists:seq(1, Limit)]} + end} + ], C), + + {ok, #{<<"result">> := Result}} = get_events_call_api(C), + + % Limit is multiplied by two because the selection occurs twice - from session and transfer. + {ok, Limit} = application:get_env(wapi, events_fetch_limit), + ?assertEqual(Limit * 2, erlang:length(Result)), + [?assertMatch(#{<<"change">> := _}, Ev) || Ev <- Result]. + +-spec get_events_fail(config()) -> + _. +get_events_fail(C) -> + PartyID = ?config(party, C), + wapi_ct_helper:mock_services([ + {fistful_p2p_transfer, fun + ('GetContext', _) -> {ok, ?DEFAULT_CONTEXT(PartyID)}; + ('Get', _) -> throw(#fistful_P2PNotFound{}) + end} + ], C), + + ?assertMatch({error, {404, #{}}}, get_events_call_api(C)). + %% create_party(_C) -> @@ -464,6 +510,17 @@ call_api(F, Params, Context) -> Response = F(Url, PreparedParams, Opts), wapi_client_lib:handle_response(Response). +get_events_call_api(C) -> + call_api( + fun swag_client_wallet_p2_p_api:get_p2_p_transfer_events/3, + #{ + binding => #{ + <<"p2pTransferID">> => ?STRING + } + }, + ct_helper:cfg(context, C) + ). + create_p2p_transfer_call_api(C) -> SenderToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), ReceiverToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), @@ -539,7 +596,7 @@ create_ok_start_mocks(C, ContextPartyID) -> {bender_thrift, fun('GenerateID', _) -> {ok, ?GENERATE_ID_RESULT} end}, {fistful_identity, fun('Get', _) -> {ok, ?IDENTITY(PartyID)}; ('GetContext', _) -> {ok, ?DEFAULT_CONTEXT(ContextPartyID)} end}, - {p2p_transfer, fun('Create', _) -> {ok, ?P2P_TRANSFER(PartyID)} end} + {fistful_p2p_transfer, fun('Create', _) -> {ok, ?P2P_TRANSFER(PartyID)} end} ], C). create_fail_start_mocks(C, CreateResultFun) -> @@ -551,7 +608,7 @@ create_fail_start_mocks(C, ContextPartyID, CreateResultFun) -> {bender_thrift, fun('GenerateID', _) -> {ok, ?GENERATE_ID_RESULT} end}, {fistful_identity, fun('Get', _) -> {ok, ?IDENTITY(PartyID)}; ('GetContext', _) -> {ok, ?DEFAULT_CONTEXT(ContextPartyID)} end}, - {p2p_transfer, fun('Create', _) -> CreateResultFun() end} + {fistful_p2p_transfer, fun('Create', _) -> CreateResultFun() end} ], C). get_quote_start_mocks(C, GetQuoteResultFun) -> @@ -559,12 +616,12 @@ get_quote_start_mocks(C, GetQuoteResultFun) -> wapi_ct_helper:mock_services([ {fistful_identity, fun('Get', _) -> {ok, ?IDENTITY(PartyID)}; ('GetContext', _) -> {ok, ?DEFAULT_CONTEXT(PartyID)} end}, - {p2p_transfer, fun('GetQuote', _) -> GetQuoteResultFun() end} + {fistful_p2p_transfer, fun('GetQuote', _) -> GetQuoteResultFun() end} ], C). get_start_mocks(C, GetResultFun) -> wapi_ct_helper:mock_services([ - {p2p_transfer, fun('Get', _) -> GetResultFun() end} + {fistful_p2p_transfer, fun('Get', _) -> GetResultFun() end} ], C). store_bank_card(C, Pan, ExpDate, CardHolder) -> diff --git a/apps/wapi/test/wapi_thrift_SUITE.erl b/apps/wapi/test/wapi_thrift_SUITE.erl index d9cc990..810de86 100644 --- a/apps/wapi/test/wapi_thrift_SUITE.erl +++ b/apps/wapi/test/wapi_thrift_SUITE.erl @@ -32,8 +32,6 @@ -type group_name() :: ct_helper:group_name(). -type test_return() :: _ | no_return(). -% -import(ct_helper, [cfg/2]). - -spec all() -> [test_case_name() | {group, group_name()}]. all() -> @@ -60,10 +58,9 @@ groups() -> -spec init_per_suite(config()) -> config(). init_per_suite(C) -> - ct_helper:makeup_cfg([ + ct_helper:makeup_cfg([ ct_helper:test_case_name(init), ct_payment_system:setup(#{ - default_termset => get_default_termset(), optional_apps => [ bender_client, wapi_woody_client, @@ -198,15 +195,21 @@ w2w_transfer_check_test(C) -> p2p_transfer_check_test(C) -> Name = <<"Keyn Fawkes">>, - Provider = ?ID_PROVIDER, + Provider = <<"quote-owner">>, Class = ?ID_CLASS, IdentityID = create_identity(Name, Provider, Class, C), Token = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), P2PTransferID = create_p2p_transfer(Token, Token, IdentityID, C), + ok = await_p2p_transfer(P2PTransferID, C), P2PTransfer = get_p2p_transfer(P2PTransferID, C), + P2PTransferEvents = get_p2p_transfer_events(P2PTransferID, C), ok = application:set_env(wapi, transport, thrift), - P2PTransferIDThrift = create_p2p_transfer(Token, Token, IdentityID, C), + IdentityIDThrift = IdentityID, + P2PTransferIDThrift = create_p2p_transfer(Token, Token, IdentityIDThrift, C), + ok = await_p2p_transfer(P2PTransferIDThrift, C), P2PTransferThrift = get_p2p_transfer(P2PTransferIDThrift, C), + P2PTransferEventsThrift = get_p2p_transfer_events(P2PTransferIDThrift, C), + ?assertEqual(maps:keys(P2PTransferEvents), maps:keys(P2PTransferEventsThrift)), ?assertEqual(maps:keys(P2PTransfer), maps:keys(P2PTransferThrift)), ?assertEqual(maps:without([<<"id">>, <<"createdAt">>], P2PTransfer), maps:without([<<"id">>, <<"createdAt">>], P2PTransferThrift)). @@ -233,34 +236,31 @@ withdrawal_check_test(C) -> p2p_template_check_test(C) -> Name = <<"Keyn Fawkes">>, - Provider = ?ID_PROVIDER, + Provider = <<"quote-owner">>, Class = ?ID_CLASS, + Metadata = #{ <<"some key">> => <<"some value">> }, ok = application:set_env(wapi, transport, thrift), - IdentityID = create_identity(Name, Provider, Class, C), - P2PTemplate = create_p2p_template(IdentityID, C), + P2PTemplate = create_p2p_template(IdentityID, Metadata, C), #{<<"id">> := P2PTemplateID} = P2PTemplate, P2PTemplateCopy = get_p2p_template(P2PTemplateID, C), ?assertEqual(maps:keys(P2PTemplate), maps:keys(P2PTemplateCopy)), - ValidUntil = woody_deadline:to_binary(woody_deadline:from_timeout(100000)), TemplateToken = get_p2p_template_token(P2PTemplateID, ValidUntil, C), TemplateTicket = get_p2p_template_ticket(P2PTemplateID, TemplateToken, ValidUntil, C), {ok, #{<<"token">> := QuoteToken}} = call_p2p_template_quote(P2PTemplateID, C), {ok, P2PTransfer} = call_p2p_template_transfer(P2PTemplateID, TemplateTicket, QuoteToken, C), - ?assertEqual(maps:get(<<"identityID">>, P2PTransfer), IdentityID), - - % TODO: #{<<"metadata">> := Metadata} = P2PTransfer, + ?assertMatch(#{<<"identityID">> := IdentityID}, P2PTransfer), + #{<<"id">> := P2PTransferID} = P2PTransfer, + ok = await_p2p_transfer(P2PTransferID, C), + ?assertMatch(#{<<"metadata">> := Metadata}, P2PTransfer), ok = block_p2p_template(P2PTemplateID, C), P2PTemplateBlocked = get_p2p_template(P2PTemplateID, C), - ?assertEqual(maps:get(<<"isBlocked">>, P2PTemplateBlocked), true), - + ?assertMatch(#{<<"isBlocked">> := true}, P2PTemplateBlocked), QuoteBlockedError = call_p2p_template_quote(P2PTemplateID, C), ?assertMatch({error, {422, _}}, QuoteBlockedError), - P2PTransferBlockedError = call_p2p_template_transfer(P2PTemplateID, TemplateTicket, QuoteToken, C), ?assertMatch({error, {422, _}}, P2PTransferBlockedError), - Quote404Error = call_p2p_template_quote(<<"404">>, C), ?assertMatch({error, {404, _}}, Quote404Error). @@ -545,6 +545,25 @@ get_p2p_transfer(P2PTransferID, C) -> ), P2PTransfer. +get_p2p_transfer_events(P2PTransferID, C) -> + {ok, P2PTransferEvents} = call_api( + fun swag_client_wallet_p2_p_api:get_p2_p_transfer_events/3, + #{binding => #{<<"p2pTransferID">> => P2PTransferID}}, + ct_helper:cfg(context, C) + ), + P2PTransferEvents. + +await_p2p_transfer(P2PTransferID, C) -> + <<"Succeeded">> = ct_helper:await( + <<"Succeeded">>, + fun () -> + Reply = get_p2p_transfer(P2PTransferID, C), + #{<<"status">> := #{<<"status">> := Status}} = Reply, + Status + end + ), + ok. + await_destination(DestID) -> authorized = ct_helper:await( authorized, @@ -612,7 +631,7 @@ get_withdrawal(WithdrawalID, C) -> %% P2PTemplate -create_p2p_template(IdentityID, C) -> +create_p2p_template(IdentityID, Metadata, C) -> {ok, P2PTemplate} = call_api( fun swag_client_wallet_p2_p_templates_api:create_p2_p_transfer_template/3, #{ @@ -626,9 +645,7 @@ create_p2p_template(IdentityID, C) -> } }, <<"metadata">> => #{ - <<"defaultMetadata">> => #{ - <<"some key">> => <<"some value">> - } + <<"defaultMetadata">> => Metadata } } } @@ -661,7 +678,6 @@ block_p2p_template(P2PTemplateID, C) -> ), ok. - get_p2p_template_token(P2PTemplateID, ValidUntil, C) -> {ok, #{<<"token">> := Token}} = call_api( fun swag_client_wallet_p2_p_templates_api:issue_p2_p_transfer_template_access_token/3, @@ -694,8 +710,7 @@ get_p2p_template_ticket(P2PTemplateID, TemplateToken, ValidUntil, C) -> Ticket. call_p2p_template_quote(P2PTemplateID, C) -> - SenderToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), - ReceiverToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), + Token = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), call_api( fun swag_client_wallet_p2_p_templates_api:quote_p2_p_transfer_with_template/3, #{ @@ -705,11 +720,11 @@ call_p2p_template_quote(P2PTemplateID, C) -> body => #{ <<"sender">> => #{ <<"type">> => <<"BankCardSenderResource">>, - <<"token">> => SenderToken + <<"token">> => Token }, <<"receiver">> => #{ <<"type">> => <<"BankCardReceiverResource">>, - <<"token">> => ReceiverToken + <<"token">> => Token }, <<"body">> => #{ <<"amount">> => ?INTEGER, @@ -721,8 +736,7 @@ call_p2p_template_quote(P2PTemplateID, C) -> ). call_p2p_template_transfer(P2PTemplateID, TemplateTicket, QuoteToken, C) -> - SenderToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), - ReceiverToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), + Token = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), Context = maps:merge(ct_helper:cfg(context, C), #{token => TemplateTicket}), call_api( fun swag_client_wallet_p2_p_templates_api:create_p2_p_transfer_with_template/3, @@ -733,15 +747,15 @@ call_p2p_template_transfer(P2PTemplateID, TemplateTicket, QuoteToken, C) -> body => #{ <<"sender">> => #{ <<"type">> => <<"BankCardSenderResourceParams">>, - <<"token">> => SenderToken, + <<"token">> => Token, <<"authData">> => <<"session id">> }, <<"receiver">> => #{ <<"type">> => <<"BankCardReceiverResourceParams">>, - <<"token">> => ReceiverToken + <<"token">> => Token }, <<"body">> => #{ - <<"amount">> => 101, + <<"amount">> => ?INTEGER, <<"currency">> => ?RUB }, <<"contactInfo">> => #{ @@ -753,124 +767,3 @@ call_p2p_template_transfer(P2PTemplateID, TemplateTicket, QuoteToken, C) -> }, Context ). - -%% - --include_lib("ff_cth/include/ct_domain.hrl"). - -get_default_termset() -> - #domain_TermSet{ - wallets = #domain_WalletServiceTerms{ - currencies = {value, ?ordset([?cur(<<"RUB">>)])}, - wallet_limit = {decisions, [ - #domain_CashLimitDecision{ - if_ = {condition, {currency_is, ?cur(<<"RUB">>)}}, - then_ = {value, ?cashrng( - {inclusive, ?cash(-10000000, <<"RUB">>)}, - {exclusive, ?cash( 10000001, <<"RUB">>)} - )} - } - ]}, - withdrawals = #domain_WithdrawalServiceTerms{ - currencies = {value, ?ordset([?cur(<<"RUB">>)])}, - cash_limit = {decisions, [ - #domain_CashLimitDecision{ - if_ = {condition, {currency_is, ?cur(<<"RUB">>)}}, - then_ = {value, ?cashrng( - {inclusive, ?cash( 0, <<"RUB">>)}, - {exclusive, ?cash(10000000, <<"RUB">>)} - )} - } - ]}, - cash_flow = {decisions, [ - #domain_CashFlowDecision{ - if_ = {condition, {currency_is, ?cur(<<"RUB">>)}}, - then_ = {value, [ - ?cfpost( - {wallet, sender_settlement}, - {wallet, receiver_destination}, - ?share(1, 1, operation_amount) - ), - ?cfpost( - {wallet, receiver_destination}, - {system, settlement}, - ?share(10, 100, operation_amount) - ) - ]} - } - ]} - }, - p2p = #domain_P2PServiceTerms{ - currencies = {value, ?ordset([?cur(<<"RUB">>), ?cur(<<"USD">>)])}, - allow = {constant, true}, - cash_limit = {decisions, [ - #domain_CashLimitDecision{ - if_ = {condition, {currency_is, ?cur(<<"RUB">>)}}, - then_ = {value, ?cashrng( - {inclusive, ?cash( 0, <<"RUB">>)}, - {exclusive, ?cash(10001, <<"RUB">>)} - )} - } - ]}, - cash_flow = {decisions, [ - #domain_CashFlowDecision{ - if_ = {condition, {currency_is, ?cur(<<"RUB">>)}}, - then_ = {value, [ - ?cfpost( - {wallet, sender_settlement}, - {wallet, receiver_settlement}, - ?share(1, 1, operation_amount) - ) - ]} - } - ]}, - fees = {decisions, [ - #domain_FeeDecision{ - if_ = {condition, {currency_is, ?cur(<<"RUB">>)}}, - then_ = {value, #domain_Fees{ - fees = #{surplus => ?share(1, 1, operation_amount)} - }} - } - ]}, - quote_lifetime = {value, {interval, #domain_LifetimeInterval{ - days = 1, minutes = 1, seconds = 1 - }}}, - templates = #domain_P2PTemplateServiceTerms{ - allow = {condition, {currency_is, ?cur(<<"RUB">>)}} - } - }, - w2w = #domain_W2WServiceTerms{ - currencies = {value, ?ordset([?cur(<<"RUB">>), ?cur(<<"USD">>)])}, - allow = {constant, true}, - cash_limit = {decisions, [ - #domain_CashLimitDecision{ - if_ = {condition, {currency_is, ?cur(<<"RUB">>)}}, - then_ = {value, ?cashrng( - {inclusive, ?cash(0, <<"RUB">>)}, - {exclusive, ?cash(10001, <<"RUB">>)} - )} - } - ]}, - cash_flow = {decisions, [ - #domain_CashFlowDecision{ - if_ = {condition, {currency_is, ?cur(<<"RUB">>)}}, - then_ = {value, [ - ?cfpost( - {wallet, sender_settlement}, - {wallet, receiver_settlement}, - ?share(1, 1, operation_amount) - ) - ]} - } - ]}, - fees = {decisions, [ - #domain_FeeDecision{ - if_ = {condition, {currency_is, ?cur(<<"RUB">>)}}, - then_ = {value, #domain_Fees{ - fees = #{surplus => ?share(1, 1, operation_amount)} - }} - } - ]} - } - } - }. diff --git a/apps/wapi/test/wapi_w2w_tests_SUITE.erl b/apps/wapi/test/wapi_w2w_tests_SUITE.erl index dace852..51c46b6 100644 --- a/apps/wapi/test/wapi_w2w_tests_SUITE.erl +++ b/apps/wapi/test/wapi_w2w_tests_SUITE.erl @@ -155,7 +155,7 @@ create_fail_unauthorized_wallet_test(C) -> wapi_ct_helper:mock_services([ {bender_thrift, fun('GenerateID', _) -> {ok, ?GENERATE_ID_RESULT} end}, {fistful_wallet, fun('GetContext', _) -> {ok, ?DEFAULT_CONTEXT(<<"someotherparty">>)} end}, - {w2w_transfer, fun('Create', _) -> {ok, ?W2W_TRANSFER(PartyID)} end} + {fistful_w2w_transfer, fun('Create', _) -> {ok, ?W2W_TRANSFER(PartyID)} end} ], C), ?assertEqual( {error, {422, #{<<"message">> => <<"No such wallet sender">>}}}, @@ -295,11 +295,11 @@ create_w2_w_transfer_start_mocks(C, CreateResultFun) -> wapi_ct_helper:mock_services([ {bender_thrift, fun('GenerateID', _) -> {ok, ?GENERATE_ID_RESULT} end}, {fistful_wallet, fun('GetContext', _) -> {ok, ?DEFAULT_CONTEXT(PartyID)} end}, - {w2w_transfer, fun('Create', _) -> CreateResultFun() end} + {fistful_w2w_transfer, fun('Create', _) -> CreateResultFun() end} ], C). get_w2_w_transfer_start_mocks(C, GetResultFun) -> wapi_ct_helper:mock_services([ - {w2w_transfer, fun('Get', _) -> GetResultFun() end} + {fistful_w2w_transfer, fun('Get', _) -> GetResultFun() end} ], C). diff --git a/apps/wapi/test/wapi_wallet_dummy_data.hrl b/apps/wapi/test/wapi_wallet_dummy_data.hrl index e2255f6..1ba2a53 100644 --- a/apps/wapi/test/wapi_wallet_dummy_data.hrl +++ b/apps/wapi/test/wapi_wallet_dummy_data.hrl @@ -519,6 +519,34 @@ adjustments = [] }). +-define(P2P_TRANSFER_SESSIONS(PartyID), ?P2P_TRANSFER(PartyID)#p2p_transfer_P2PTransferState{ + sessions = [#p2p_transfer_SessionState{id = ?STRING}] +}). + +-define(P2P_TRANSFER_EVENT(EventID), #p2p_transfer_Event{ + event = EventID, + occured_at = ?TIMESTAMP, + change = {status_changed, #p2p_transfer_StatusChange{ + status = {succeeded, #p2p_status_Succeeded{}} + }} +}). + +-define(P2P_SESSION_EVENT(EventID), #p2p_session_Event{ + event = EventID, + occured_at = ?TIMESTAMP, + change = {ui, #p2p_session_UserInteractionChange{ + id = ?STRING, + payload = {created, #p2p_session_UserInteractionCreatedChange{ + ui = #p2p_session_UserInteraction{ + id = ?STRING, + user_interaction = {redirect, {get_request, #ui_BrowserGetRequest{ + uri = ?STRING + }}} + } + }} + }} +}). + -define(FEES, #'Fees'{fees = #{operation_amount => ?CASH}}). -define(P2P_TRANSFER_QUOTE(IdentityID), #p2p_transfer_Quote{ @@ -532,3 +560,4 @@ receiver = ?RESOURCE_BANK_CARD, fees = ?FEES }). + diff --git a/apps/wapi_woody_client/src/wapi_woody_client.erl b/apps/wapi_woody_client/src/wapi_woody_client.erl index 3d65433..555e675 100644 --- a/apps/wapi_woody_client/src/wapi_woody_client.erl +++ b/apps/wapi_woody_client/src/wapi_woody_client.erl @@ -96,9 +96,11 @@ get_service_modname(fistful_p2p_template) -> {ff_proto_p2p_template_thrift, 'Management'}; get_service_modname(webhook_manager) -> {ff_proto_webhooker_thrift, 'WebhookManager'}; -get_service_modname(p2p_transfer) -> +get_service_modname(fistful_p2p_transfer) -> {ff_proto_p2p_transfer_thrift, 'Management'}; -get_service_modname(w2w_transfer) -> +get_service_modname(fistful_p2p_session) -> + {ff_proto_p2p_session_thrift, 'Management'}; +get_service_modname(fistful_w2w_transfer) -> {ff_proto_w2w_transfer_thrift, 'Management'}. -spec get_service_deadline(service_name()) -> undefined | woody_deadline:deadline(). diff --git a/config/sys.config b/config/sys.config index 3e3ad4b..0479a2c 100644 --- a/config/sys.config +++ b/config/sys.config @@ -154,10 +154,18 @@ {wapi_woody_client, [ {service_urls, #{ - webhook_manager => "http://hooker:8022/hook", - cds_storage => "http://cds:8022/v1/storage", - identdoc_storage => "http://cds:8022/v1/identity_document_storage", - fistful_stat => "http://fistful-magista:8022/stat" + webhook_manager => "http://hooker:8022/hook", + cds_storage => "http://cds:8022/v1/storage", + identdoc_storage => "http://cds:8022/v1/identity_document_storage", + fistful_stat => "http://fistful-magista:8022/stat", + fistful_wallet => "http://fistful:8022/v1/wallet", + fistful_identity => "http://fistful:8022/v1/identity", + fistful_destination => "http://fistful:8022/v1/destination", + fistful_withdrawal => "http://fistful:8022/v1/withdrawal", + fistful_w2w_transfer => "http://fistful:8022/v1/w2w_transfer", + fistful_p2p_template => "http://fistful:8022/v1/p2p_template", + fistful_p2p_transfer => "http://fistful:8022/v1/p2p_transfer", + fistful_p2p_session => "http://fistful:8022/v1/p2p_transfer/session" }}, {api_deadlines, #{ wallet => 5000 % millisec diff --git a/rebar.config b/rebar.config index 51745a5..db447fd 100644 --- a/rebar.config +++ b/rebar.config @@ -189,6 +189,11 @@ ]}, {test, [ + {deps, [ + {meck, + "0.9.0" + } + ]}, {cover_enabled, true}, {cover_excl_apps, [ ff_cth, diff --git a/rebar.lock b/rebar.lock index 44a62d1..c89f1b6 100644 --- a/rebar.lock +++ b/rebar.lock @@ -1,4 +1,4 @@ -{"1.2.0", +{"1.1.0", [{<<"accept">>,{pkg,<<"accept">>,<<"0.3.5">>},2}, {<<"base64url">>,{pkg,<<"base64url">>,<<"0.0.1">>},0}, {<<"bear">>,{pkg,<<"bear">>,<<"0.8.7">>},3}, @@ -68,7 +68,7 @@ 0}, {<<"fistful_proto">>, {git,"git@github.com:rbkmoney/fistful-proto.git", - {ref,"f373e09fc2e451b9ef3b5f86f54f9627fa29c59f"}}, + {ref,"87b13d386969047c9c16d310754f1f18733b36ab"}}, 0}, {<<"fistful_reporter_proto">>, {git,"git@github.com:rbkmoney/fistful-reporter-proto.git", @@ -204,28 +204,5 @@ {<<"ranch">>, <<"6B1FAB51B49196860B733A49C07604465A47BDB78AA10C1C16A3D199F7F8C881">>}, {<<"ssl_verify_fun">>, <<"F0EAFFF810D2041E93F915EF59899C923F4568F4585904D010387ED74988E77B">>}, {<<"unicode_util_compat">>, <<"D869E4C68901DD9531385BB0C8C40444EBF624E60B6962D95952775CAC5E90CD">>}, - {<<"uuid">>, <<"C5DF97D1A3D626235C2415E74053C47B2138BB863C5CD802AB5CAECB8ECC019F">>}]}, -{pkg_hash_ext,[ - {<<"accept">>, <<"11B18C220BCC2EAB63B5470C038EF10EB6783BCB1FCDB11AA4137DEFA5AC1BB8">>}, - {<<"base64url">>, <<"FAB09B20E3F5DB886725544CBCF875B8E73EC93363954EB8A1A9ED834AA8C1F9">>}, - {<<"bear">>, <<"534217DCE6A719D59E54FB0EB7A367900DBFC5F85757E8C1F94269DF383F6D9B">>}, - {<<"cache">>, <<"3E7D6706DE5DF76C4D71C895B4BE62B01C3DE6EDB63197035E465C3BCE63F19B">>}, - {<<"certifi">>, <<"805ABD97539CAF89EC6D4732C91E62BA9DA0CDA51AC462380BBD28EE697A8C42">>}, - {<<"cowboy">>, <<"04FD8C6A39EDC6AAA9C26123009200FC61F92A3A94F3178C527B70B767C6E605">>}, - {<<"cowlib">>, <<"79F954A7021B302186A950A32869DBC185523D99D3E44CE430CD1F3289F41ED4">>}, - {<<"gproc">>, <<"580ADAFA56463B75263EF5A5DF4C86AF321F68694E7786CB057FD805D1E2A7DE">>}, - {<<"hackney">>, <<"C2790C9F0F7205F4A362512192DEE8179097394400E745E4D20BAB7226A8EAAD">>}, - {<<"idna">>, <<"4BDD305EB64E18B0273864920695CB18D7A2021F31A11B9C5FBCD9A253F936E2">>}, - {<<"jose">>, <<"3C7DDC8A9394B92891DB7C2771DA94BF819834A1A4C92E30857B7D582E2F8257">>}, - {<<"jsx">>, <<"B4C5D3230B397C8D95579E4A3D72826BB6463160130CCF4182F5BE8579B5F44C">>}, - {<<"metrics">>, <<"69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16">>}, - {<<"mimerl">>, <<"F278585650AA581986264638EBF698F8BB19DF297F66AD91B18910DFC6E19323">>}, - {<<"prometheus">>, <<"4905FD2992F8038ECCD7AA0CD22F40637ED618C0BED1F75C05AACEC15B7545DE">>}, - {<<"prometheus_cowboy">>, <<"BA286BECA9302618418892D37BCD5DC669A6CC001F4EB6D6AF85FF81F3F4F34C">>}, - {<<"prometheus_httpd">>, <<"0BBE831452CFDF9588538EB2F570B26F30C348ADAE5E95A7D87F35A5910BCF92">>}, - {<<"quickrand">>, <<"E05EE94A9DA317B4B7D9C453638E592D002FE8F2109A0357B0A54F966EDBBA90">>}, - {<<"ranch">>, <<"451D8527787DF716D99DC36162FCA05934915DB0B6141BBDAC2EA8D3C7AFC7D7">>}, - {<<"ssl_verify_fun">>, <<"603561DC0FD62F4F2EA9B890F4E20E1A0D388746D6E20557CAFB1B16950DE88C">>}, - {<<"unicode_util_compat">>, <<"1D1848C40487CDB0B30E8ED975E34E025860C02E419CB615D255849F3427439D">>}, - {<<"uuid">>, <<"F87BAD1A8E90373B75DAEE259A6EB880293AB178AE2B2779ACB0B00CEA81C602">>}]} + {<<"uuid">>, <<"C5DF97D1A3D626235C2415E74053C47B2138BB863C5CD802AB5CAECB8ECC019F">>}]} ].