diff --git a/.iex.exs b/.iex.exs index 81ba49e..8d41650 100644 --- a/.iex.exs +++ b/.iex.exs @@ -22,7 +22,7 @@ defmodule HomeResolver do |> ExUssd.set(resolve: &business_account/2) end - def home(menu, _payload) do + def ussd_init(menu, _payload) do data = %{user_name: "john_doe", account_type: :personal} menu |> ExUssd.set(title: "Welcome") @@ -55,6 +55,15 @@ defmodule HomeResolver do menu |> ExUssd.set(title: "You have Entered the Secret Number, 5555") |> ExUssd.set(should_close: true) + else + menu + |> ExUssd.set(error: "You have Entered the Wrong Number") end end + + def ussd_after_callback(%{error: true} = menu, _payload, %{attempt: %{count: 3}}) do + menu + |> ExUssd.set(title: "Account is locked, Dial *234# to reset your account") + |> ExUssd.set(should_close: true) + end end \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d47b3e..f5609f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v.1.0.1 - 2021-10-02 + + - Metadata internal bug fix [#38](https://github.com/beamkenya/ex_ussd/pull/38) + ## v1.0.0 - 2021-10-02 - Add support for Zero Based menu list diff --git a/README.md b/README.md index dc0f9fc..0cdd3b8 100644 --- a/README.md +++ b/README.md @@ -9,24 +9,11 @@ Goals: - Detailed error messages and documentation. - A focus on robustness and production-level performance. -## Table of contents - -- [Why Use ExUssd](#why-use-exussd) -- [Documentation](#documentation) -- [Installation](#installation) -- [Configuration](#configuration) -- [Usage](#usage) -- [Contribution](#contribution) -- [Contributors](#contributors) -- [Licence](#licence) - ## Why Use ExUssd? ExUssd lets you create simple, flexible, and customizable USSD interface. Under the hood ExUssd uses Elixir Registry to create and route individual USSD session. -https://user-images.githubusercontent.com/23293150/124460086-95ebf080-dd97-11eb-87ab-605f06291563.mp4 - ## Documentation The docs can be found at [https://hexdocs.pm/ex_ussd](https://hexdocs.pm/ex_ussd) @@ -39,7 +26,7 @@ by adding `ex_ussd` to your list of dependencies in `mix.exs`: ```elixir defp deps do [ - {:ex_ussd, "~> 1.0.0"} + {:ex_ussd, "~> 1.0.1"} ] end ``` @@ -78,15 +65,15 @@ defmodule ApiWeb.HomeResolver do ExUssd.set(menu, title: "Enter your PIN") end - def ussd_callback(menu, payload, %{attempt: attempt}) do + def ussd_callback(menu, payload,%{attempt: %{count: count}}) do if payload.text == "5555" do ExUssd.set(menu, resolve: &success_menu/2) else - ExUssd.set(menu, error: "Wrong PIN, #{3 - attempt} attempt left\n") + ExUssd.set(menu, error: "Wrong PIN, #{2 - count} attempt left\n") end end - def ussd_after_callback(%{error: true} = menu, _payload, %{attempt: 3}) do + def ussd_after_callback(%{error: true} = menu, _payload, %{attempt: %{count: 3}}) do menu |> ExUssd.set(title: "Account is locked, Dial *234# to reset your account") |> ExUssd.set(should_close: true) diff --git a/lib/ex_ussd.ex b/lib/ex_ussd.ex index 7bd3d3a..b1aea70 100644 --- a/lib/ex_ussd.ex +++ b/lib/ex_ussd.ex @@ -67,7 +67,7 @@ defmodule ExUssd do Example: ```elixir - %{attempt: 1, invoked_at: ~U[2024-09-25 09:10:15Z], route: "*555*1#", text: "1"} + %{attempt: %{count: 2, input: ["wrong2", "wrong1"]}, invoked_at: ~U[2024-09-25 09:10:15Z], route: "*555*1#", text: "1"} ``` """ @type metadata() :: map() @@ -257,7 +257,7 @@ defmodule ExUssd do ...> ExUssd.set(menu, error: "Wrong PIN\\n") ...> end ...> end - ...> def ussd_after_callback(%{error: true} = menu, _payload, %{attempt: 3}) do + ...> def ussd_after_callback(%{error: true} = menu, _payload, %{attempt: %{count: 3}}) do ...> menu ...> |> ExUssd.set(title: "Account is locked, you have entered the wrong PIN 3 times") ...> |> ExUssd.set(should_close: true) @@ -270,7 +270,7 @@ defmodule ExUssd do ...> end iex> # To simulate a user entering wrong PIN 3 times. iex> menu = ExUssd.new(name: "PIN", resolve: AppWeb.HomeResolver) - iex> ExUssd.to_string!(menu, :ussd_after_callback, payload: %{text: "5556", attempt: 3}) + iex> ExUssd.to_string!(menu, :ussd_after_callback, payload: %{text: "5556", attempt: %{count: 3}}) "Account is locked, you have entered the wrong PIN 3 times" """ @callback ussd_after_callback( @@ -432,13 +432,15 @@ defmodule ExUssd do iex> resolve = fn menu, _payload -> ...> menu ...> |> ExUssd.set(title: "Menu title") - ...> |> ExUssd.add(ExUssd.new(name: "offers", resolve: &(ExUssd.set(&1, title: "offers")))) + ...> |> ExUssd.add(ExUssd.new(name: "offers", resolve: fn menu, _ -> ExUssd.set(menu, title: "offers") end)) ...> |> ExUssd.add(ExUssd.new(name: "option 1", resolve: &(ExUssd.set(&1, title: "option 1")))) ...> |> ExUssd.add(ExUssd.new(name: "option 2", resolve: &(ExUssd.set(&1, title: "option 2")))) ...> end iex> menu = ExUssd.new(name: "HOME", is_zero_based: true, resolve: resolve) iex> ExUssd.to_string!(menu, []) "Menu title\\n0:offers\\n1:option 1\\n2:option 2" + iex> ExUssd.to_string!(menu, [simulate: true, payload: %{text: "0"}]) + "offers" NOTE: `ExUssd.new/1` can be used to create a menu with a callback function. @@ -495,13 +497,17 @@ defmodule ExUssd do ...> def product_b(menu, _payload), do: menu |> ExUssd.set(title: "selected product b") ...> def product_c(menu, _payload), do: menu |> ExUssd.set(title: "selected product c") ...> def account(%{data: %{type: :personal, name: name}} = menu, _payload) do - ...> # Get Personal account details, then set as data + ...> # Should be stateless, don't put call functions with side effect (Insert to DB, fetch) + ...> # Because it will be called every time the menu is rendered because the menu `:name` is dynamic + ...> # See `ExUssd.new/2` for more details where `:name` is static. ...> menu ...> |> ExUssd.set(name: "Personal account") ...> |> ExUssd.set(resolve: &(ExUssd.set(&1, title: "Personal account"))) ...> end ...> def account(%{data: %{type: :business, name: name}} = menu, _payload) do - ...> # Get Business account details, then set as data + ...> # Should be stateless, don't put call functions with side effect (Insert to DB, fetch) + ...> # Because it will be called every time the menu is rendered because the menu `:name` is dynamic + ...> # See `ExUssd.new/2` for more details where `:name` is static. ...> menu ...> |> ExUssd.set(name: "Business account") ...> |> ExUssd.set(resolve: &(ExUssd.set(&1, title: "Business account"))) diff --git a/lib/ex_ussd/executer.ex b/lib/ex_ussd/executer.ex index 75da251..971e81e 100644 --- a/lib/ex_ussd/executer.ex +++ b/lib/ex_ussd/executer.ex @@ -73,10 +73,10 @@ defmodule ExUssd.Executer do when not is_nil(navigate) do menu |> Map.put(:resolve, navigate) - |> get_next_menu(payload, opts) + |> get_next_menu(menu, payload, Keyword.merge(opts, navigate: true)) end - def execute_callback(%ExUssd{resolve: resolve} = menu, payload, opts) + def execute_callback(%ExUssd{resolve: resolve, menu_list: menu_list} = menu, payload, opts) when is_atom(resolve) do if function_exported?(resolve, :ussd_callback, 3) do metadata = @@ -87,7 +87,7 @@ defmodule ExUssd.Executer do %{ route: "*test#", invoked_at: DateTime.truncate(DateTime.utc_now(), :second), - attempt: 1 + attempt: %{count: 1} }, payload ) @@ -95,12 +95,22 @@ defmodule ExUssd.Executer do try do with %ExUssd{error: error} = current_menu <- - apply(resolve, :ussd_callback, [%{menu | resolve: nil}, payload, metadata]) do + apply(resolve, :ussd_callback, [ + %{menu | resolve: nil, menu_list: []}, + payload, + metadata + ]) do if is_bitstring(error) do - build_response_menu(:halt, current_menu, menu, payload, opts) + if Keyword.get(opts, :state) do + ExUssd.Registry.add_attempt(payload[:session_id], payload[:text]) + end + + if Enum.empty?(menu_list) do + build_response_menu(:halt, current_menu, menu, payload, opts) + end else build_response_menu(:ok, current_menu, menu, payload, opts) - |> get_next_menu(payload, opts) + |> get_next_menu(menu, payload, opts) end end rescue @@ -144,7 +154,7 @@ defmodule ExUssd.Executer do %{ route: "*test#", invoked_at: DateTime.truncate(DateTime.utc_now(), :second), - attempt: 3 + attempt: %{count: 3} }, payload ) @@ -153,7 +163,7 @@ defmodule ExUssd.Executer do try do with %ExUssd{error: error} = current_menu <- apply(resolve, :ussd_after_callback, [ - %{menu | resolve: nil, error: error_state}, + %{menu | resolve: nil, menu_list: [], error: error_state}, payload, metadata ]) do @@ -161,7 +171,7 @@ defmodule ExUssd.Executer do build_response_menu(:halt, current_menu, menu, payload, opts) else build_response_menu(:ok, current_menu, menu, payload, opts) - |> get_next_menu(payload, opts) + |> get_next_menu(menu, payload, opts) end end rescue @@ -189,14 +199,16 @@ defmodule ExUssd.Executer do %{route: route} = ExUssd.Route.get_route(payload) %{session_id: session} = payload ExUssd.Registry.add_route(session, route) - end - {:ok, %{current_menu | parent: fn -> menu end}} + {:ok, %{current_menu | parent: fn -> menu end}} + else + {:ok, current_menu} + end end - defp get_next_menu(menu, payload, opts) do + defp get_next_menu(menu, parent, payload, opts) do fun = fn - %ExUssd{orientation: orientation, data: data, resolve: resolve} -> + %ExUssd{orientation: orientation, data: data, resolve: resolve} when not is_nil(resolve) -> new_menu = ExUssd.new( orientation: orientation, @@ -207,7 +219,11 @@ defmodule ExUssd.Executer do current_menu = execute_init_callback!(new_menu, payload) - build_response_menu(:ok, current_menu, menu, payload, opts) + if Keyword.get(opts, :navigate) do + build_response_menu(:ok, current_menu, menu, payload, opts) + else + {:ok, %{current_menu | parent: fn -> parent end}} + end response -> response @@ -218,9 +234,6 @@ defmodule ExUssd.Executer do {:ok, %ExUssd{resolve: resolve} = menu} when not is_nil(resolve) -> menu - %ExUssd{} = menu -> - menu - menu -> menu end diff --git a/lib/ex_ussd/navigation.ex b/lib/ex_ussd/navigation.ex index 2f317c1..44e8495 100644 --- a/lib/ex_ussd/navigation.ex +++ b/lib/ex_ussd/navigation.ex @@ -141,15 +141,9 @@ defmodule ExUssd.Navigation do @spec get_menu(integer(), map(), ExUssd.t(), map()) :: {:ok | :halt, ExUssd.t()} defp get_menu(pos, route, menu, payload) - defp get_menu( - _pos, - route, - %ExUssd{default_error: error, menu_list: []} = menu, - %{session_id: session} = payload - ) do + defp get_menu(_pos, _route, %ExUssd{default_error: error, menu_list: []} = menu, payload) do with response when not is_menu(response) <- Executer.execute_callback(menu, payload) do - Registry.add_attempt(session, route[:text]) {:halt, %{menu | error: error}} end end @@ -179,7 +173,6 @@ defmodule ExUssd.Navigation do {:ok, %{current_menu | parent: fn -> parent_menu end}} nil -> - Registry.add_attempt(session, route[:text]) {:halt, %{menu | error: default_error}} end end diff --git a/lib/ex_ussd/utils.ex b/lib/ex_ussd/utils.ex index 5490b66..7cc9272 100644 --- a/lib/ex_ussd/utils.ex +++ b/lib/ex_ussd/utils.ex @@ -120,7 +120,7 @@ defmodule ExUssd.Utils do %{attempt: attempt, invoked_at: invoked_at, route: routes_string, text: text} end - def get_menu(%ExUssd{} = menu, opts) do + def get_menu(%ExUssd{is_zero_based: is_zero_based} = menu, opts) do payload = Keyword.get(opts, :payload, %{text: "set_init_text"}) position = @@ -135,7 +135,9 @@ defmodule ExUssd.Utils do current_menu = get_menu(menu, :ussd_callback, opts) if error do - case Enum.at(Enum.reverse(menu_list), position - 1) do + from = if(is_zero_based, do: 0, else: 1) + + case Enum.at(Enum.reverse(menu_list), position - from) do nil -> get_menu(%{menu | error: true}, :ussd_after_callback, opts) diff --git a/mix.exs b/mix.exs index a7039c7..08de0c2 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule ExUssd.MixProject do use Mix.Project @source_url "https://github.com/beamkenya/ex_ussd.git" - @version "1.0.0" + @version "1.0.1" def project do [ diff --git a/test/ex_ussd/op_test.exs b/test/ex_ussd/op_test.exs index 9957041..9a7f539 100644 --- a/test/ex_ussd/op_test.exs +++ b/test/ex_ussd/op_test.exs @@ -289,4 +289,58 @@ defmodule ExUssd.OpTest do }) end end + + describe "metadata" do + defmodule PinResolver do + use ExUssd + + def ussd_init(menu, _) do + ExUssd.set(menu, title: "Enter your PIN") + end + + def ussd_callback(menu, payload, %{attempt: %{count: count}}) do + if payload.text == "5555" do + ExUssd.set(menu, resolve: &success_menu/2) + else + ExUssd.set(menu, error: "Wrong PIN, #{2 - count} attempt left\n") + end + end + + def ussd_after_callback(%{error: true} = menu, _payload, %{attempt: %{count: 3}}) do + menu + |> ExUssd.set(title: "Account is locked, Dial *234# to reset your account") + |> ExUssd.set(should_close: true) + end + + def success_menu(menu, _) do + menu + |> ExUssd.set(title: "You have Entered the Secret Number, 5555") + |> ExUssd.set(should_close: true) + end + end + + setup do + %{ + menu: ExUssd.new(name: Faker.Company.name(), resolve: PinResolver), + session: "#{System.unique_integer()}" + } + end + + test "successfully navigates to the first menu", %{menu: menu, session: session} do + assert {:ok, %{menu_string: "Enter your PIN", should_close: false}} == + ExUssd.goto(%{ + payload: %{session_id: session, text: "*444#", service_code: "*444#"}, + menu: menu + }) + end + + test "successfully render the first error message", %{menu: menu, session: session} do + assert {:ok, + %{menu_string: "Wrong PIN, 2 attempt left\nEnter your PIN", should_close: false}} == + ExUssd.goto(%{ + payload: %{session_id: session, text: "2211", service_code: "*444#"}, + menu: menu + }) + end + end end