From 684a1235438e47a54efa654733fc2a549815578a Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Thu, 3 Sep 2026 00:01:57 +0500 Subject: [PATCH 1/2] XYZ-447: Add routing affinity and email to Customer --- .../0000000000002-customer-email.sql | 7 + .../0000000000003-terminal-affinity.sql | 26 ++ apps/cs/src/cs_customer.erl | 120 ++++- apps/cs/src/cs_customer_database.erl | 78 +++- apps/cs/src/cs_customer_handler.erl | 83 +++- apps/cs/src/cs_mapper.erl | 18 +- apps/cs/src/cs_terminal_affinity.erl | 145 ++++++ apps/cs/src/cs_terminal_affinity_database.erl | 160 +++++++ apps/cs/test/cs_client.erl | 40 +- apps/cs/test/cs_ct_helper.erl | 2 +- apps/cs/test/cs_integration_SUITE.erl | 436 ++++++++++++++++++ rebar.config | 2 +- rebar.lock | 2 +- 13 files changed, 1089 insertions(+), 30 deletions(-) create mode 100644 apps/cs/priv/migrations/0000000000002-customer-email.sql create mode 100644 apps/cs/priv/migrations/0000000000003-terminal-affinity.sql create mode 100644 apps/cs/src/cs_terminal_affinity.erl create mode 100644 apps/cs/src/cs_terminal_affinity_database.erl diff --git a/apps/cs/priv/migrations/0000000000002-customer-email.sql b/apps/cs/priv/migrations/0000000000002-customer-email.sql new file mode 100644 index 0000000..38fb0bd --- /dev/null +++ b/apps/cs/priv/migrations/0000000000002-customer-email.sql @@ -0,0 +1,7 @@ +ALTER TABLE customer ADD COLUMN IF NOT EXISTS email TEXT; + +-- email is stored already normalized (lower + trim), hence a plain column index +-- rather than an expression one; partial, same as external_id +CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_email_party + ON customer(email, party_ref) + WHERE deleted_at IS NULL AND email IS NOT NULL; diff --git a/apps/cs/priv/migrations/0000000000003-terminal-affinity.sql b/apps/cs/priv/migrations/0000000000003-terminal-affinity.sql new file mode 100644 index 0000000..c1093eb --- /dev/null +++ b/apps/cs/priv/migrations/0000000000003-terminal-affinity.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS terminal_affinity ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + customer_id UUID NOT NULL REFERENCES customer(id), + provider_ref TEXT NOT NULL, + terminal_ref TEXT NOT NULL, + -- strict total order of bindings, immune to timestamp collisions + bind_seq BIGSERIAL NOT NULL, + bound_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- base for since_last_use; set on bind, bumped by every successful payment + last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + released_at TIMESTAMPTZ, + released_reason TEXT +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_terminal_affinity_unique + ON terminal_affinity(customer_id, provider_ref, terminal_ref) + WHERE released_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_terminal_affinity_lookup + ON terminal_affinity(customer_id, bind_seq) + WHERE released_at IS NULL; + +-- for the admin "release everyone off terminal X" operation +CREATE INDEX IF NOT EXISTS idx_terminal_affinity_terminal + ON terminal_affinity(provider_ref, terminal_ref) + WHERE released_at IS NULL; diff --git a/apps/cs/src/cs_customer.erl b/apps/cs/src/cs_customer.erl index 1566cec..c6098fc 100644 --- a/apps/cs/src/cs_customer.erl +++ b/apps/cs/src/cs_customer.erl @@ -1,11 +1,14 @@ -module(cs_customer). -export([ - create/4, + create/5, get/1, get_state/1, get_by_external_id/2, + get_by_email/2, get_by_payment/2, + find_or_create_by_email/2, + normalize_email/1, delete/1, add_bank_card/2, remove_bank_card/2, @@ -15,7 +18,12 @@ ]). -export_type([customer_id/0, bank_card_id/0, party_ref/0, contact_info/0, metadata/0]). --export_type([customer/0, customer_state/0, payment_ref/0]). +-export_type([customer/0, customer_state/0, payment_ref/0, email/0]). + +%% Reason recorded on affinities released together with the customer +-define(DELETED_REASON, <<"customer_deleted">>). +%% Longest address an SMTP envelope may carry (RFC 5321) +-define(MAX_EMAIL_SIZE, 320). %% Types -type customer_id() :: binary(). @@ -23,6 +31,7 @@ -type party_ref() :: dmsl_domain_thrift:'PartyConfigRef'(). -type contact_info() :: dmsl_domain_thrift:'ContactInfo'() | undefined. -type metadata() :: dmsl_domain_thrift:'Metadata'() | undefined. +-type email() :: binary(). -type customer() :: #{ id := customer_id(), @@ -31,7 +40,8 @@ metadata => metadata(), created_at := binary(), deleted_at => binary() | undefined, - external_id => binary() | undefined + external_id => binary() | undefined, + email => email() | undefined }. -type customer_state() :: #{ customer := customer(), @@ -46,11 +56,18 @@ %% API --spec create(party_ref(), contact_info(), metadata(), binary() | undefined) -> - {ok, customer_id()} | {error, term()}. -create(PartyRef, ContactInfo, Metadata, ExternalID) -> - PartyRefJson = party_ref_to_json(PartyRef), - cs_customer_database:create(PartyRefJson, ContactInfo, Metadata, ExternalID). +-spec create(party_ref(), contact_info(), metadata(), binary() | undefined, email() | undefined) -> + {ok, customer_id()} | {error, external_id_conflict | email_conflict | invalid_email | term()}. +create(PartyRef, ContactInfo, Metadata, ExternalID, Email) -> + case maybe_normalize_email(Email) of + {ok, NormalizedEmail} -> + PartyRefJson = party_ref_to_json(PartyRef), + cs_customer_database:create( + PartyRefJson, ContactInfo, Metadata, ExternalID, NormalizedEmail + ); + {error, _} = Error -> + Error + end. -spec get(customer_id()) -> {ok, customer()} | {error, not_found | term()}. get(CustomerID) -> @@ -95,6 +112,33 @@ get_by_external_id(ExternalID, PartyRef) -> Error end. +-spec get_by_email(party_ref(), email()) -> + {ok, customer_state()} | {error, not_found | invalid_email | term()}. +get_by_email(PartyRef, Email) -> + case normalize_email(Email) of + {ok, NormalizedEmail} -> + PartyRefJson = party_ref_to_json(PartyRef), + case cs_customer_database:get_by_email(NormalizedEmail, PartyRefJson) of + {ok, Customer} -> + get_state(maps:get(id, Customer)); + Error -> + Error + end; + {error, _} = Error -> + Error + end. + +-spec find_or_create_by_email(party_ref(), email()) -> + {ok, customer()} | {error, invalid_email | term()}. +find_or_create_by_email(PartyRef, Email) -> + case normalize_email(Email) of + {ok, NormalizedEmail} -> + PartyRefJson = party_ref_to_json(PartyRef), + cs_customer_database:find_or_create_by_email(PartyRefJson, NormalizedEmail); + {error, _} = Error -> + Error + end. + -spec get_by_payment(binary(), binary()) -> {ok, customer_state()} | {error, not_found | invalid_recurrent_parent | term()}. get_by_payment(InvoiceId, PaymentId) -> @@ -108,9 +152,28 @@ get_by_payment(InvoiceId, PaymentId) -> Error end. +-spec normalize_email(email()) -> {ok, email()} | {error, invalid_email}. +normalize_email(Email) when is_binary(Email) -> + try unicode:characters_to_binary(string:trim(string:lowercase(Email))) of + Normalized when is_binary(Normalized) -> validate_email(Normalized); + _ -> {error, invalid_email} + catch + %% string:lowercase/1 fails with badarg on malformed UTF-8 + _:_ -> {error, invalid_email} + end; +normalize_email(_Email) -> + {error, invalid_email}. + -spec delete(customer_id()) -> ok | {error, not_found | term()}. delete(CustomerID) -> - cs_customer_database:delete(CustomerID). + case cs_customer_database:delete(CustomerID) of + ok -> + %% Orphan affinities would outlive the customer and the freed email + _ = release_terminal_affinities(CustomerID), + ok; + Error -> + Error + end. -spec add_bank_card(customer_id(), bank_card_id()) -> ok | {error, customer_not_found | term()}. add_bank_card(CustomerID, BankCardId) -> @@ -157,6 +220,45 @@ get_payments(CustomerID, Limit, Offset) -> %% Internal functions +-spec maybe_normalize_email(email() | undefined) -> {ok, email() | undefined} | {error, invalid_email}. +maybe_normalize_email(undefined) -> {ok, undefined}; +maybe_normalize_email(Email) -> normalize_email(Email). + +-spec validate_email(binary()) -> {ok, email()} | {error, invalid_email}. +validate_email(<<>>) -> + {error, invalid_email}; +%% Beyond the RFC 5321 envelope limit the value is not an address, and it also stops +%% fitting into idx_customer_email_party (btree rejects entries over ~2704 bytes) +validate_email(Email) when byte_size(Email) > ?MAX_EMAIL_SIZE -> + {error, invalid_email}; +validate_email(Email) -> + case binary:match(Email, <<"@">>) of + nomatch -> {error, invalid_email}; + _ -> validate_email_charset(Email) + end. + +%% Control characters never belong in an address, and a NUL byte cannot be stored in a +%% text column at all, so PostgreSQL would answer with an error instead of a conflict +-spec validate_email_charset(binary()) -> {ok, email()} | {error, invalid_email}. +validate_email_charset(Email) -> + ControlChars = [<> || Char <- lists:seq(0, 31)] ++ [<<127>>], + case binary:match(Email, ControlChars) of + nomatch -> {ok, Email}; + _ -> {error, invalid_email} + end. + +-spec release_terminal_affinities(customer_id()) -> ok. +release_terminal_affinities(CustomerID) -> + case cs_terminal_affinity:release_by_customer(CustomerID, ?DELETED_REASON) of + ok -> + ok; + {error, Reason} -> + logger:warning("failed to release terminal affinities of customer ~s: ~p", [ + CustomerID, Reason + ]), + ok + end. + -spec make_continuation_token(non_neg_integer(), non_neg_integer(), non_neg_integer()) -> binary() | undefined. make_continuation_token(Offset, Count, Total) -> diff --git a/apps/cs/src/cs_customer_database.erl b/apps/cs/src/cs_customer_database.erl index 23e2188..fbfb3ab 100644 --- a/apps/cs/src/cs_customer_database.erl +++ b/apps/cs/src/cs_customer_database.erl @@ -1,9 +1,11 @@ -module(cs_customer_database). -export([ - create/4, + create/5, get/1, get_by_external_id/2, + get_by_email/2, + find_or_create_by_email/2, get_by_payment/2, delete/1, link_bank_card/2, @@ -28,19 +30,21 @@ %% API --spec create(binary(), term(), term(), binary() | undefined) -> - {ok, customer_id()} | {error, term()}. -create(PartyRef, ContactInfo, Metadata, ExternalID) -> +-spec create(binary(), term(), term(), binary() | undefined, binary() | undefined) -> + {ok, customer_id()} | {error, external_id_conflict | email_conflict | term()}. +create(PartyRef, ContactInfo, Metadata, ExternalID, Email) -> Query = """ - INSERT INTO customer (party_ref, contact_info, metadata, external_id) - VALUES ($1, $2, $3, $4) + INSERT INTO customer (party_ref, contact_info, metadata, external_id, email) + VALUES ($1, $2, $3, $4, $5) RETURNING id """, - Params = [PartyRef, encode_contact_info(ContactInfo), encode_metadata(Metadata), ExternalID], + Params = [ + PartyRef, encode_contact_info(ContactInfo), encode_metadata(Metadata), ExternalID, Email + ], case epg_pool:query(?POOL, Query, Params) of {ok, _, _, [{Id}]} -> {ok, Id}; {ok, _, [{Id}]} -> {ok, Id}; - {error, #error{codename = unique_violation}} -> {error, external_id_conflict}; + {error, #error{codename = unique_violation, extra = Extra}} -> unique_violation(Extra); {error, Reason} -> {error, Reason}; Other -> {error, {unexpected_result, Other}} end. @@ -48,7 +52,7 @@ create(PartyRef, ContactInfo, Metadata, ExternalID) -> -spec get(customer_id()) -> {ok, customer()} | {error, not_found | term()}. get(CustomerID) -> Query = """ - SELECT id, party_ref, contact_info, metadata, created_at, deleted_at, external_id + SELECT id, party_ref, contact_info, metadata, created_at, deleted_at, external_id, email FROM customer WHERE id = $1::uuid """, @@ -63,7 +67,7 @@ get(CustomerID) -> -spec get_by_external_id(binary(), binary()) -> {ok, customer()} | {error, not_found | term()}. get_by_external_id(ExternalID, PartyRef) -> Query = """ - SELECT id, party_ref, contact_info, metadata, created_at, deleted_at, external_id + SELECT id, party_ref, contact_info, metadata, created_at, deleted_at, external_id, email FROM customer WHERE external_id = $1 AND party_ref = $2 @@ -78,10 +82,48 @@ get_by_external_id(ExternalID, PartyRef) -> {error, Reason} -> {error, Reason} end. +%% Email is expected to be already normalized by the business layer. +-spec get_by_email(binary(), binary()) -> {ok, customer()} | {error, not_found | term()}. +get_by_email(Email, PartyRef) -> + Query = """ + SELECT id, party_ref, contact_info, metadata, created_at, deleted_at, external_id, email + FROM customer + WHERE email = $1 + AND party_ref = $2 + AND deleted_at IS NULL + LIMIT 1 + """, + case epg_pool:query(?POOL, Query, [Email, PartyRef]) of + {ok, _, _, [Row]} -> {ok, row_to_customer(Row)}; + {ok, _, _, []} -> {error, not_found}; + {ok, _, [Row]} -> {ok, row_to_customer(Row)}; + {ok, _, []} -> {error, not_found}; + {error, Reason} -> {error, Reason} + end. + +-spec find_or_create_by_email(binary(), binary()) -> {ok, customer()} | {error, term()}. +find_or_create_by_email(PartyRef, Email) -> + %% ON CONFLICT predicate is literally the predicate of idx_customer_email_party, + %% otherwise PostgreSQL fails to infer the arbiter index (42P10). The DO UPDATE is + %% a no-op needed to make RETURNING fire for an already existing row as well. + Query = """ + INSERT INTO customer (party_ref, email) + VALUES ($1, $2) + ON CONFLICT (email, party_ref) WHERE deleted_at IS NULL AND email IS NOT NULL + DO UPDATE SET email = EXCLUDED.email + RETURNING id, party_ref, contact_info, metadata, created_at, deleted_at, external_id, email + """, + case query_rows(?POOL, Query, [PartyRef, Email]) of + {ok, [Row]} -> {ok, row_to_customer(Row)}; + {ok, []} -> {error, failed_to_create}; + {error, Reason} -> {error, Reason} + end. + -spec get_by_payment(invoice_id(), payment_id()) -> {ok, customer()} | {error, not_found | term()}. get_by_payment(InvoiceId, PaymentId) -> Query = """ - SELECT c.id, c.party_ref, c.contact_info, c.metadata, c.created_at, c.deleted_at, c.external_id + SELECT c.id, c.party_ref, c.contact_info, c.metadata, c.created_at, c.deleted_at, + c.external_id, c.email FROM customer c JOIN payment_ref pr ON c.id = pr.customer_id WHERE pr.invoice_id = $1 @@ -191,7 +233,7 @@ get_payments(CustomerID, Limit, Offset) -> %% Internal functions -row_to_customer({Id, PartyRef, ContactInfo, Metadata, CreatedAt, DeletedAt, ExternalID}) -> +row_to_customer({Id, PartyRef, ContactInfo, Metadata, CreatedAt, DeletedAt, ExternalID, Email}) -> #{ id => Id, party_ref => PartyRef, @@ -199,9 +241,19 @@ row_to_customer({Id, PartyRef, ContactInfo, Metadata, CreatedAt, DeletedAt, Exte metadata => decode_metadata(Metadata), created_at => CreatedAt, deleted_at => null_to_default(DeletedAt, undefined), - external_id => null_to_default(ExternalID, undefined) + external_id => null_to_default(ExternalID, undefined), + email => null_to_default(Email, undefined) }. +%% Both partial unique indexes on customer are reachable from create/5, so the +%% conflicting one is told apart by constraint name. +unique_violation(Extra) -> + case proplists:get_value(constraint_name, Extra) of + <<"idx_customer_external_id_party">> -> {error, external_id_conflict}; + <<"idx_customer_email_party">> -> {error, email_conflict}; + Name -> {error, {unique_violation, Name}} + end. + null_to_default(null, Default) -> Default; null_to_default(V, _Default) -> V. diff --git a/apps/cs/src/cs_customer_handler.erl b/apps/cs/src/cs_customer_handler.erl index 92df29e..1842807 100644 --- a/apps/cs/src/cs_customer_handler.erl +++ b/apps/cs/src/cs_customer_handler.erl @@ -1,6 +1,7 @@ -module(cs_customer_handler). -include_lib("damsel/include/dmsl_customer_thrift.hrl"). +-include_lib("damsel/include/dmsl_base_thrift.hrl"). -export([handle_function/4]). @@ -18,9 +19,10 @@ do_handle_function('Create', {Params}, _Context, _Options) -> party_ref = PartyRef, contact_info = ContactInfo, metadata = Metadata, - external_id = ExternalID + external_id = ExternalID, + email = Email } = Params, - case cs_customer:create(PartyRef, ContactInfo, Metadata, ExternalID) of + case cs_customer:create(PartyRef, ContactInfo, Metadata, ExternalID, Email) of {ok, CustomerID} -> {ok, CustomerState} = cs_customer:get_state(CustomerID), {ok, cs_mapper:customer_to_thrift(maps:get(customer, CustomerState))}; @@ -28,6 +30,12 @@ do_handle_function('Create', {Params}, _Context, _Options) -> woody_error:raise(business, #customer_CustomerAlreadyExists{ id = get_existing_customer_id(ExternalID, PartyRef) }); + {error, email_conflict} -> + woody_error:raise(business, #customer_CustomerEmailConflict{ + id = get_existing_customer_id_by_email(PartyRef, Email) + }); + {error, invalid_email} -> + woody_error:raise(business, #base_InvalidRequest{errors = [<<"invalid email">>]}); {error, Reason} -> woody_error:raise(system, {internal, Reason}) end; @@ -161,6 +169,71 @@ do_handle_function('GetBankCards', {CustomerID, Limit, ContinuationToken}, _Cont woody_error:raise(business, #customer_CustomerNotFound{}); {error, Reason} -> woody_error:raise(system, {internal, Reason}) + end; +do_handle_function('FindOrCreateByEmail', {PartyRef, Email}, _Context, _Options) -> + case cs_customer:find_or_create_by_email(PartyRef, Email) of + {ok, Customer} -> + {ok, cs_mapper:customer_to_thrift(Customer)}; + {error, invalid_email} -> + woody_error:raise(business, #base_InvalidRequest{errors = [<<"invalid email">>]}); + {error, Reason} -> + woody_error:raise(system, {internal, Reason}) + end; +do_handle_function('GetByEmail', {PartyRef, Email}, _Context, _Options) -> + case cs_customer:get_by_email(PartyRef, Email) of + {ok, State} -> + {ok, cs_mapper:customer_state_to_thrift(State)}; + {error, Reason} when Reason =:= not_found; Reason =:= invalid_email -> + woody_error:raise(business, #customer_CustomerNotFound{}); + {error, Reason} -> + woody_error:raise(system, {internal, Reason}) + end; +do_handle_function('GetTerminalAffinities', {CustomerID}, _Context, _Options) -> + case cs_terminal_affinity:list(CustomerID) of + {ok, Affinities} -> + {ok, [cs_mapper:terminal_affinity_to_thrift(A) || A <- Affinities]}; + {error, not_found} -> + woody_error:raise(business, #customer_CustomerNotFound{}); + {error, Reason} -> + woody_error:raise(system, {internal, Reason}) + end; +do_handle_function('BindTerminalAffinity', {Params}, _Context, _Options) -> + #customer_TerminalAffinityParams{ + customer_id = CustomerID, + provider_ref = ProviderRef, + terminal_ref = TerminalRef, + ttl = Ttl + } = Params, + case cs_terminal_affinity:bind(CustomerID, ProviderRef, TerminalRef, Ttl) of + {ok, Affinity} -> + {ok, cs_mapper:terminal_affinity_to_thrift(Affinity)}; + {error, not_found} -> + woody_error:raise(business, #customer_CustomerNotFound{}); + {error, invalid_request} -> + woody_error:raise(business, #base_InvalidRequest{errors = [<<"invalid ttl">>]}); + {error, Reason} -> + woody_error:raise(system, {internal, Reason}) + end; +do_handle_function('ReleaseTerminalAffinity', {Params}, _Context, _Options) -> + #customer_ReleaseTerminalAffinityParams{ + customer_id = CustomerID, + key = Key, + reason = Reason + } = Params, + case cs_terminal_affinity:release(CustomerID, Key, Reason) of + ok -> + {ok, ok}; + {error, not_found} -> + woody_error:raise(business, #customer_CustomerNotFound{}); + {error, Error} -> + woody_error:raise(system, {internal, Error}) + end; +do_handle_function('ReleaseTerminalAffinitiesByTerminal', {Key, Reason}, _Context, _Options) -> + case cs_terminal_affinity:release_by_terminal(Key, Reason) of + ok -> + {ok, ok}; + {error, Error} -> + woody_error:raise(system, {internal, Error}) end. %% Internal functions @@ -175,6 +248,12 @@ get_existing_customer_id(ExternalID, PartyRef) -> {error, not_found} -> undefined end. +get_existing_customer_id_by_email(PartyRef, Email) -> + case cs_customer:get_by_email(PartyRef, Email) of + {ok, #{customer := Customer}} -> maps:get(id, Customer); + {error, _} -> undefined + end. + get_bank_card_info(BankCardId) -> case cs_bank_card:get_info(BankCardId) of {ok, BankCardInfo} -> {true, cs_mapper:bank_card_info_to_thrift(BankCardInfo)}; diff --git a/apps/cs/src/cs_mapper.erl b/apps/cs/src/cs_mapper.erl index fc30c42..baa8405 100644 --- a/apps/cs/src/cs_mapper.erl +++ b/apps/cs/src/cs_mapper.erl @@ -10,7 +10,8 @@ bank_card_with_tokens_to_thrift/1, bank_card_info_to_thrift/1, payment_to_thrift/1, - recurrent_token_to_thrift/1 + recurrent_token_to_thrift/1, + terminal_affinity_to_thrift/1 ]). -export_type([ @@ -20,6 +21,7 @@ bank_card_with_tokens/0, bank_card_info/0, recurrent_token/0, + terminal_affinity/0, tokens_map/0 ]). @@ -30,6 +32,7 @@ -type bank_card_with_tokens() :: cs_bank_card:bank_card_with_tokens(). -type bank_card_info() :: cs_bank_card:bank_card_info(). -type recurrent_token() :: cs_bank_card_database:recurrent_token(). +-type terminal_affinity() :: cs_terminal_affinity:affinity(). -type tokens_map() :: #{ dmsl_customer_thrift:'ProviderTerminalKey'() => dmsl_customer_thrift:'RecurrentToken'() }. @@ -45,7 +48,8 @@ customer_to_thrift(Customer) -> status = customer_status(Customer), contact_info = maps:get(contact_info, Customer, undefined), metadata = maps:get(metadata, Customer, undefined), - external_id = maps:get(external_id, Customer, undefined) + external_id = maps:get(external_id, Customer, undefined), + email = maps:get(email, Customer, undefined) }. -spec customer_state_to_thrift(customer_state()) -> dmsl_customer_thrift:'CustomerState'(). @@ -125,6 +129,16 @@ recurrent_token_to_thrift(Token) -> status = recurrent_token_status(Token) }. +-spec terminal_affinity_to_thrift(terminal_affinity()) -> dmsl_customer_thrift:'TerminalAffinity'(). +terminal_affinity_to_thrift(Affinity) -> + #customer_TerminalAffinity{ + provider_ref = binary_to_provider_ref(maps:get(provider_ref, Affinity)), + terminal_ref = binary_to_terminal_ref(maps:get(terminal_ref, Affinity)), + bind_seq = maps:get(bind_seq, Affinity), + bound_at = format_timestamp_required(maps:get(bound_at, Affinity)), + last_used_at = format_timestamp_required(maps:get(last_used_at, Affinity)) + }. + %% Internal functions -spec customer_status(customer()) -> dmsl_customer_thrift:'CustomerStatus'(). diff --git a/apps/cs/src/cs_terminal_affinity.erl b/apps/cs/src/cs_terminal_affinity.erl new file mode 100644 index 0000000..15e61c2 --- /dev/null +++ b/apps/cs/src/cs_terminal_affinity.erl @@ -0,0 +1,145 @@ +-module(cs_terminal_affinity). + +-include_lib("damsel/include/dmsl_customer_thrift.hrl"). + +-export([ + list/1, + bind/4, + release/3, + release_by_customer/2, + release_by_terminal/2 +]). + +-export_type([ + affinity/0, + cutoff/0, + customer_id/0, + provider_ref/0, + terminal_ref/0, + provider_terminal_key/0, + reason/0, + ttl/0 +]). + +%% Types +-type customer_id() :: cs_customer:customer_id(). +-type provider_ref() :: dmsl_domain_thrift:'ProviderRef'(). +-type terminal_ref() :: dmsl_domain_thrift:'TerminalRef'(). +-type provider_terminal_key() :: dmsl_customer_thrift:'ProviderTerminalKey'(). +-type ttl() :: dmsl_domain_thrift:'RoutingAffinityTtl'() | undefined. +-type reason() :: binary() | undefined. + +-type affinity() :: cs_terminal_affinity_database:affinity(). +%% Expiration base and the timestamp an affinity is expired when its base precedes +-type cutoff() :: cs_terminal_affinity_database:cutoff(). + +%% API + +-spec list(customer_id()) -> {ok, [affinity()]} | {error, not_found | term()}. +list(CustomerID) -> + case cs_customer:get(CustomerID) of + {ok, _Customer} -> + cs_terminal_affinity_database:list(CustomerID); + Error -> + Error + end. + +-spec bind(customer_id(), provider_ref(), terminal_ref(), ttl()) -> + {ok, affinity()} | {error, not_found | invalid_request | term()}. +bind(CustomerID, ProviderRef, TerminalRef, Ttl) -> + case ttl_to_cutoff(Ttl) of + {ok, Cutoff} -> + case cs_customer:get(CustomerID) of + {ok, _Customer} -> + {ProviderRefJson, TerminalRefJson} = refs_to_json(ProviderRef, TerminalRef), + cs_terminal_affinity_database:bind( + CustomerID, ProviderRefJson, TerminalRefJson, Cutoff + ); + Error -> + Error + end; + {error, _} = Error -> + Error + end. + +-spec release(customer_id(), provider_terminal_key(), reason()) -> ok | {error, not_found | term()}. +release(CustomerID, #customer_ProviderTerminalKey{} = Key, Reason) -> + case cs_customer:get(CustomerID) of + {ok, _Customer} -> + {ProviderRefJson, TerminalRefJson} = key_to_json(Key), + Result = cs_terminal_affinity_database:release( + CustomerID, ProviderRefJson, TerminalRefJson, Reason + ), + %% The customer exists, so an absent active affinity means the release + %% already happened: the operation is idempotent, not an error. Only a + %% missing customer is reported as not_found (mapped to CustomerNotFound). + case Result of + {error, not_found} -> ok; + Other -> Other + end; + Error -> + Error + end. + +-spec release_by_customer(customer_id(), reason()) -> ok | {error, term()}. +release_by_customer(CustomerID, Reason) -> + case cs_terminal_affinity_database:release_by_customer(CustomerID, Reason) of + {ok, _Count} -> ok; + Error -> Error + end. + +-spec release_by_terminal(provider_terminal_key(), reason()) -> ok | {error, term()}. +release_by_terminal(#customer_ProviderTerminalKey{} = Key, Reason) -> + {ProviderRefJson, TerminalRefJson} = key_to_json(Key), + case cs_terminal_affinity_database:release_by_terminal(ProviderRefJson, TerminalRefJson, Reason) of + {ok, _Count} -> ok; + Error -> Error + end. + +%% Internal functions + +-spec ttl_to_cutoff(ttl()) -> {ok, cutoff()} | {error, invalid_request}. +ttl_to_cutoff(undefined) -> + {ok, undefined}; +ttl_to_cutoff({Base, Timer}) when Base =:= since_bound; Base =:= since_last_use -> + case timer_to_timestamp(Timer) of + {ok, Timestamp} -> {ok, {Base, Timestamp}}; + {error, _} = Error -> Error + end; +ttl_to_cutoff(_Ttl) -> + {error, invalid_request}. + +-spec timer_to_timestamp(dmsl_base_thrift:'Timer'()) -> {ok, binary()} | {error, invalid_request}. +timer_to_timestamp({timeout, Timeout}) when is_integer(Timeout), Timeout >= 0 -> + Cutoff = erlang:system_time(second) - Timeout, + {ok, list_to_binary(calendar:system_time_to_rfc3339(Cutoff, [{offset, "Z"}]))}; +timer_to_timestamp({deadline, Deadline}) when is_binary(Deadline) -> + %% Parsed here rather than left to the PostgreSQL timestamptz parser: the latter + %% turns a malformed deadline into a transaction error (a system error to the + %% caller instead of the declared InvalidRequest) and silently accepts the special + %% values 'now' / 'infinity' / 'yesterday', which would expire every affinity. + %% Reformatting with an explicit offset also keeps the cutoff independent of the + %% session TimeZone, which an offsetless timestamp would otherwise depend on. + try calendar:rfc3339_to_system_time(binary_to_list(Deadline), [{unit, microsecond}]) of + Micro -> + Formatted = calendar:system_time_to_rfc3339(Micro, [{unit, microsecond}, {offset, "Z"}]), + {ok, list_to_binary(Formatted)} + catch + _:_ -> {error, invalid_request} + end; +timer_to_timestamp(_Timer) -> + {error, invalid_request}. + +-spec key_to_json(provider_terminal_key()) -> {binary(), binary()}. +key_to_json(#customer_ProviderTerminalKey{provider_ref = ProviderRef, terminal_ref = TerminalRef}) -> + refs_to_json(ProviderRef, TerminalRef). + +-spec refs_to_json(provider_ref(), terminal_ref()) -> {binary(), binary()}. +refs_to_json(ProviderRef, TerminalRef) -> + ProviderType = {struct, struct, {dmsl_domain_thrift, 'ProviderRef'}}, + TerminalType = {struct, struct, {dmsl_domain_thrift, 'TerminalRef'}}, + {ref_to_json(ProviderRef, ProviderType), ref_to_json(TerminalRef, TerminalType)}. + +-spec ref_to_json(tuple(), cs_json:thrift_type()) -> binary(). +ref_to_json(Ref, Type) -> + cs_json:encode(cs_json:term_to_json(Ref, Type)). diff --git a/apps/cs/src/cs_terminal_affinity_database.erl b/apps/cs/src/cs_terminal_affinity_database.erl new file mode 100644 index 0000000..af12ef6 --- /dev/null +++ b/apps/cs/src/cs_terminal_affinity_database.erl @@ -0,0 +1,160 @@ +-module(cs_terminal_affinity_database). + +-export([ + list/1, + bind/4, + release/4, + release_by_customer/2, + release_by_terminal/3 +]). + +-define(POOL, default_pool). + +%% Types +-type customer_id() :: cs_customer_database:customer_id(). +-type affinity() :: #{ + provider_ref := binary(), + terminal_ref := binary(), + bind_seq := integer(), + bound_at := term(), + last_used_at := term() +}. +%% Expiration base and the cutoff it is compared against; `undefined' means no ttl. +-type cutoff() :: undefined | {since_bound | since_last_use, binary()}. + +-export_type([customer_id/0, affinity/0, cutoff/0]). + +%% API + +-spec list(customer_id()) -> {ok, [affinity()]} | {error, term()}. +list(CustomerID) -> + Query = """ + SELECT provider_ref, terminal_ref, bind_seq, bound_at, last_used_at + FROM terminal_affinity + WHERE customer_id = $1::uuid + AND released_at IS NULL + ORDER BY bind_seq ASC + """, + case query_rows(?POOL, Query, [CustomerID]) of + {ok, Rows} -> {ok, [row_to_affinity(Row) || Row <- Rows]}; + {error, Reason} -> {error, Reason} + end. + +-spec bind(customer_id(), binary(), binary(), cutoff()) -> {ok, affinity()} | {error, term()}. +bind(CustomerID, ProviderRef, TerminalRef, Cutoff) -> + %% Two statements in a single transaction rather than a data-modifying CTE: CTE + %% branches share one snapshot and would not see each other's effects. + Fun = fun(Conn) -> + ok = release_expired(Conn, CustomerID, ProviderRef, TerminalRef, Cutoff), + upsert(Conn, CustomerID, ProviderRef, TerminalRef) + end, + case epg_pool:transaction(?POOL, Fun) of + {ok, _Affinity} = Result -> Result; + {error, Reason} -> {error, Reason}; + {rollback, {?MODULE, Reason}} -> {error, Reason}; + {rollback, Reason} -> {error, Reason} + end. + +-spec release(customer_id(), binary(), binary(), binary() | undefined) -> + ok | {error, not_found | term()}. +release(CustomerID, ProviderRef, TerminalRef, Reason) -> + Query = """ + UPDATE terminal_affinity + SET released_at = NOW(), released_reason = $4 + WHERE customer_id = $1::uuid + AND provider_ref = $2 + AND terminal_ref = $3 + AND released_at IS NULL + """, + case epg_pool:query(?POOL, Query, [CustomerID, ProviderRef, TerminalRef, Reason]) of + {ok, N} when N > 0 -> ok; + {ok, 0} -> {error, not_found}; + {error, Error} -> {error, Error} + end. + +-spec release_by_customer(customer_id(), binary() | undefined) -> + {ok, non_neg_integer()} | {error, term()}. +release_by_customer(CustomerID, Reason) -> + Query = """ + UPDATE terminal_affinity + SET released_at = NOW(), released_reason = $2 + WHERE customer_id = $1::uuid + AND released_at IS NULL + """, + case epg_pool:query(?POOL, Query, [CustomerID, Reason]) of + {ok, N} -> {ok, N}; + {error, Error} -> {error, Error} + end. + +-spec release_by_terminal(binary(), binary(), binary() | undefined) -> + {ok, non_neg_integer()} | {error, term()}. +release_by_terminal(ProviderRef, TerminalRef, Reason) -> + Query = """ + UPDATE terminal_affinity + SET released_at = NOW(), released_reason = $3 + WHERE provider_ref = $1 + AND terminal_ref = $2 + AND released_at IS NULL + """, + case epg_pool:query(?POOL, Query, [ProviderRef, TerminalRef, Reason]) of + {ok, N} -> {ok, N}; + {error, Error} -> {error, Error} + end. + +%% Internal functions + +release_expired(_Conn, _CustomerID, _ProviderRef, _TerminalRef, undefined) -> + ok; +release_expired(Conn, CustomerID, ProviderRef, TerminalRef, {Base, CutoffAt}) -> + Query = """ + UPDATE terminal_affinity + SET released_at = NOW(), released_reason = 'expired' + WHERE customer_id = $1::uuid + AND provider_ref = $2 + AND terminal_ref = $3 + AND released_at IS NULL + AND (CASE $5::text WHEN 'since_bound' THEN bound_at ELSE last_used_at END) + < $4::text::timestamptz + """, + Params = [CustomerID, ProviderRef, TerminalRef, CutoffAt, atom_to_binary(Base, utf8)], + case epg_pool:query(Conn, Query, Params) of + {ok, _} -> ok; + {error, Reason} -> rollback(Reason) + end. + +upsert(Conn, CustomerID, ProviderRef, TerminalRef) -> + %% ON CONFLICT predicate is literally the predicate of idx_terminal_affinity_unique + Query = """ + INSERT INTO terminal_affinity (customer_id, provider_ref, terminal_ref) + VALUES ($1::uuid, $2, $3) + ON CONFLICT (customer_id, provider_ref, terminal_ref) WHERE released_at IS NULL + DO UPDATE SET last_used_at = NOW() + RETURNING provider_ref, terminal_ref, bind_seq, bound_at, last_used_at + """, + case query_rows(Conn, Query, [CustomerID, ProviderRef, TerminalRef]) of + {ok, [Row]} -> {ok, row_to_affinity(Row)}; + {ok, []} -> rollback(failed_to_bind); + {error, Reason} -> rollback(Reason) + end. + +%% Only a raised exception rolls the transaction back; a plain {error, _} return +%% would be committed by epgsql:with_transaction/3. +-spec rollback(term()) -> no_return(). +rollback(Reason) -> + erlang:error({?MODULE, Reason}). + +row_to_affinity({ProviderRef, TerminalRef, BindSeq, BoundAt, LastUsedAt}) -> + #{ + provider_ref => ProviderRef, + terminal_ref => TerminalRef, + bind_seq => BindSeq, + bound_at => BoundAt, + last_used_at => LastUsedAt + }. + +query_rows(PoolOrConn, Query, Params) -> + case epg_pool:query(PoolOrConn, Query, Params) of + {ok, _, _, Rows} -> {ok, Rows}; + {ok, _, Rows} -> {ok, Rows}; + {error, Reason} -> {error, Reason} + end. diff --git a/apps/cs/test/cs_client.erl b/apps/cs/test/cs_client.erl index fdbfcdf..4e15e96 100644 --- a/apps/cs/test/cs_client.erl +++ b/apps/cs/test/cs_client.erl @@ -14,7 +14,13 @@ remove_bank_card/3, add_payment/4, get_payments/4, - get_bank_cards/4 + get_bank_cards/4, + find_or_create_customer_by_email/3, + get_customer_by_email/3, + get_terminal_affinities/2, + bind_terminal_affinity/2, + release_terminal_affinity/2, + release_terminal_affinities_by_terminal/3 ]). %% BankCardStorage API @@ -79,6 +85,38 @@ get_payments(CustomerID, Limit, ContinuationToken, Client) -> get_bank_cards(CustomerID, Limit, ContinuationToken, Client) -> cs_client_api:call(customer_management, 'GetBankCards', [CustomerID, Limit, ContinuationToken], Client). +-spec find_or_create_customer_by_email(dmsl_domain_thrift:'PartyConfigRef'(), binary(), cs_client_api:t()) -> + {ok, dmsl_customer_thrift:'Customer'()} | {exception, term()} | {error, term()}. +find_or_create_customer_by_email(PartyRef, Email, Client) -> + cs_client_api:call(customer_management, 'FindOrCreateByEmail', [PartyRef, Email], Client). + +-spec get_customer_by_email(dmsl_domain_thrift:'PartyConfigRef'(), binary(), cs_client_api:t()) -> + {ok, dmsl_customer_thrift:'CustomerState'()} | {exception, term()} | {error, term()}. +get_customer_by_email(PartyRef, Email, Client) -> + cs_client_api:call(customer_management, 'GetByEmail', [PartyRef, Email], Client). + +-spec get_terminal_affinities(binary(), cs_client_api:t()) -> + {ok, [dmsl_customer_thrift:'TerminalAffinity'()]} | {exception, term()} | {error, term()}. +get_terminal_affinities(CustomerID, Client) -> + cs_client_api:call(customer_management, 'GetTerminalAffinities', [CustomerID], Client). + +-spec bind_terminal_affinity(dmsl_customer_thrift:'TerminalAffinityParams'(), cs_client_api:t()) -> + {ok, dmsl_customer_thrift:'TerminalAffinity'()} | {exception, term()} | {error, term()}. +bind_terminal_affinity(Params, Client) -> + cs_client_api:call(customer_management, 'BindTerminalAffinity', [Params], Client). + +-spec release_terminal_affinity(dmsl_customer_thrift:'ReleaseTerminalAffinityParams'(), cs_client_api:t()) -> + {ok, ok} | {exception, term()} | {error, term()}. +release_terminal_affinity(Params, Client) -> + cs_client_api:call(customer_management, 'ReleaseTerminalAffinity', [Params], Client). + +-spec release_terminal_affinities_by_terminal( + dmsl_customer_thrift:'ProviderTerminalKey'(), binary() | undefined, cs_client_api:t() +) -> + {ok, ok} | {exception, term()} | {error, term()}. +release_terminal_affinities_by_terminal(Key, Reason, Client) -> + cs_client_api:call(customer_management, 'ReleaseTerminalAffinitiesByTerminal', [Key, Reason], Client). + %% BankCardStorage -spec get_bank_card(binary(), cs_client_api:t()) -> diff --git a/apps/cs/test/cs_ct_helper.erl b/apps/cs/test/cs_ct_helper.erl index 3110003..3094853 100644 --- a/apps/cs/test/cs_ct_helper.erl +++ b/apps/cs/test/cs_ct_helper.erl @@ -64,7 +64,7 @@ start_app(epg_connector = AppName) -> start_app(AppName, [ {databases, #{ cs => #{ - host => "cs_db", + host => os:getenv("POSTGRES_HOST", "cs_db"), port => 5432, username => "postgres", password => "postgres", diff --git a/apps/cs/test/cs_integration_SUITE.erl b/apps/cs/test/cs_integration_SUITE.erl index 5c389fb..ee43c99 100644 --- a/apps/cs/test/cs_integration_SUITE.erl +++ b/apps/cs/test/cs_integration_SUITE.erl @@ -2,9 +2,13 @@ -include_lib("common_test/include/ct.hrl"). -include_lib("stdlib/include/assert.hrl"). +-include_lib("damsel/include/dmsl_base_thrift.hrl"). -include_lib("damsel/include/dmsl_customer_thrift.hrl"). -include_lib("damsel/include/dmsl_domain_thrift.hrl"). +%% Age, in seconds, an affinity is backdated to before a one day TTL is applied to it +-define(TEN_DAYS, 10 * 24 * 60 * 60). + -export([ all/0, groups/0, @@ -32,15 +36,40 @@ get_by_external_id_test/1, get_by_external_id_not_found_test/1, create_customer_external_id_conflict_test/1, + find_or_create_by_email_test/1, + find_or_create_by_email_concurrent_test/1, + find_or_create_by_email_normalization_test/1, + find_or_create_by_email_invalid_test/1, + create_customer_email_conflict_test/1, + create_customer_email_other_party_test/1, + get_by_email_test/1, + get_by_email_not_found_test/1, create_bank_card_test/1, find_bank_card_test/1, add_recurrent_token_test/1, invalidate_recurrent_token_test/1 ]). +-export([ + terminal_affinities_empty_test/1, + terminal_affinities_customer_not_found_test/1, + bind_terminal_affinity_test/1, + bind_terminal_affinity_idempotent_test/1, + bind_terminal_affinity_ttl_since_bound_test/1, + bind_terminal_affinity_ttl_since_last_use_test/1, + bind_terminal_affinity_ttl_since_last_use_expires_test/1, + bind_terminal_affinity_ttl_since_last_use_deadline_test/1, + bind_terminal_affinity_ttl_deadline_test/1, + bind_terminal_affinity_invalid_ttl_test/1, + release_terminal_affinity_test/1, + release_terminal_affinities_by_terminal_test/1, + delete_customer_releases_affinities_test/1 +]). + all() -> [ {group, customer_management}, + {group, terminal_affinity}, {group, bank_card_storage} ]. @@ -61,10 +90,33 @@ groups() -> get_by_external_id_test, get_by_external_id_not_found_test, create_customer_external_id_conflict_test, + find_or_create_by_email_test, + find_or_create_by_email_concurrent_test, + find_or_create_by_email_normalization_test, + find_or_create_by_email_invalid_test, + create_customer_email_conflict_test, + create_customer_email_other_party_test, + get_by_email_test, + get_by_email_not_found_test, remove_bank_card_test, delete_customer_test, customer_not_found_test ]}, + {terminal_affinity, [parallel], [ + terminal_affinities_empty_test, + terminal_affinities_customer_not_found_test, + bind_terminal_affinity_test, + bind_terminal_affinity_idempotent_test, + bind_terminal_affinity_ttl_since_bound_test, + bind_terminal_affinity_ttl_since_last_use_test, + bind_terminal_affinity_ttl_since_last_use_expires_test, + bind_terminal_affinity_ttl_since_last_use_deadline_test, + bind_terminal_affinity_ttl_deadline_test, + bind_terminal_affinity_invalid_ttl_test, + release_terminal_affinity_test, + release_terminal_affinities_by_terminal_test, + delete_customer_releases_affinities_test + ]}, {bank_card_storage, [parallel], [ create_bank_card_test, find_bank_card_test, @@ -395,6 +447,316 @@ create_customer_external_id_conflict_test(Config) -> ?assertEqual(CustomerID, ConflictID), ok. +%% Repeated find-or-create by the same email resolves to a single customer +find_or_create_by_email_test(Config) -> + Client = ?config(client, Config), + PartyRef = #domain_PartyConfigRef{id = <<"party-email-find-or-create">>}, + Email = <<"find-or-create@affinity.test">>, + {ok, Customer1} = cs_client:find_or_create_customer_by_email(PartyRef, Email, Client), + {ok, Customer2} = cs_client:find_or_create_customer_by_email(PartyRef, Email, Client), + ?assertEqual(Customer1#customer_Customer.id, Customer2#customer_Customer.id), + ?assertEqual(Email, Customer1#customer_Customer.email), + ?assertEqual(PartyRef, Customer1#customer_Customer.party_ref), + ?assertEqual(1, count_customers_by_email(Email)), + ok. + +%% Concurrent find-or-create is arbitrated by the unique index, not by the caller +find_or_create_by_email_concurrent_test(_Config) -> + PartyRef = #domain_PartyConfigRef{id = <<"party-email-concurrent">>}, + Email = <<"concurrent@affinity.test">>, + Self = self(), + Pids = [ + erlang:spawn_link(fun() -> + Client = cs_ct_helper:create_client(), + Self ! {self(), cs_client:find_or_create_customer_by_email(PartyRef, Email, Client)} + end) + || _ <- lists:seq(1, 5) + ], + Ids = [ + receive + {Pid, {ok, Customer}} -> Customer#customer_Customer.id + after 30000 -> error({timeout, Pid}) + end + || Pid <- Pids + ], + ?assertEqual(1, length(lists:usort(Ids))), + ?assertEqual(1, count_customers_by_email(Email)), + ok. + +%% Email is normalized (trimmed and lowercased) both on write and on lookup +find_or_create_by_email_normalization_test(Config) -> + Client = ?config(client, Config), + PartyRef = #domain_PartyConfigRef{id = <<"party-email-normalization">>}, + Normalized = <<"user@norm.test">>, + {ok, Customer1} = cs_client:find_or_create_customer_by_email(PartyRef, <<" User@Norm.Test ">>, Client), + {ok, Customer2} = cs_client:find_or_create_customer_by_email(PartyRef, Normalized, Client), + ?assertEqual(Customer1#customer_Customer.id, Customer2#customer_Customer.id), + ?assertEqual(Normalized, Customer1#customer_Customer.email), + ?assertEqual(1, count_customers_by_email(Normalized)), + ok. + +find_or_create_by_email_invalid_test(Config) -> + Client = ?config(client, Config), + PartyRef = #domain_PartyConfigRef{id = <<"party-email-invalid">>}, + lists:foreach( + fun(Email) -> + {exception, #base_InvalidRequest{}} = + cs_client:find_or_create_customer_by_email(PartyRef, Email, Client) + end, + [ + <<"">>, + <<" ">>, + <<"no-at-sign">>, + %% A NUL byte cannot be stored in a text column at all + <<"nul@byte.test", 0>>, + %% Past the envelope limit the value no longer fits into the unique index + <<(binary:copy(<<"x">>, 400))/binary, "@long.test">> + ] + ), + ok. + +create_customer_email_conflict_test(Config) -> + Client = ?config(client, Config), + PartyRef = #domain_PartyConfigRef{id = <<"party-email-conflict">>}, + Email = <<"conflict@affinity.test">>, + Params = #customer_CustomerParams{party_ref = PartyRef, email = Email}, + {ok, Customer} = cs_client:create_customer(Params, Client), + {exception, #customer_CustomerEmailConflict{id = ConflictID}} = cs_client:create_customer(Params, Client), + ?assertEqual(Customer#customer_Customer.id, ConflictID), + ok. + +%% Email uniqueness is scoped to the party +create_customer_email_other_party_test(Config) -> + Client = ?config(client, Config), + Email = <<"shared@affinity.test">>, + {ok, Customer1} = cs_client:create_customer( + #customer_CustomerParams{party_ref = #domain_PartyConfigRef{id = <<"party-email-shared-1">>}, email = Email}, + Client + ), + {ok, Customer2} = cs_client:create_customer( + #customer_CustomerParams{party_ref = #domain_PartyConfigRef{id = <<"party-email-shared-2">>}, email = Email}, + Client + ), + ?assertNotEqual(Customer1#customer_Customer.id, Customer2#customer_Customer.id), + ?assertEqual(2, count_customers_by_email(Email)), + ok. + +get_by_email_test(Config) -> + Client = ?config(client, Config), + PartyRef = #domain_PartyConfigRef{id = <<"party-email-get">>}, + Email = <<"get@affinity.test">>, + {ok, Customer} = cs_client:find_or_create_customer_by_email(PartyRef, Email, Client), + {ok, State} = cs_client:get_customer_by_email(PartyRef, Email, Client), + StoredCustomer = State#customer_CustomerState.customer, + ?assertEqual(Customer#customer_Customer.id, StoredCustomer#customer_Customer.id), + ?assertEqual(Email, StoredCustomer#customer_Customer.email), + ok. + +get_by_email_not_found_test(Config) -> + Client = ?config(client, Config), + PartyRef = #domain_PartyConfigRef{id = <<"party-email-get-missing">>}, + {exception, #customer_CustomerNotFound{}} = + cs_client:get_customer_by_email(PartyRef, <<"missing@affinity.test">>, Client), + ok. + +%% Terminal Affinity Tests + +terminal_affinities_empty_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-empty">>, Client), + ?assertEqual({ok, []}, cs_client:get_terminal_affinities(CustomerID, Client)), + ok. + +terminal_affinities_customer_not_found_test(Config) -> + Client = ?config(client, Config), + FakeId = <<"00000000-0000-0000-0000-000000000000">>, + {exception, #customer_CustomerNotFound{}} = cs_client:get_terminal_affinities(FakeId, Client), + ok. + +%% Affinities are listed in bind order, earliest first +bind_terminal_affinity_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-bind">>, Client), + {ok, First} = bind_affinity(CustomerID, 1, 10, undefined, Client), + {ok, Second} = bind_affinity(CustomerID, 1, 11, undefined, Client), + ?assert(First#customer_TerminalAffinity.bind_seq < Second#customer_TerminalAffinity.bind_seq), + {ok, Affinities} = cs_client:get_terminal_affinities(CustomerID, Client), + ?assertEqual([{1, 10}, {1, 11}], [affinity_key(A) || A <- Affinities]), + ok. + +%% Rebinding the same terminal keeps bind_seq and only moves last_used_at forward +bind_terminal_affinity_idempotent_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-idempotent">>, Client), + {ok, First} = bind_affinity(CustomerID, 1, 12, undefined, Client), + ok = timer:sleep(50), + {ok, Second} = bind_affinity(CustomerID, 1, 12, undefined, Client), + ?assertEqual(First#customer_TerminalAffinity.bind_seq, Second#customer_TerminalAffinity.bind_seq), + %% Two binds are two transactions, so NOW() strictly advances between them + ?assert(last_used_at(Second) > last_used_at(First)), + ?assertEqual(First#customer_TerminalAffinity.bound_at, Second#customer_TerminalAffinity.bound_at), + {ok, Affinities} = cs_client:get_terminal_affinities(CustomerID, Client), + ?assertEqual(1, length(Affinities)), + ?assertEqual([], released_affinities(CustomerID)), + ok. + +%% Hard TTL: an affinity bound long ago expires and is rebound at the tail +bind_terminal_affinity_ttl_since_bound_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-ttl-bound">>, Client), + {ok, First} = bind_affinity(CustomerID, 2, 20, undefined, Client), + 1 = backdate_affinities(CustomerID, ?TEN_DAYS, ?TEN_DAYS), + {ok, Second} = bind_affinity(CustomerID, 2, 20, {since_bound, {timeout, 86400}}, Client), + ?assert(Second#customer_TerminalAffinity.bind_seq > First#customer_TerminalAffinity.bind_seq), + ?assertEqual([{First#customer_TerminalAffinity.bind_seq, <<"expired">>}], released_affinities(CustomerID)), + {ok, [Live]} = cs_client:get_terminal_affinities(CustomerID, Client), + ?assertEqual(Second#customer_TerminalAffinity.bind_seq, Live#customer_TerminalAffinity.bind_seq), + ok. + +%% Sliding TTL looks at last_used_at, hard TTL at bound_at — same row, different verdicts +bind_terminal_affinity_ttl_since_last_use_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-ttl-last-use">>, Client), + {ok, First} = bind_affinity(CustomerID, 2, 21, undefined, Client), + 1 = backdate_affinities(CustomerID, ?TEN_DAYS, 0), + {ok, Second} = bind_affinity(CustomerID, 2, 21, {since_last_use, {timeout, 86400}}, Client), + ?assertEqual(First#customer_TerminalAffinity.bind_seq, Second#customer_TerminalAffinity.bind_seq), + ?assertEqual([], released_affinities(CustomerID)), + %% bound_at is still stale, so the hard TTL does expire the very same row + {ok, Third} = bind_affinity(CustomerID, 2, 21, {since_bound, {timeout, 86400}}, Client), + ?assert(Third#customer_TerminalAffinity.bind_seq > First#customer_TerminalAffinity.bind_seq), + ?assertEqual([{First#customer_TerminalAffinity.bind_seq, <<"expired">>}], released_affinities(CustomerID)), + ok. + +%% The sliding TTL in its expiring position: idle for longer than the term, rebound at the tail +bind_terminal_affinity_ttl_since_last_use_expires_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-ttl-last-use-exp">>, Client), + {ok, First} = bind_affinity(CustomerID, 2, 23, undefined, Client), + %% bound_at stays fresh, so only the sliding base is stale + 1 = backdate_affinities(CustomerID, 0, ?TEN_DAYS), + {ok, Second} = bind_affinity(CustomerID, 2, 23, {since_last_use, {timeout, 86400}}, Client), + ?assert(Second#customer_TerminalAffinity.bind_seq > First#customer_TerminalAffinity.bind_seq), + ?assertEqual([{First#customer_TerminalAffinity.bind_seq, <<"expired">>}], released_affinities(CustomerID)), + ok. + +%% The remaining kind x form combination: sliding base against an absolute cutoff +bind_terminal_affinity_ttl_since_last_use_deadline_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-ttl-last-use-deadline">>, Client), + {ok, First} = bind_affinity(CustomerID, 2, 24, undefined, Client), + Past = {since_last_use, {deadline, <<"2000-01-01T00:00:00Z">>}}, + {ok, Second} = bind_affinity(CustomerID, 2, 24, Past, Client), + ?assertEqual(First#customer_TerminalAffinity.bind_seq, Second#customer_TerminalAffinity.bind_seq), + ?assertEqual([], released_affinities(CustomerID)), + Future = {since_last_use, {deadline, <<"2100-01-01T00:00:00Z">>}}, + {ok, Third} = bind_affinity(CustomerID, 2, 24, Future, Client), + ?assert(Third#customer_TerminalAffinity.bind_seq > First#customer_TerminalAffinity.bind_seq), + ?assertEqual([{First#customer_TerminalAffinity.bind_seq, <<"expired">>}], released_affinities(CustomerID)), + ok. + +%% A deadline is parsed before it reaches the database: neither garbage nor the special +%% values PostgreSQL would accept ('now', 'infinity', ...) may reach the cutoff +bind_terminal_affinity_invalid_ttl_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-invalid-ttl">>, Client), + {ok, First} = bind_affinity(CustomerID, 2, 25, undefined, Client), + lists:foreach( + fun(Deadline) -> + {exception, #base_InvalidRequest{}} = + bind_affinity(CustomerID, 2, 25, {since_bound, {deadline, Deadline}}, Client) + end, + %% The last one is a timestamp without an offset: its meaning would depend on + %% the session TimeZone rather than on what the caller sent + [<<"not-a-timestamp">>, <<"now">>, <<"infinity">>, <<"yesterday">>, <<"2026-01-01T00:00:00">>] + ), + {exception, #base_InvalidRequest{}} = bind_affinity(CustomerID, 2, 25, {since_bound, {timeout, -5}}, Client), + %% Nothing was released and nothing was rebound + ?assertEqual([], released_affinities(CustomerID)), + {ok, [Live]} = cs_client:get_terminal_affinities(CustomerID, Client), + ?assertEqual(First#customer_TerminalAffinity.bind_seq, Live#customer_TerminalAffinity.bind_seq), + ok. + +%% Deadline form: the base is compared against an absolute cutoff +bind_terminal_affinity_ttl_deadline_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-ttl-deadline">>, Client), + {ok, First} = bind_affinity(CustomerID, 2, 22, undefined, Client), + Past = {since_bound, {deadline, <<"2000-01-01T00:00:00Z">>}}, + {ok, Second} = bind_affinity(CustomerID, 2, 22, Past, Client), + ?assertEqual(First#customer_TerminalAffinity.bind_seq, Second#customer_TerminalAffinity.bind_seq), + ?assertEqual([], released_affinities(CustomerID)), + Future = {since_bound, {deadline, <<"2100-01-01T00:00:00Z">>}}, + {ok, Third} = bind_affinity(CustomerID, 2, 22, Future, Client), + ?assert(Third#customer_TerminalAffinity.bind_seq > First#customer_TerminalAffinity.bind_seq), + ?assertEqual([{First#customer_TerminalAffinity.bind_seq, <<"expired">>}], released_affinities(CustomerID)), + ok. + +release_terminal_affinity_test(Config) -> + Client = ?config(client, Config), + CustomerID = create_affinity_customer(<<"party-affinity-release">>, Client), + {ok, _} = bind_affinity(CustomerID, 3, 30, undefined, Client), + {ok, _} = bind_affinity(CustomerID, 3, 31, undefined, Client), + Params = #customer_ReleaseTerminalAffinityParams{ + customer_id = CustomerID, + key = terminal_key(3, 30), + reason = <<"manual">> + }, + {ok, ok} = cs_client:release_terminal_affinity(Params, Client), + {ok, Affinities} = cs_client:get_terminal_affinities(CustomerID, Client), + ?assertEqual([{3, 31}], [affinity_key(A) || A <- Affinities]), + %% Releasing an affinity that is already gone is a no-op, not an error + {ok, ok} = cs_client:release_terminal_affinity(Params, Client), + %% A missing customer is still reported as such + {exception, #customer_CustomerNotFound{}} = cs_client:release_terminal_affinity( + Params#customer_ReleaseTerminalAffinityParams{customer_id = <<"00000000-0000-0000-0000-000000000000">>}, + Client + ), + ok. + +%% Bulk release covers every customer bound to the terminal and nothing else +release_terminal_affinities_by_terminal_test(Config) -> + Client = ?config(client, Config), + CustomerID1 = create_affinity_customer(<<"party-affinity-release-terminal-1">>, Client), + CustomerID2 = create_affinity_customer(<<"party-affinity-release-terminal-2">>, Client), + lists:foreach( + fun(CustomerID) -> + {ok, _} = bind_affinity(CustomerID, 4, 40, undefined, Client), + {ok, _} = bind_affinity(CustomerID, 4, 41, undefined, Client) + end, + [CustomerID1, CustomerID2] + ), + {ok, ok} = cs_client:release_terminal_affinities_by_terminal( + terminal_key(4, 40), <<"terminal_disabled">>, Client + ), + lists:foreach( + fun(CustomerID) -> + {ok, Affinities} = cs_client:get_terminal_affinities(CustomerID, Client), + ?assertEqual([{4, 41}], [affinity_key(A) || A <- Affinities]) + end, + [CustomerID1, CustomerID2] + ), + ok. + +%% Soft-deleting a customer releases its affinities and frees the email +delete_customer_releases_affinities_test(Config) -> + Client = ?config(client, Config), + PartyRef = #domain_PartyConfigRef{id = <<"party-affinity-delete">>}, + Email = <<"delete@affinity.test">>, + {ok, Customer} = cs_client:find_or_create_customer_by_email(PartyRef, Email, Client), + CustomerID = Customer#customer_Customer.id, + {ok, Affinity} = bind_affinity(CustomerID, 5, 50, undefined, Client), + {ok, ok} = cs_client:delete_customer(CustomerID, Client), + ?assertEqual( + [{Affinity#customer_TerminalAffinity.bind_seq, <<"customer_deleted">>}], + released_affinities(CustomerID) + ), + {ok, Recreated} = cs_client:find_or_create_customer_by_email(PartyRef, Email, Client), + RecreatedID = Recreated#customer_Customer.id, + ?assertNotEqual(CustomerID, RecreatedID), + ?assertEqual({ok, []}, cs_client:get_terminal_affinities(RecreatedID, Client)), + ok. + %% Bank Card Storage Tests create_bank_card_test(Config) -> @@ -479,3 +841,77 @@ invalidate_recurrent_token_test(Config) -> {ok, Tokens} = cs_client:get_recurrent_tokens(BankCardId, Client), ?assertEqual(0, length(Tokens)), ok. + +%% Internal functions + +create_affinity_customer(PartyID, Client) -> + {ok, Customer} = cs_client:create_customer( + #customer_CustomerParams{party_ref = #domain_PartyConfigRef{id = PartyID}}, + Client + ), + Customer#customer_Customer.id. + +bind_affinity(CustomerID, ProviderID, TerminalID, Ttl, Client) -> + cs_client:bind_terminal_affinity( + #customer_TerminalAffinityParams{ + customer_id = CustomerID, + provider_ref = #domain_ProviderRef{id = ProviderID}, + terminal_ref = #domain_TerminalRef{id = TerminalID}, + ttl = Ttl + }, + Client + ). + +terminal_key(ProviderID, TerminalID) -> + #customer_ProviderTerminalKey{ + provider_ref = #domain_ProviderRef{id = ProviderID}, + terminal_ref = #domain_TerminalRef{id = TerminalID} + }. + +affinity_key(#customer_TerminalAffinity{provider_ref = ProviderRef, terminal_ref = TerminalRef}) -> + {ProviderRef#domain_ProviderRef.id, TerminalRef#domain_TerminalRef.id}. + +last_used_at(#customer_TerminalAffinity{last_used_at = Timestamp}) -> + calendar:rfc3339_to_system_time(binary_to_list(Timestamp), [{unit, microsecond}]). + +%% Direct database access, to observe what the API deliberately does not expose + +count_customers_by_email(Email) -> + Query = + """ + SELECT count(*) + FROM customer + WHERE email = $1 + AND deleted_at IS NULL + """, + [{Count}] = select(Query, [Email]), + Count. + +released_affinities(CustomerID) -> + Query = + """ + SELECT bind_seq, released_reason + FROM terminal_affinity + WHERE customer_id = $1::uuid + AND released_at IS NOT NULL + ORDER BY bind_seq + """, + select(Query, [CustomerID]). + +backdate_affinities(CustomerID, BoundAge, LastUsedAge) -> + Query = + """ + UPDATE terminal_affinity + SET bound_at = NOW() - ($2::int * interval '1 second'), + last_used_at = NOW() - ($3::int * interval '1 second') + WHERE customer_id = $1::uuid + AND released_at IS NULL + """, + {ok, Count} = epg_pool:query(default_pool, Query, [CustomerID, BoundAge, LastUsedAge]), + Count. + +select(Query, Params) -> + case epg_pool:query(default_pool, Query, Params) of + {ok, _Columns, Rows} -> Rows; + {ok, _Count, _Columns, Rows} -> Rows + end. diff --git a/rebar.config b/rebar.config index 1df5189..854f2ee 100644 --- a/rebar.config +++ b/rebar.config @@ -25,7 +25,7 @@ {scoper, {git, "https://github.com/valitydev/scoper.git", {tag, "v1.1.0"}}}, {erl_health, {git, "https://github.com/valitydev/erlang-health.git", {branch, "master"}}}, {woody, {git, "https://github.com/valitydev/woody_erlang.git", {tag, "v1.1.1"}}}, - {damsel, {git, "https://github.com/valitydev/damsel.git", {tag, "v2.2.32"}}}, + {damsel, {git, "https://github.com/valitydev/damsel.git", {branch, "XYZ-447/routing_affinity"}}}, %% PostgreSQL connector (includes epgsql, epgsql_pool, herd) {epg_connector, {git, "https://github.com/valitydev/epg_connector.git", {tag, "v0.0.5"}}}, diff --git a/rebar.lock b/rebar.lock index bc4c88a..f9c00a7 100644 --- a/rebar.lock +++ b/rebar.lock @@ -21,7 +21,7 @@ {<<"ctx">>,{pkg,<<"ctx">>,<<"0.6.0">>},2}, {<<"damsel">>, {git,"https://github.com/valitydev/damsel.git", - {ref,"31495ce9d95c5d1b627b349c01d9937a5ef0231c"}}, + {ref,"cb5e3150582f92d7873c5b51cc37210b3e5b52aa"}}, 0}, {<<"epg_connector">>, {git,"https://github.com/valitydev/epg_connector.git", From db83b657d499dc5218867790ed8e7f7fbb52d014 Mon Sep 17 00:00:00 2001 From: Rustem Shaydullin Date: Fri, 4 Sep 2026 20:17:42 +0500 Subject: [PATCH 2/2] Bump damsel to change deadline location --- apps/cs/src/cs_terminal_affinity.erl | 32 +++++++------- apps/cs/src/cs_terminal_affinity_database.erl | 19 ++++++++- apps/cs/test/cs_integration_SUITE.erl | 42 +++++++------------ rebar.lock | 2 +- 4 files changed, 47 insertions(+), 48 deletions(-) diff --git a/apps/cs/src/cs_terminal_affinity.erl b/apps/cs/src/cs_terminal_affinity.erl index 15e61c2..b8fae8d 100644 --- a/apps/cs/src/cs_terminal_affinity.erl +++ b/apps/cs/src/cs_terminal_affinity.erl @@ -98,36 +98,34 @@ release_by_terminal(#customer_ProviderTerminalKey{} = Key, Reason) -> %% Internal functions +%% Every ttl variant names the moment an affinity expires; here it is turned into +%% what the database layer compares: a cutoff for a base column, or a verdict that +%% the active affinity is already expired regardless of its bases. -spec ttl_to_cutoff(ttl()) -> {ok, cutoff()} | {error, invalid_request}. ttl_to_cutoff(undefined) -> {ok, undefined}; -ttl_to_cutoff({Base, Timer}) when Base =:= since_bound; Base =:= since_last_use -> - case timer_to_timestamp(Timer) of - {ok, Timestamp} -> {ok, {Base, Timestamp}}; - {error, _} = Error -> Error - end; -ttl_to_cutoff(_Ttl) -> - {error, invalid_request}. - --spec timer_to_timestamp(dmsl_base_thrift:'Timer'()) -> {ok, binary()} | {error, invalid_request}. -timer_to_timestamp({timeout, Timeout}) when is_integer(Timeout), Timeout >= 0 -> +ttl_to_cutoff({Base, Timeout}) when + (Base =:= since_bound orelse Base =:= since_last_use), is_integer(Timeout), Timeout >= 0 +-> Cutoff = erlang:system_time(second) - Timeout, - {ok, list_to_binary(calendar:system_time_to_rfc3339(Cutoff, [{offset, "Z"}]))}; -timer_to_timestamp({deadline, Deadline}) when is_binary(Deadline) -> + {ok, {Base, list_to_binary(calendar:system_time_to_rfc3339(Cutoff, [{offset, "Z"}]))}}; +ttl_to_cutoff({deadline, Deadline}) when is_binary(Deadline) -> %% Parsed here rather than left to the PostgreSQL timestamptz parser: the latter %% turns a malformed deadline into a transaction error (a system error to the %% caller instead of the declared InvalidRequest) and silently accepts the special %% values 'now' / 'infinity' / 'yesterday', which would expire every affinity. - %% Reformatting with an explicit offset also keeps the cutoff independent of the - %% session TimeZone, which an offsetless timestamp would otherwise depend on. + %% An absolute deadline is indifferent to bound_at / last_used_at: once it has + %% passed, the active affinity is expired, whatever its bases say. try calendar:rfc3339_to_system_time(binary_to_list(Deadline), [{unit, microsecond}]) of Micro -> - Formatted = calendar:system_time_to_rfc3339(Micro, [{unit, microsecond}, {offset, "Z"}]), - {ok, list_to_binary(Formatted)} + case Micro =< erlang:system_time(microsecond) of + true -> {ok, expired}; + false -> {ok, undefined} + end catch _:_ -> {error, invalid_request} end; -timer_to_timestamp(_Timer) -> +ttl_to_cutoff(_Ttl) -> {error, invalid_request}. -spec key_to_json(provider_terminal_key()) -> {binary(), binary()}. diff --git a/apps/cs/src/cs_terminal_affinity_database.erl b/apps/cs/src/cs_terminal_affinity_database.erl index af12ef6..a8b382d 100644 --- a/apps/cs/src/cs_terminal_affinity_database.erl +++ b/apps/cs/src/cs_terminal_affinity_database.erl @@ -19,8 +19,10 @@ bound_at := term(), last_used_at := term() }. -%% Expiration base and the cutoff it is compared against; `undefined' means no ttl. --type cutoff() :: undefined | {since_bound | since_last_use, binary()}. +%% What bind/4 releases before writing: nothing, the active affinity whatever its +%% bases (an absolute deadline has passed), or the one whose base column precedes +%% the cutoff timestamp. +-type cutoff() :: undefined | expired | {since_bound | since_last_use, binary()}. -export_type([customer_id/0, affinity/0, cutoff/0]). @@ -105,6 +107,19 @@ release_by_terminal(ProviderRef, TerminalRef, Reason) -> release_expired(_Conn, _CustomerID, _ProviderRef, _TerminalRef, undefined) -> ok; +release_expired(Conn, CustomerID, ProviderRef, TerminalRef, expired) -> + Query = """ + UPDATE terminal_affinity + SET released_at = NOW(), released_reason = 'expired' + WHERE customer_id = $1::uuid + AND provider_ref = $2 + AND terminal_ref = $3 + AND released_at IS NULL + """, + case epg_pool:query(Conn, Query, [CustomerID, ProviderRef, TerminalRef]) of + {ok, _} -> ok; + {error, Reason} -> rollback(Reason) + end; release_expired(Conn, CustomerID, ProviderRef, TerminalRef, {Base, CutoffAt}) -> Query = """ UPDATE terminal_affinity diff --git a/apps/cs/test/cs_integration_SUITE.erl b/apps/cs/test/cs_integration_SUITE.erl index ee43c99..99fbf8e 100644 --- a/apps/cs/test/cs_integration_SUITE.erl +++ b/apps/cs/test/cs_integration_SUITE.erl @@ -58,7 +58,6 @@ bind_terminal_affinity_ttl_since_bound_test/1, bind_terminal_affinity_ttl_since_last_use_test/1, bind_terminal_affinity_ttl_since_last_use_expires_test/1, - bind_terminal_affinity_ttl_since_last_use_deadline_test/1, bind_terminal_affinity_ttl_deadline_test/1, bind_terminal_affinity_invalid_ttl_test/1, release_terminal_affinity_test/1, @@ -110,7 +109,6 @@ groups() -> bind_terminal_affinity_ttl_since_bound_test, bind_terminal_affinity_ttl_since_last_use_test, bind_terminal_affinity_ttl_since_last_use_expires_test, - bind_terminal_affinity_ttl_since_last_use_deadline_test, bind_terminal_affinity_ttl_deadline_test, bind_terminal_affinity_invalid_ttl_test, release_terminal_affinity_test, @@ -606,7 +604,7 @@ bind_terminal_affinity_ttl_since_bound_test(Config) -> CustomerID = create_affinity_customer(<<"party-affinity-ttl-bound">>, Client), {ok, First} = bind_affinity(CustomerID, 2, 20, undefined, Client), 1 = backdate_affinities(CustomerID, ?TEN_DAYS, ?TEN_DAYS), - {ok, Second} = bind_affinity(CustomerID, 2, 20, {since_bound, {timeout, 86400}}, Client), + {ok, Second} = bind_affinity(CustomerID, 2, 20, {since_bound, 86400}, Client), ?assert(Second#customer_TerminalAffinity.bind_seq > First#customer_TerminalAffinity.bind_seq), ?assertEqual([{First#customer_TerminalAffinity.bind_seq, <<"expired">>}], released_affinities(CustomerID)), {ok, [Live]} = cs_client:get_terminal_affinities(CustomerID, Client), @@ -619,11 +617,11 @@ bind_terminal_affinity_ttl_since_last_use_test(Config) -> CustomerID = create_affinity_customer(<<"party-affinity-ttl-last-use">>, Client), {ok, First} = bind_affinity(CustomerID, 2, 21, undefined, Client), 1 = backdate_affinities(CustomerID, ?TEN_DAYS, 0), - {ok, Second} = bind_affinity(CustomerID, 2, 21, {since_last_use, {timeout, 86400}}, Client), + {ok, Second} = bind_affinity(CustomerID, 2, 21, {since_last_use, 86400}, Client), ?assertEqual(First#customer_TerminalAffinity.bind_seq, Second#customer_TerminalAffinity.bind_seq), ?assertEqual([], released_affinities(CustomerID)), %% bound_at is still stale, so the hard TTL does expire the very same row - {ok, Third} = bind_affinity(CustomerID, 2, 21, {since_bound, {timeout, 86400}}, Client), + {ok, Third} = bind_affinity(CustomerID, 2, 21, {since_bound, 86400}, Client), ?assert(Third#customer_TerminalAffinity.bind_seq > First#customer_TerminalAffinity.bind_seq), ?assertEqual([{First#customer_TerminalAffinity.bind_seq, <<"expired">>}], released_affinities(CustomerID)), ok. @@ -635,26 +633,11 @@ bind_terminal_affinity_ttl_since_last_use_expires_test(Config) -> {ok, First} = bind_affinity(CustomerID, 2, 23, undefined, Client), %% bound_at stays fresh, so only the sliding base is stale 1 = backdate_affinities(CustomerID, 0, ?TEN_DAYS), - {ok, Second} = bind_affinity(CustomerID, 2, 23, {since_last_use, {timeout, 86400}}, Client), + {ok, Second} = bind_affinity(CustomerID, 2, 23, {since_last_use, 86400}, Client), ?assert(Second#customer_TerminalAffinity.bind_seq > First#customer_TerminalAffinity.bind_seq), ?assertEqual([{First#customer_TerminalAffinity.bind_seq, <<"expired">>}], released_affinities(CustomerID)), ok. -%% The remaining kind x form combination: sliding base against an absolute cutoff -bind_terminal_affinity_ttl_since_last_use_deadline_test(Config) -> - Client = ?config(client, Config), - CustomerID = create_affinity_customer(<<"party-affinity-ttl-last-use-deadline">>, Client), - {ok, First} = bind_affinity(CustomerID, 2, 24, undefined, Client), - Past = {since_last_use, {deadline, <<"2000-01-01T00:00:00Z">>}}, - {ok, Second} = bind_affinity(CustomerID, 2, 24, Past, Client), - ?assertEqual(First#customer_TerminalAffinity.bind_seq, Second#customer_TerminalAffinity.bind_seq), - ?assertEqual([], released_affinities(CustomerID)), - Future = {since_last_use, {deadline, <<"2100-01-01T00:00:00Z">>}}, - {ok, Third} = bind_affinity(CustomerID, 2, 24, Future, Client), - ?assert(Third#customer_TerminalAffinity.bind_seq > First#customer_TerminalAffinity.bind_seq), - ?assertEqual([{First#customer_TerminalAffinity.bind_seq, <<"expired">>}], released_affinities(CustomerID)), - ok. - %% A deadline is parsed before it reaches the database: neither garbage nor the special %% values PostgreSQL would accept ('now', 'infinity', ...) may reach the cutoff bind_terminal_affinity_invalid_ttl_test(Config) -> @@ -664,30 +647,33 @@ bind_terminal_affinity_invalid_ttl_test(Config) -> lists:foreach( fun(Deadline) -> {exception, #base_InvalidRequest{}} = - bind_affinity(CustomerID, 2, 25, {since_bound, {deadline, Deadline}}, Client) + bind_affinity(CustomerID, 2, 25, {deadline, Deadline}, Client) end, %% The last one is a timestamp without an offset: its meaning would depend on %% the session TimeZone rather than on what the caller sent [<<"not-a-timestamp">>, <<"now">>, <<"infinity">>, <<"yesterday">>, <<"2026-01-01T00:00:00">>] ), - {exception, #base_InvalidRequest{}} = bind_affinity(CustomerID, 2, 25, {since_bound, {timeout, -5}}, Client), + {exception, #base_InvalidRequest{}} = bind_affinity(CustomerID, 2, 25, {since_bound, -5}, Client), + {exception, #base_InvalidRequest{}} = bind_affinity(CustomerID, 2, 25, {since_last_use, -5}, Client), %% Nothing was released and nothing was rebound ?assertEqual([], released_affinities(CustomerID)), {ok, [Live]} = cs_client:get_terminal_affinities(CustomerID, Client), ?assertEqual(First#customer_TerminalAffinity.bind_seq, Live#customer_TerminalAffinity.bind_seq), ok. -%% Deadline form: the base is compared against an absolute cutoff +%% An absolute deadline expires the affinity once passed, however fresh its bases are; +%% until then it is inert bind_terminal_affinity_ttl_deadline_test(Config) -> Client = ?config(client, Config), CustomerID = create_affinity_customer(<<"party-affinity-ttl-deadline">>, Client), {ok, First} = bind_affinity(CustomerID, 2, 22, undefined, Client), - Past = {since_bound, {deadline, <<"2000-01-01T00:00:00Z">>}}, - {ok, Second} = bind_affinity(CustomerID, 2, 22, Past, Client), + Future = {deadline, <<"2100-01-01T00:00:00Z">>}, + {ok, Second} = bind_affinity(CustomerID, 2, 22, Future, Client), ?assertEqual(First#customer_TerminalAffinity.bind_seq, Second#customer_TerminalAffinity.bind_seq), ?assertEqual([], released_affinities(CustomerID)), - Future = {since_bound, {deadline, <<"2100-01-01T00:00:00Z">>}}, - {ok, Third} = bind_affinity(CustomerID, 2, 22, Future, Client), + %% Nothing is backdated: the row is seconds old and still gets expired + Past = {deadline, <<"2000-01-01T00:00:00Z">>}, + {ok, Third} = bind_affinity(CustomerID, 2, 22, Past, Client), ?assert(Third#customer_TerminalAffinity.bind_seq > First#customer_TerminalAffinity.bind_seq), ?assertEqual([{First#customer_TerminalAffinity.bind_seq, <<"expired">>}], released_affinities(CustomerID)), ok. diff --git a/rebar.lock b/rebar.lock index f9c00a7..e8ac736 100644 --- a/rebar.lock +++ b/rebar.lock @@ -21,7 +21,7 @@ {<<"ctx">>,{pkg,<<"ctx">>,<<"0.6.0">>},2}, {<<"damsel">>, {git,"https://github.com/valitydev/damsel.git", - {ref,"cb5e3150582f92d7873c5b51cc37210b3e5b52aa"}}, + {ref,"3bdfd4cd0022611dbf3477efdabfedf16d5e2786"}}, 0}, {<<"epg_connector">>, {git,"https://github.com/valitydev/epg_connector.git",