diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..10a491d --- /dev/null +++ b/.credo.exs @@ -0,0 +1,189 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/", + "web/", + "apps/*/lib/", + "apps/*/src/", + "apps/*/test/", + "apps/*/web/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: false, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 2]}, + {Credo.Check.Design.TagFIXME, []}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, false}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 16]}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + # {Credo.Check.Refactor.MapInto, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + # {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.MixEnv, false}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.UnsafeExec, []}, + + # + # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) + + # + # Controversial and experimental checks (opt-in, just replace `false` with `[]`) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, + {Credo.Check.Consistency.UnusedVariableNames, false}, + {Credo.Check.Design.DuplicatedCode, false}, + {Credo.Check.Readability.AliasAs, false}, + {Credo.Check.Readability.BlockPipe, false}, + {Credo.Check.Readability.ImplTrue, false}, + {Credo.Check.Readability.MultiAlias, false}, + {Credo.Check.Readability.SeparateAliasRequire, false}, + {Credo.Check.Readability.SinglePipe, false}, + {Credo.Check.Readability.Specs, false}, + {Credo.Check.Readability.StrictModuleLayout, false}, + {Credo.Check.Readability.WithCustomTaggedTuple, false}, + {Credo.Check.Refactor.ABCSize, false}, + {Credo.Check.Refactor.AppendSingleItem, false}, + {Credo.Check.Refactor.DoubleBooleanNegation, false}, + {Credo.Check.Refactor.ModuleDependencies, false}, + {Credo.Check.Refactor.NegatedIsNil, false}, + {Credo.Check.Refactor.PipeChainStart, false}, + {Credo.Check.Refactor.VariableRebinding, false}, + {Credo.Check.Warning.LeakyEnvironment, false}, + {Credo.Check.Warning.MapGetUnsafePass, false}, + {Credo.Check.Warning.UnsafeToAtom, false} + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + ] +} diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..feea3a0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,37 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] + +**Smartphone (please complete the following information):** + +- Device: [e.g. iPhone6] +- OS: [e.g. iOS8.1] +- Browser [e.g. stock browser, safari] +- Version [e.g. 22] diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..3c21a13 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: 'Feature Request: ' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Work Involved** +What would would need to be done to deliver this? + +**Definition of Done** +A clear and concise description of what done would look like for this issue + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml new file mode 100644 index 0000000..bbf56a5 --- /dev/null +++ b/.github/workflows/elixir.yml @@ -0,0 +1,66 @@ +name: Elixir CI Workflow ๐Ÿงช + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + elixir_ci: + name: Elixir CI ๐Ÿงช + runs-on: ubuntu-latest + env: + MIX_ENV: test + + steps: + - name: Checkout repository ๐Ÿ›Ž๏ธ + uses: actions/checkout@v2 + + - name: Install Erlang/OTP + Elixir ๐Ÿ—๏ธ + id: setup-beam + uses: erlef/setup-beam@v1 + with: + otp-version: '24.0' # version range or exact (required) + elixir-version: '1.12.0' # version range or exact (required) + # install-hex: true (default) + # install-rebar: true (default) + # outputs: ${steps.setup-beam.outputs.(opt, elixir, rebar3)-version} (exact version installed) + + - name: Restore dependency/build cache ๐Ÿ—ƒ๏ธ + uses: actions/cache@v2 + with: + path: | + deps + _build + # cache key is hierarchical: OS, otp-version, elixir-version, mix.lock + key: ${{ runner.os }}-mix-${{ steps.setup-beam.outputs.otp-version }}-${{ steps.setup-beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }} + # restore keys are tried on cache misses, and only match the key prefix + restore-keys: | + ${{ runner.os }}-mix-${{ steps.setup-beam.outputs.otp-version }}-${{ steps.setup-beam.outputs.elixir-version }}- + ${{ runner.os }}-mix-${{ steps.setup-beam.outputs.otp-version }}- + ${{ runner.os }}-mix- + + - name: Dependencies ๐Ÿ”— + run: | + mix deps.get + mix deps.compile + - name: Code ๐Ÿ”ง + run: | + mix format --check-formatted + mix compile --warnings-as-errors + mix credo --strict + mix dialyzer + mix docs + + - name: Test ๐Ÿฆบ + run: | + mix test --warnings-as-errors --cover --export-coverage default + mix test.coverage + - name: Artifacts ๐Ÿ“š + uses: actions/upload-artifact@v2 + with: + name: doc-cover + path: | + doc + cover \ No newline at end of file diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml new file mode 100644 index 0000000..68eb382 --- /dev/null +++ b/.github/workflows/greetings.yml @@ -0,0 +1,13 @@ +name: Greetings + +on: [pull_request_target, issues] + +jobs: + greeting: + runs-on: ubuntu-latest + steps: + - uses: actions/first-interaction@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + issue-message: "Thank you @${{ github.actor }} for your issue! We are glad you contributed!" + pr-message: "Thank you @${{ github.actor }} for your first pull Request!" \ No newline at end of file diff --git a/.github/workflows/hex-publish.yml b/.github/workflows/hex-publish.yml new file mode 100644 index 0000000..1dcb8be --- /dev/null +++ b/.github/workflows/hex-publish.yml @@ -0,0 +1,16 @@ +on: + push: + tags: + - '*' + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v2 + + - name: Publish to Hex.pm + uses: erlangpack/github-action@v1 + env: + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index b263cd1..0826505 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,38 @@ -/_build -/cover -/deps -/doc +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. /.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). *.ez -*.beam -/config/*.secret.exs -.elixir_ls/ + +# Ignore package tarball (built via "mix hex.build"). +ex_ussd-*.tar + + +# Temporary files for e.g. tests +/tmp + +# If NPM crashes, it generates a log, let's ignore it too. +npm-debug.log + +# The directory NPM downloads your dependencies sources to. +/assets/node_modules/ + +.elixir_ls +.DS_Store +.vcode +.idea diff --git a/.iex.exs b/.iex.exs new file mode 100644 index 0000000..3faacca --- /dev/null +++ b/.iex.exs @@ -0,0 +1,53 @@ +defmodule HomeResolver do + use ExUssd + + def product_a(menu, _payload), do: menu |> ExUssd.set(title: "selected product a") + 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: %{account_type: :personal}} = menu, _payload) do + menu + |> ExUssd.set(name: "personal Account") + |> ExUssd.set(resolve: &personal_account/2) + end + + def account(%{data: %{account_type: :business}} = menu, _payload) do + menu + |> ExUssd.set(name: "business Account") + |> ExUssd.set(resolve: &business_account/2) + end + + def home(menu, _payload) do + data = %{user_name: "john_doe", account_type: :personal} + menu + |> ExUssd.set(title: "Welcome") + |> ExUssd.set(data: data) + |> ExUssd.add(ExUssd.new(name: "Product A", resolve: &product_a/2)) + |> ExUssd.add(ExUssd.new(name: "Product B", resolve: &product_b/2)) + |> ExUssd.add(ExUssd.new(name: "Product C", resolve: &product_c/2)) + |> ExUssd.add(ExUssd.new(&account/2)) + |> ExUssd.add(ExUssd.new(name: "Enter Pin", resolve: __MODULE__)) + end + + def personal_account(%{data: %{user_name: user_name}} = menu, _payload) do + # send SMS notification + menu |> ExUssd.set(title: "This is #{user_name}'s personal account") + end + + def business_account(menu, _payload) do + menu |> ExUssd.set(title: "This is a business account") + end + + def ussd_init(menu, _) do + menu + |> ExUssd.set(title: "Enter your PIN") + end + + def ussd_callback(menu, payload, _metadata) do + if payload.text == "5555" do + menu + |> ExUssd.set(title: "You have Entered the Secret Number, 5555") + |> ExUssd.set(should_close: true) + end + end + end \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 0282cc2..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2020 BEAM Kenya - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index 66f3c17..1b019ad 100644 --- a/README.md +++ b/README.md @@ -1 +1,245 @@ -# ex_ussd \ No newline at end of file +# ExUssd + +[![Actions Status](https://github.com/beamkenya/ex_ussd/workflows/Elixir%20CI/badge.svg)](https://github.com/beamkenya/ex_ussd/actions) ![Hex.pm](https://img.shields.io/hexpm/v/ex_ussd) ![Hex.pm](https://img.shields.io/hexpm/dt/ex_ussd) + +Goals: +- An idiomatic, readable, and comfortable API for Elixir developers +- Extensibility based on small parts that do one thing well. +- 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). + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `ex_ussd` to your list of dependencies in `mix.exs`: + + +```elixir +defp deps do + [ + {:ex_ussd, "~> 1.0.0-rc-1"} + ] +end +``` + +## Configuration + +Add to your `config.exs` + + ```elixir +# config/config.exs +# TODO: This are example values, replace them with your own +config :ex_ussd, + nav: [ + ExUssd.Nav.new(type: :home, name: "HOME", match: "00", reverse: true, orientation: :vertical), + ExUssd.Nav.new(type: :back, name: "BACK", match: "0", right: 1), + ExUssd.Nav.new(type: :next, name: "MORE", match: "98") + ], + delimiter: ").", + default_error: "invalid input,try again\n" +``` + +## Usage + +### Settable Fields + +* **`:data`** Set data to pass through to next menu. N/B - ExUssd menu are stateful unless using ExUssd.new/2 with `:name` and `:resolve` as arguments; + ```elixir + data = %{name: "John Doe"} + # stateful + menu + |> ExUssd.set(data: data) + |> ExUssd.add(ExUssd.new("Check Balance", &check_balance/2)) + + # stateless + menu + |> ExUssd.add(ExUssd.new(data: data, name: "Check Balance", resolve: &check_balance/2)) + ``` + +* **`:delimiter`** Set's menu style delimiter. Default- `:` +* **`:default_error`** Default error shown on invalid input +* **`:error`** Set custom error message +* **`:name`** Sets the name of the menu +* **`:nav`** Its used to create a new ExUssd Nav menu +* **`:orientation`** Sets the menu orientation. Available option; + - `:horizontal` - Left to right. Blog/articles style menu + - `vertical` - Top to bottom(default) +* **`:resolve`** Navigates(invokes the next `ussd_init/2`) to the next menu +* **`:should_close`** Indicate whether to USSD session should end or continue +* **`:show_navigation`** Set show navigation menu. Default - `true` +* **`:split`** Set menu batch size. Default - 7 +* **`:title`** Set menu title + + +### ExUssd Callbacks + +ExUssd provides you with 3 callbacks + +* **`ussd_init/2`** + It's invoked once when the user navigates to that particular menu + +* **`ussd_callback/3`** + It's an optional callback that is invoked after `ussd_init/2` to validate the user input. + +* **`ussd_after_callback/3`** + It's an optional callback that is invoked after `ussd_callback/3` is invoked. + + +#### Example +Create a new module: + +```elixir +defmodule ApiWeb.HomeResolver do + use ExUssd + def ussd_init(menu, _) do + ExUssd.set(menu, title: "Enter your PIN") + end + + def ussd_callback(menu, payload, %{attempt: attempt}) do + if payload.text == "5555" do + ExUssd.set(menu, resolve: &success_menu/2) + else + ExUssd.set(menu, error: "Wrong PIN, attempt #{attempt}/3\n") + end + end + + def ussd_after_callback(%{error: true} = menu, _payload, %{attempt: 3}) do + menu + |> ExUssd.set(title: "Account is locked, you have entered the wrong PIN 3 times") + |> 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 +``` + +Let's test the different ExUssd callbacks with `ExUssd.to_string/3` + +create menu + +```elixir +menu = ExUssd.new(name: "PIN", resolve: ApiWeb.HomeResolver) +``` + +**`ussd_init/2`** + +```elixir +iex> ExUssd.to_string(menu, :ussd_init, []) +{:ok, %{menu_string: "Enter your PIN", should_close: false}} +``` + +**`ussd_callback/3`** + +```elixir +iex> ExUssd.to_string(menu, :ussd_callback, [payload: %{text: "5555"}, init_text: "1"]) +{:ok, %{menu_string: "You have Entered the Secret Number, 5555", should_close: true}} + +iex> ExUssd.to_string(menu, :ussd_callback, [payload: %{text: "42", attempt: 3}, init_text: "1"]) +{:ok, %{menu_string: "Wrong PIN, attempt 3/3\nEnter your PIN", should_close: false}} +``` + +**`ussd_after_callback/3`** + +```elixir +iex> ExUssd.to_string(menu, :ussd_after_callback, [payload: %{text: "42", attempt: 3}, init_text: "1"]) +{:ok, + %{ + menu_string: "Account is locked, you have entered the wrong PIN 3 times", + should_close: true + }} +``` + +### ExUssd Menu List +```elixir + defmodule HomeResolver do + use ExUssd + + def product_a(menu, _payload), do: menu |> ExUssd.set(title: "selected product a") + 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: %{account_type: :personal}} = menu, _payload) do + menu + |> ExUssd.set(name: "personal Account") + |> ExUssd.set(resolve: &personal_account/2) + end + + def account(%{data: %{account_type: :business}} = menu, _payload) do + menu + |> ExUssd.set(name: "business Account") + |> ExUssd.set(resolve: &business_account/2) + end + + def check_balance(%{data: %{account_type: account_type}} = menu, _payload) do + if (account_type == :personal) do + menu + |> ExUssd.set(resolve: &personal_account_balance/2) + else + menu + |> ExUssd.set(resolve: &business_account_balance/2) + end + end + + def home(menu, _payload) do + data = %{user_name: "john_doe", account_type: :personal} + menu + |> ExUssd.set(title: "Welcome") + |> ExUssd.set(data: data) + |> ExUssd.add(ExUssd.new(name: "Product A", resolve: &product_a/2)) + |> ExUssd.add(ExUssd.new(name: "Product B", resolve: &product_b/2)) + |> ExUssd.add(ExUssd.new(name: "Product C", resolve: &product_c/2)) + |> ExUssd.add(ExUssd.new(&account/2)) + |> ExUssd.add(ExUssd.new("Check Balance", &check_balance/2)) + |> ExUssd.add(ExUssd.new(name: "Enter Pin", resolve: __MODULE__)) + end + end +``` + + +## Contribution + +If you'd like to contribute, start by searching through the [issues](https://github.com/beamkenya/ex_ussd/issues) and [pull requests](https://github.com/beamkenya/ex_ussd/pulls) to see whether someone else has raised a similar idea or question. +If you don't see your idea listed, [Open an issue](https://github.com/beamkenya/ex_ussd/issues). + +Check the [Contribution guide](contributing.md) on how to contribute. + +## Contributors + +Auto-populated from: +[contributors-img](https://contributors-img.firebaseapp.com/image?repo=beamkenya/ex_ussd) + + + + + +## Licence + +ExUssd is released under [MIT License](https://github.com/appcues/exsentry/blob/master/LICENSE.txt) + +[![license](https://img.shields.io/github/license/mashape/apistatus.svg?style=for-the-badge)](#) + diff --git a/contributing.md b/contributing.md new file mode 100644 index 0000000..afcd53e --- /dev/null +++ b/contributing.md @@ -0,0 +1,26 @@ +## Guide + +If you don't see your idea listed, and you think it fits into the goals of this library, do the following: + +0. Find an issue that you are interested in addressing or a feature that you would like to add. +1. Fork the repository associated with the issue to your local GitHub organization. This means that you will have a copy of the repository under `your-GitHub-username/repository-name`. +2. Clone the repository to your local machine using `git clone https://github.com/beamkenya/ex_ussd.git` +3. Create a new branch for your fix using `git checkout -b your-branch-name-here`. +4. Make the appropriate changes for the issue you are trying to address or the feature that you want to add. +5. Use `git add insert-paths-of-changed-files-here` to add the file contents of the changed files to the "snapshot" git uses to manage the state of the project, also known as the index. +6. Use `git commit -m "Insert a short message of the changes made here"` to store the contents of the index with a descriptive message. +7. Push the changes to the remote repository using `git push origin your-branch-name-here`. +8. Submit a pull request to the upstream repository. +9. Title the pull request with a short description of the changes made and the issue or bug number associated with your change. For example, you can title an issue like so "Added more log outputting to resolve #4352". +10. In the description of the pull request, explain the changes that you made, any issues you think exist with the pull request you made, and any questions you have for the maintainers. It's OK if your pull request is not perfect (no pull request is), the reviewer will be able to help you fix any problems and improve it! +11. Wait for the pull request to be reviewed by a maintainer. +12. Make changes to the pull request if the reviewing maintainer recommends them. +13. Celebrate your success after your pull request is merged! + +## Testing + +Make sure you write tests to any `module` or `function` you add. Also add `doctests` + +## Style guide + +Ensure you run `mix format` to format the code to the best standards and limit failure when the CI runs. diff --git a/lib/ex_ussd.ex b/lib/ex_ussd.ex new file mode 100644 index 0000000..febd270 --- /dev/null +++ b/lib/ex_ussd.ex @@ -0,0 +1,89 @@ +defmodule ExUssd do + alias __MODULE__ + + @type t :: %__MODULE__{ + name: String.t(), + resolve: fun() | mfa(), + navigate: fun(), + title: String.t(), + parent: ExUssd.t(), + data: any(), + error: String.t(), + show_navigation: boolean(), + should_close: boolean(), + delimiter: String.t(), + default_error: String.t(), + orientation: atom(), + menu_list: list(ExUssd.t()) + } + + defstruct [ + :name, + :resolve, + :navigate, + :title, + :parent, + :data, + :error, + split: Application.get_env(:ex_ussd, :split) || 7, + show_navigation: true, + should_close: false, + delimiter: Application.get_env(:ex_ussd, :delimiter) || ":", + default_error: Application.get_env(:ex_ussd, :default_error) || "Invalid Choice\n", + orientation: :vertical, + menu_list: [], + nav: + Application.get_env(:ex_ussd, :nav) || + [ + ExUssd.Nav.new( + type: :home, + name: "HOME", + match: "00", + reverse: true, + orientation: :vertical + ), + ExUssd.Nav.new(type: :back, name: "BACK", match: "0", right: 1), + ExUssd.Nav.new(type: :next, name: "MORE", match: "98") + ] + ] + + @type menu() :: ExUssd.t() + @type payload() :: map() + @type metadata() :: map() + + @callback ussd_init( + menu :: menu(), + payload :: payload() + ) :: menu() + + @callback ussd_callback( + menu :: menu(), + payload :: payload(), + metadata :: metadata() + ) :: menu() + + @callback ussd_after_callback( + menu :: menu(), + payload :: payload(), + metadata :: metadata() + ) :: any() + + @optional_callbacks ussd_callback: 3, + ussd_after_callback: 3 + + defmacro __using__([]) do + quote do + @behaviour ExUssd + end + end + + defdelegate add(menu, child), to: ExUssd.Op + defdelegate add(menu, menus, opts), to: ExUssd.Op + defdelegate end_session(opts), to: ExUssd.Op + defdelegate goto(opts), to: ExUssd.Op + defdelegate new(opts), to: ExUssd.Op + defdelegate new(name, function), to: ExUssd.Op + defdelegate set(menu, opts), to: ExUssd.Op + defdelegate to_string(menu, opts), to: ExUssd.Op + defdelegate to_string(menu, atom, opts), to: ExUssd.Op +end diff --git a/lib/ex_ussd/application.ex b/lib/ex_ussd/application.ex new file mode 100644 index 0000000..d1dc6d2 --- /dev/null +++ b/lib/ex_ussd/application.ex @@ -0,0 +1,15 @@ +defmodule ExUssd.Application do + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + {Registry, keys: :unique, name: :session_registry} + ] + + opts = [strategy: :one_for_one, name: ExUssd.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/lib/ex_ussd/display.ex b/lib/ex_ussd/display.ex new file mode 100644 index 0000000..3aae28f --- /dev/null +++ b/lib/ex_ussd/display.ex @@ -0,0 +1,148 @@ +defmodule ExUssd.Display do + @moduledoc false + + @doc """ + Its used to tranform ExUssd menu struct to string. + + ## Parameters + + - `menu` - menu to transform to string + - `route` - route + - `opts` - optional session args + + ## Examples + + iex> menu = ExUssd.new(name: "home", resolve: fn menu, _payload, _metadata -> menu |> ExUssd.set(title: "Welcome") end) + iex> ExUssd.Display.to_string(menu, ExUssd.Route.get_route(%{text: "*544#", service_code: "*544#"})) + {:ok, %{menu_string: "Welcome", should_close: false}} + """ + + def to_string(_, _, opts \\ []) + + @spec to_string(%ExUssd{orientation: :horizontal}, map()) :: + {:ok, %{menu_string: String.t(), should_close: boolean()}} + def to_string( + %ExUssd{ + orientation: :horizontal, + error: error, + delimiter: delimiter, + menu_list: menu_list, + nav: nav, + should_close: should_close, + default_error: default_error + }, + %{route: route}, + opts + ) do + session = Keyword.get(opts, :session_id) + + %{depth: depth} = List.first(route) + + total_length = Enum.count(menu_list) + + menu_list = get_menu_list(menu_list, opts) + + navigation = ExUssd.Nav.to_string(nav, depth + 1, menu_list, depth - 1) + + should_close = + if depth == total_length do + should_close + else + false + end + + menu_string = + case Enum.at(menu_list, depth - 1) do + %ExUssd{name: name} -> + IO.iodata_to_binary(["#{depth}", delimiter, "#{total_length}", "\n", name, navigation]) + + _ -> + ExUssd.Registry.set_depth(session, total_length + 1) + IO.iodata_to_binary([default_error, navigation]) + end + + error = if error != true, do: error + + {:ok, + %{menu_string: IO.iodata_to_binary(["#{error}", menu_string]), should_close: should_close}} + end + + @spec to_string(ExUssd.t(), map(), keyword()) :: + {:ok, %{menu_string: String.t(), should_close: boolean()}} + def to_string( + %ExUssd{ + orientation: :vertical, + delimiter: delimiter, + error: error, + menu_list: menu_list, + nav: nav, + should_close: should_close, + show_navigation: show_navigation, + split: split, + title: title + }, + %{route: route}, + opts + ) do + %{depth: depth} = List.first(route) + + # {0, 6} + {min, max} = {split * (depth - 1), depth * split - 1} + + # [0, 1, 2, 3, 4, 5, 6] + selection = Enum.into(min..max, []) + + menu_list = get_menu_list(menu_list, opts) + + menus = + selection + |> Enum.with_index() + |> Enum.map(&transform(menu_list, min, delimiter, &1)) + |> Enum.reject(&is_nil(&1)) + + navigation = ExUssd.Nav.to_string(nav, depth, menu_list, max) + error = if error != true, do: error + + title_error = IO.iodata_to_binary(["#{error}", "#{title}"]) + + menu_string = + cond do + Enum.empty?(menus) and show_navigation == false -> + title_error + + Enum.empty?(menus) and show_navigation == true -> + IO.iodata_to_binary([title_error, navigation]) + + show_navigation == false -> + IO.iodata_to_binary([title_error, "\n", Enum.join(menus, "\n")]) + + show_navigation == true -> + IO.iodata_to_binary([title_error, "\n", Enum.join(menus, "\n"), navigation]) + end + + {:ok, %{menu_string: menu_string, should_close: should_close}} + end + + @spec transform([ExUssd.t()], integer(), String.t(), {integer(), integer()}) :: nil | binary() + defp transform(menu_list, min, delimiter, {position, index}) do + case Enum.at(menu_list, position) do + %ExUssd{name: name} -> + "#{index + 1 + min}#{delimiter}#{name}" + + nil -> + nil + end + end + + defp get_menu_list(menu_list, opts) do + menu_list + |> Enum.map(fn %{name: name} = menu -> + if String.equivalent?(name, "") do + ExUssd.Executer.execute_navigate(menu, Map.new(opts)) + else + menu + end + end) + |> Enum.reverse() + end +end diff --git a/lib/ex_ussd/executer.ex b/lib/ex_ussd/executer.ex new file mode 100644 index 0000000..bab134b --- /dev/null +++ b/lib/ex_ussd/executer.ex @@ -0,0 +1,249 @@ +defmodule ExUssd.Executer do + @moduledoc false + + @doc """ + 'execute_navigate/2' function + It invoke's anonymous function set on navigate field. + Params: + - menu: ExUssd struct menu + - payload: gateway response map + """ + + alias ExUssd.Utils + + @spec execute_navigate(ExUssd.t(), map()) :: ExUssd.t() + def execute_navigate(menu, payload) + + def execute_navigate(%ExUssd{navigate: navigate} = menu, payload) + when is_function(navigate) do + case apply(navigate, [menu, payload]) do + %ExUssd{} = menu -> %{menu | navigate: nil} + _ -> menu + end + end + + def execute_navigate(%ExUssd{} = menu, _), do: menu + + @doc """ + It invoke's the init callback function on the resolve field. + + ## Parameters + + - `menu` - ExUssd struct menu + - `payload` - gateway response map + """ + @spec execute_init_callback(ExUssd.t(), map()) :: {:ok, ExUssd.t()} + def execute_init_callback(menu, payload) + + def execute_init_callback(%ExUssd{resolve: resolve} = menu, payload) + when is_function(resolve) do + if is_function(resolve, 2) do + with %ExUssd{} = menu <- apply(resolve, [menu, payload]), do: {:ok, menu} + else + raise %BadArityError{function: resolve, args: [menu, payload]} + end + end + + def execute_init_callback(%ExUssd{name: name, resolve: resolve} = menu, payload) + when is_atom(resolve) do + if function_exported?(resolve, :ussd_init, 2) do + with %ExUssd{} = menu <- apply(resolve, :ussd_init, [menu, payload]), + do: {:ok, menu} + else + raise %ArgumentError{message: "resolve module for #{name} does not export ussd_init/2"} + end + end + + def execute_init_callback(%ExUssd{name: name, resolve: resolve}, _payload), + do: + raise( + ArgumentError, + "resolve for #{name} should be a function or a module, found #{inspect(resolve)}" + ) + + def execute_init_callback(menu, _payload), + do: raise(ArgumentError, "expected a ExUssd struct found #{inspect(menu)}") + + @spec execute_init_callback!(ExUssd.t(), map()) :: ExUssd.t() + def execute_init_callback!(menu, payload) do + {:ok, menu} = execute_init_callback(menu, payload) + menu + end + + @doc """ + It invoke's 'ussd_callback/3' callback function on the resolver module. + + ## Parameters + + - `menu` - ExUssd struct menu + - `payload` - gateway response map + - `opts` - optional argument + """ + + @spec execute_callback(%ExUssd{}, map(), keyword()) :: {:ok, ExUssd.t()} | any() + def execute_callback(menu, payload, opts \\ [state: true]) + + def execute_callback(%ExUssd{navigate: navigate} = menu, payload, opts) + when not is_nil(navigate) do + menu + |> Map.put(:resolve, navigate) + |> get_next_menu(payload, opts) + end + + def execute_callback(%ExUssd{resolve: resolve} = menu, payload, opts) + when is_atom(resolve) do + if function_exported?(resolve, :ussd_callback, 3) do + metadata = + if(Keyword.get(opts, :state), + do: Utils.fetch_metadata(payload), + else: + Map.merge( + %{ + route: "*test#", + invoked_at: DateTime.truncate(DateTime.utc_now(), :second), + attempt: 1 + }, + payload + ) + ) + + try do + with %ExUssd{error: error} = current_menu <- + apply(resolve, :ussd_callback, [%{menu | resolve: nil}, payload, metadata]) do + if is_bitstring(error) 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) + end + end + rescue + FunctionClauseError -> + nil + end + end + end + + def execute_callback(_menu, _payload, _opts), do: nil + + @spec execute_callback!(ExUssd.t(), map(), keyword()) :: ExUssd.t() | nil + def execute_callback!(menu, payload, opts \\ [state: true]) do + case execute_callback(menu, payload, opts) do + {_, menu} -> menu + nil -> nil + end + end + + @doc """ + It invoke's 'ussd_after_callback/3' callback function on the resolver module. + + ## Parameters + + - `menu` - ExUssd struct menu + - `payload` - gateway response map + """ + + @spec execute_after_callback(%ExUssd{}, map()) :: {:ok | :halt, ExUssd.t()} | any() + def execute_after_callback(menu, payload, opts \\ [state: true]) + + def execute_after_callback( + %ExUssd{error: original_error, resolve: resolve} = menu, + payload, + opts + ) + when is_atom(resolve) do + if function_exported?(resolve, :ussd_after_callback, 3) do + error_state = if is_bitstring(original_error), do: true + + metadata = + if(Keyword.get(opts, :state), + do: Utils.fetch_metadata(payload), + else: + Map.merge( + %{ + route: "*test#", + invoked_at: DateTime.truncate(DateTime.utc_now(), :second), + attempt: 3 + }, + payload + ) + ) + + try do + with %ExUssd{error: error} = current_menu <- + apply(resolve, :ussd_after_callback, [ + %{menu | resolve: nil, error: error_state}, + payload, + metadata + ]) do + if is_bitstring(error) 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) + end + end + rescue + FunctionClauseError -> + nil + end + end + end + + def execute_after_callback(_menu, _payload, _opts), do: nil + + @spec execute_after_callback!(ExUssd.t(), map(), keyword()) :: ExUssd.t() | nil + def execute_after_callback!(menu, payload, opts \\ [state: true]) do + case execute_after_callback(menu, payload, opts) do + {_, menu} -> menu + nil -> nil + end + end + + defp build_response_menu(:halt, current_menu, %{resolve: resolve}, _payload, _opts), + do: {:halt, %{current_menu | resolve: resolve}} + + defp build_response_menu(:ok, current_menu, menu, payload, opts) do + if Keyword.get(opts, :state) 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}} + end + + defp get_next_menu(menu, payload, opts) do + fun = fn + %ExUssd{orientation: orientation, data: data, resolve: resolve} -> + new_menu = + ExUssd.new( + orientation: orientation, + name: "#{inspect(resolve)}", + resolve: resolve, + data: data + ) + + current_menu = execute_init_callback!(new_menu, payload) + + build_response_menu(:ok, current_menu, menu, payload, opts) + + response -> + response + end + + current_response = + case menu do + {:ok, %ExUssd{resolve: resolve} = menu} when not is_nil(resolve) -> + menu + + %ExUssd{} = menu -> + menu + + menu -> + menu + end + + apply(fun, [current_response]) + end +end diff --git a/lib/ex_ussd/nav.ex b/lib/ex_ussd/nav.ex new file mode 100644 index 0000000..3f9e15a --- /dev/null +++ b/lib/ex_ussd/nav.ex @@ -0,0 +1,193 @@ +defmodule ExUssd.Nav do + @moduledoc """ + USSD Nav module + """ + + @type t :: %__MODULE__{ + name: String.t(), + match: String.t(), + type: atom(), + orientation: atom(), + delimiter: String.t(), + reverse: boolean(), + top: integer(), + bottom: integer(), + right: integer(), + left: integer(), + show: boolean() + } + + @enforce_keys [:name, :match, :type] + + defstruct [ + :name, + :match, + :type, + orientation: :horizontal, + delimiter: ":", + reverse: false, + top: 0, + bottom: 0, + right: 0, + left: 0, + show: true + ] + + @allowed_fields [ + :type, + :name, + :match, + :delimiter, + :orientation, + :reverse, + :top, + :bottom, + :right, + :left, + :show + ] + + @doc """ + Its used to create a new ExUssd Nav menu. + + ## Parameters + + - `opts` - Nav arguments + + ## Example + + ```elixir + iex> ExUssd.new(name: "home") |> ExUssd.set(nav: Nav.new(type: :next, name: "MORE", match: "98")) + + iex> ExUssd.new(name: "home") + |> ExUssd.set(nav: [ + ExUssd.Nav.new(type: :home, name: "HOME", match: "00", reverse: true, orientation: :vertical) + ExUssd.Nav.new(type: :back, name: "BACK", match: "0", right: 1), + ExUssd.Nav.new(type: :next, name: "MORE", match: "98") + ]) + ``` + """ + + @spec new(keyword()) :: %ExUssd.Nav{} + def new(opts) do + if Keyword.get(opts, :type) in [:home, :next, :back] do + struct!(__MODULE__, Keyword.take(opts, @allowed_fields)) + else + raise %ArgumentError{message: "Invalid USSD navigation type: #{Keyword.get(opts, :type)}"} + end + end + + @doc """ + Convert the USSD navigation menu to string + + ## Parameters + - `nav` - Nav Struct + - `depth` - depth of the nav menu + - `max` - max value of the menu list + + ## Example + + ```elixir + iex> Nav.new(type: :next, name: "MORE", match: "98") |> Nav.to_string() + "MORE:98" + + iex> nav = [ + ExUssd.Nav.new(type: :home, name: "HOME", match: "00", reverse: true, orientation: :vertical), + ExUssd.Nav.new(type: :back, name: "BACK", match: "0", right: 1), + ExUssd.Nav.new(type: :next, name: "MORE", match: "98") + ] + iex> ExUssd.Nav.to_string(nav) + "HOME:00 + BACK:0 MORE:98" + ``` + """ + + @spec to_string([ExUssd.Nav.t()]) :: String.t() + def to_string(nav) when is_list(nav) do + to_string(nav, 1, Enum.map(1..10, & &1), 0) + end + + @spec to_string([ExUssd.Nav.t()], integer(), [ExUssd.t()], integer()) :: String.t() + def to_string(navs, depth, menu_list, max) when is_list(navs) do + navs + |> Enum.reduce("", &reduce_nav(&1, &2, navs, menu_list, depth, max)) + |> String.trim_trailing() + end + + @spec to_string(ExUssd.Nav.t(), integer(), integer()) :: String.t() + def to_string(%ExUssd.Nav{} = nav, depth \\ 2, max \\ 999) do + fun = fn + _, %ExUssd.Nav{show: false} -> + "" + + %{depth: 1, max: nil}, _nav -> + "" + + %{max: nil}, %ExUssd.Nav{type: :next} -> + "" + + _, %ExUssd.Nav{name: name, delimiter: delimiter, match: match, reverse: true} -> + "#{match}#{delimiter}#{name}" + + _, %ExUssd.Nav{name: name, delimiter: delimiter, match: match} -> + "#{name}#{delimiter}#{match}" + end + + navigation = apply(fun, [%{depth: depth, max: max}, nav]) + + if String.equivalent?(navigation, "") do + navigation + else + navigation + |> padding(:left, nav) + |> padding(:right, nav) + |> padding(:top, nav) + |> padding(:bottom, nav) + end + end + + @spec padding(String.t(), atom(), ExUssd.Nav.t()) :: String.t() + defp padding(string, direction, nav) + + defp padding(string, :left, %ExUssd.Nav{left: amount}) do + String.pad_leading(string, String.length(string) + amount) + end + + defp padding(string, :right, %ExUssd.Nav{orientation: :horizontal, right: amount}) do + String.pad_trailing(string, String.length(string) + amount) + end + + defp padding(string, :right, %ExUssd.Nav{orientation: :vertical}), do: string + + defp padding(string, :top, %ExUssd.Nav{orientation: :vertical, top: amount}) do + padding = String.duplicate("\n", 1 + amount) + IO.iodata_to_binary([padding, string]) + end + + defp padding(string, :top, %ExUssd.Nav{top: amount}) do + padding = String.duplicate("\n", amount) + IO.iodata_to_binary([padding, string]) + end + + defp padding(string, :bottom, %ExUssd.Nav{orientation: :vertical, bottom: amount}) do + padding = String.duplicate("\n", 1 + amount) + IO.iodata_to_binary([string, padding]) + end + + defp padding(string, :bottom, %ExUssd.Nav{orientation: :horizontal}), do: string + + @spec reduce_nav( + ExUssd.Nav.t(), + String.t(), + [ExUssd.Nav.t()], + [ExUssd.t()], + integer(), + integer() + ) :: + String.t() + defp reduce_nav(%{type: type}, acc, nav, menu_list, depth, max) do + navigation = to_string(Enum.find(nav, &(&1.type == type)), depth, Enum.at(menu_list, max + 1)) + + IO.iodata_to_binary([acc, navigation]) + end +end diff --git a/lib/ex_ussd/navigation.ex b/lib/ex_ussd/navigation.ex new file mode 100644 index 0000000..011deb3 --- /dev/null +++ b/lib/ex_ussd/navigation.ex @@ -0,0 +1,190 @@ +defmodule ExUssd.Navigation do + @moduledoc false + + alias ExUssd.{Executer, Registry, Route, Utils} + + defguard is_menu(value) when is_tuple(value) and is_struct(elem(value, 1), ExUssd) + + @doc """ + Its used to navigate ExUssd menus. + + ## Parameters + + - `route` - route to navigate + - `menu` - menu to navigate + - `payload` - gateway response value + """ + @spec navigate(ExUssd.Route.t(), ExUssd.t(), map()) :: ExUssd.t() + def navigate(routes, menu, %{session_id: session_id} = payload) do + fun = fn + %Route{mode: :parallel, route: route}, payload, session_id, menu -> + Registry.start(session_id) + execute_navigation(Enum.reverse(route), payload, menu) + + %Route{mode: :serial, route: route}, payload, session_id, _ -> + execute_navigation(route, payload, Registry.fetch_current(session_id)) + end + + with {_, menu} <- apply(fun, [routes, payload, session_id, menu]), + do: Registry.set_current(session_id, menu) + end + + @spec execute_navigation(map() | list(), map(), ExUssd.t()) :: {term(), ExUssd.t()} + defp execute_navigation(route, payload, menu) + + defp execute_navigation(route, payload, menu) when is_list(route) do + fun = fn + [], _payload, menu -> + {:ok, menu} + + [%{depth: _, text: "555"}] = route, payload, menu -> + execute_navigation(List.first(route), payload, menu) + + [head | tail], payload, menu -> + case execute_navigation(head, payload, menu) do + {:ok, current_menu} -> execute_navigation(tail, payload, current_menu) + {:halt, current_menu} -> {:ok, current_menu} + end + end + + apply(fun, [route, payload, menu]) + end + + defp execute_navigation( + %{depth: _, text: "555"} = route, + %{session_id: session} = payload, + %ExUssd{} = menu + ) + when is_map(route) do + Registry.add_route(session, route) + + {:ok, home} = + menu + |> Executer.execute_navigate(payload) + |> Executer.execute_init_callback(payload) + + {:ok, Registry.set_home(session, %{home | parent: fn -> home end})} + end + + defp execute_navigation( + route, + %{session_id: session} = payload, + %ExUssd{orientation: :vertical, parent: parent} = menu + ) + when is_map(route) do + payload = %{payload | text: route[:text]} + + case Utils.to_int(Integer.parse(route[:text]), menu, payload, route[:text]) do + 705_897_792_423_629_962_208_442_626_284 -> + Registry.set(session, [%ExUssd.Route.State{depth: 1, text: "555"}]) + {:ok, Registry.fetch_home(session)} + + 605_356_150_351_840_375_921_999_017_933 -> + Registry.next_route(session) + {:ok, menu} + + 128_977_754_852_657_127_041_634_246_588 -> + %{depth: depth} = Registry.route_back(session) + + if depth == 1 do + Registry.reset_attempt(session) + current = if(is_nil(parent), do: menu, else: parent.()) + {:ok, current} + else + {:ok, menu} + end + + position -> + with {_, current_menu} = menu <- get_menu(position, route, menu, payload), + response when not is_menu(response) <- + Executer.execute_after_callback(current_menu, payload) do + menu + end + end + end + + defp execute_navigation( + route, + %{session_id: session} = payload, + %ExUssd{orientation: :horizontal, parent: parent, default_error: default_error} = menu + ) + when is_map(route) do + payload = %{payload | text: route[:text]} + + case Utils.to_int(Integer.parse(route[:text]), menu, payload, route[:text]) do + 705_897_792_423_629_962_208_442_626_284 -> + Registry.set(session, [%ExUssd.Route.State{depth: 1, text: "555"}]) + {:ok, Registry.fetch_home(session)} + + 605_356_150_351_840_375_921_999_017_933 -> + Registry.next_route(session) + {:ok, menu} + + 128_977_754_852_657_127_041_634_246_588 -> + %{depth: depth} = Registry.route_back(session) + + if depth == 1 do + Registry.reset_attempt(session) + current = if(is_nil(parent), do: menu, else: parent.()) + {:ok, current} + else + {:ok, menu} + end + + 436_739_010_658_356_127_157_159_114_145 -> + {:ok, %{menu | error: default_error}} + + position -> + ExUssd.Registry.set_depth(session, position) + {:ok, menu} + end + end + + defp execute_navigation(_, _, nil), + do: + raise(%RuntimeError{message: "menu not found, something went wrong with resolve callback"}) + + @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 + 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 + + defp get_menu( + position, + route, + %ExUssd{default_error: default_error, menu_list: menu_list} = parent_menu, + %{session_id: session} = payload + ) do + with menu <- Executer.execute_navigate(parent_menu, payload), + response when not is_menu(response) <- + Executer.execute_callback(menu, payload) do + case Enum.at(Enum.reverse(menu_list), position - 1) do + # invoke the child init callback + %ExUssd{} = menu -> + Registry.add_route(session, route) + + {:ok, current_menu} = + menu + |> Executer.execute_navigate(payload) + |> Executer.execute_init_callback(payload) + + {:ok, %{current_menu | parent: fn -> parent_menu end}} + + nil -> + Registry.add_attempt(session, route[:text]) + {:halt, %{menu | error: default_error}} + end + end + end +end diff --git a/lib/ex_ussd/op.ex b/lib/ex_ussd/op.ex new file mode 100644 index 0000000..cbb3c3b --- /dev/null +++ b/lib/ex_ussd/op.ex @@ -0,0 +1,496 @@ +defmodule ExUssd.Op do + @moduledoc """ + Contains all ExUssd Public API functions + """ + alias ExUssd.{Display, Executer, Route, Utils} + + @allowed_fields [ + :error, + :title, + :next, + :previous, + :should_close, + :split, + :delimiter, + :default_error, + :show_navigation, + :data, + :resolve, + :orientation, + :name, + :nav + ] + + @doc """ + Add menu to ExUssd menu list. + + ## Parameters + - `menu` โ€” ExUssd Menu + - `menu` โ€” ExUssd or List of ExUssd + - `opts` โ€” Keyword list + + ## Example + ```elixir + iex> menu = ExUssd.new(name: "Home", resolve: MyHomeResolver) + iex> ExUssd.add(menu, ExUssd.new(name: "Product A", resolve: ProductResolver))) + ``` + + Add menus to to ExUssd menu list. + Note: The menus with `orientation: :vertical` share one resolver + + ## Example + ```elixir + iex> menu = ExUssd.new(orientation: :vertical, name: "Home", resolve: MyHomeResolver) + iex> menu |> ExUssd.add([ExUssd.new(name: "Nairobi", data: %{city: "Nairobi", code: 47})], resolve: &CountyResolver.city_menu/2)) + ``` + """ + @spec add(ExUssd.t(), ExUssd.t() | [ExUssd.t()], keyword()) :: ExUssd.t() + def add(_, _, opts \\ []) + + def add(%ExUssd{} = menu, %ExUssd{} = child, _opts) do + fun = fn + %ExUssd{data: data} = menu, %ExUssd{navigate: navigate} = child + when is_function(navigate, 2) -> + Map.get_and_update(menu, :menu_list, fn menu_list -> + {:ok, [%{child | data: data} | menu_list]} + end) + + menu, child -> + Map.get_and_update(menu, :menu_list, fn menu_list -> {:ok, [child | menu_list]} end) + end + + with {:ok, menu} <- apply(fun, [menu, child]), do: menu + end + + def add(%ExUssd{} = menu, menus, opts) do + resolve = Keyword.get(opts, :resolve) + + fun = fn + _menu, menus, _ when not is_list(menus) -> + {:error, "menus should be a list, found #{inspect(menus)}"} + + _menu, menus, _ when menus == [] -> + {:error, "menus should not be empty, found #{inspect(menu)}"} + + %ExUssd{orientation: :vertical}, _menus, nil -> + {:error, "resolve callback not found in opts keyword list"} + + %ExUssd{} = menu, menus, resolve -> + if Enum.all?(menus, &is_struct(&1, ExUssd)) do + menu_list = Enum.map(menus, fn menu -> Map.put(menu, :resolve, resolve) end) + Map.put(menu, :menu_list, Enum.reverse(menu_list)) + else + {:error, "menus should be a list of ExUssd menus, found #{inspect(menus)}"} + end + end + + with {:error, message} <- apply(fun, [menu, menus, resolve]) do + raise %ArgumentError{message: message} + end + end + + @doc """ + Teminates session the gateway session id. + ```elixir + iex> ExUssd.end_session(session_id: "sn1") + ``` + """ + @spec end_session(keyword()) :: no_return() + def end_session(session_id: session_id) do + ExUssd.Registry.stop(session_id) + end + + @doc """ + Returns + menu_string: to be used as gateway response string. + should_close: indicates if the gateway should close the session. + + ## Parameters + - `opts` โ€” keyword list / map + + ## Example + ```elixir + iex> case ExUssd.goto(menu: menu, payload: payload) do + {:ok, %{menu_string: menu_string, should_close: false}} -> + "CON " <> menu_string + + {:ok, %{menu_string: menu_string, should_close: true}} -> + # End Session + ExUssd.end_session(session_id: session_id) + + "END " <> menu_string + end + ``` + """ + @spec goto(map() | keyword()) :: {:ok, %{menu_string: String.t(), should_close: boolean()}} + def goto(opts) + + def goto(fields) when is_list(fields), + do: goto(Enum.into(fields, %{})) + + def goto(%{ + payload: %{text: _, session_id: session, service_code: _} = payload, + menu: menu + }) do + payload + |> ExUssd.Route.get_route() + |> ExUssd.Navigation.navigate(menu, payload) + |> ExUssd.Display.to_string(ExUssd.Registry.fetch_state(session), Keyword.new(payload)) + end + + def goto(%{ + payload: %{"text" => _, "session_id" => _, "service_code" => _} = payload, + menu: menu + }) do + goto(%{payload: Utils.format(payload), menu: menu}) + end + + def goto(%{payload: %{"session_id" => _, "service_code" => _} = payload, menu: _}) do + message = "'text' not found in payload #{inspect(payload)}" + raise %ArgumentError{message: message} + end + + def goto(%{payload: %{"text" => _, "service_code" => _} = payload, menu: _}) do + message = "'session_id' not found in payload #{inspect(payload)}" + raise %ArgumentError{message: message} + end + + def goto(%{payload: %{"text" => _, "session_id" => _} = payload, menu: _}) do + message = "'service_code' not found in payload #{inspect(payload)}" + raise %ArgumentError{message: message} + end + + def goto(%{payload: payload, menu: _}) do + message = "'text', 'service_code', 'session_id', not found in payload #{inspect(payload)}" + + raise %ArgumentError{message: message} + end + + @doc """ + Returns the ExUssd struct for the given keyword list opts. + + ## Parameters + - `opts` โ€” keyword lists, must include name field + + ## Example + ```elixir + iex> ExUssd.new(orientation: :vertical, name: "home", resolve: MyHomeResolver) + iex> ExUssd.new(orientation: :horizontal, name: "home", resolve: fn menu, _payload -> menu |> ExUssd.set(title: "Welcome") end) + ``` + + To have the child menu have a known `name` value, you can pass a string to ExUssd.new. + ```elixir + iex> ExUssd.new("home", fn menu, payload -> + menu |> ExUssd.set(resolve: HomeResolver) + end) + ``` + + Note: ExUssd.new callback function will be called multiple times to get the `name` value. + + It's advisable to only set the `name` and `resolve` values on the callback like so reduce the side effects. + + ```elixir + iex> ExUssd.new(fn menu, payload -> + if is_registered?(phone_number: payload[:phone_number]) do + menu + |> ExUssd.set(name: "home") + |> ExUssd.set(resolve: HomeResolver) + else + menu + |> ExUssd.set(name: "guest") + |> ExUssd.set(resolve: GuestResolver) + end + end) + ``` + """ + + @spec new(String.t(), fun()) :: ExUssd.t() + def new(name, fun) when is_function(fun, 2) and is_bitstring(name) do + ExUssd.new(navigate: fun, name: name) + end + + def new(name, fun) when is_function(fun, 2) do + raise ArgumentError, "`name` must be a string, #{inspect(name)}" + end + + def new(_name, fun) do + raise ArgumentError, "expected a function with arity of 2, found #{inspect(fun)}" + end + + @spec new(fun()) :: ExUssd.t() + def new(fun) when is_function(fun, 2) do + ExUssd.new(navigate: fun, name: "") + end + + @spec new(keyword()) :: ExUssd.t() + def new(opts) do + fun = fn opts -> + if Keyword.keyword?(opts) do + {_, opts} = + Keyword.get_and_update( + opts, + :name, + &{&1, Utils.truncate(&1, length: 140, omission: "...")} + ) + + struct!(ExUssd, Keyword.take(opts, [:data, :resolve, :name, :orientation, :navigate])) + end + end + + with {:error, message} <- apply(fun, [opts]) |> validate_new(opts) do + raise %ArgumentError{message: message} + end + end + + @doc """ + Sets the allowed fields on ExUssd struct. + + ## Parameters + - `:menu` โ€” ExUssd Menu + - `:opts` โ€” Keyword list. Keys should be in the @allowed_fields + + ```elixir + @allowed_fields [ + :error, + :title, + :next, + :previous, + :should_close, + :split, + :delimiter, + :default_error, + :show_navigation, + :data, + :resolve, + :orientation, + :name + ] + ``` + + ## Example + ```elixir + iex> menu = ExUssd.new(name: "Home", resolve: &HomeResolver.welcome_menu/2) + iex> menu |> ExUssd.set(title: "Welcome", data: %{a: 1}, should_close: true) + iex> menu |> ExUssd.set(nav: ExUssd.Nav.new(type: :back, name: "BACK", match: "*")) + iex> menu |> ExUssd.set(nav: [ExUssd.Nav.new(type: :back, name: "BACK", match: "*")]) + ``` + """ + + @spec set(ExUssd.t(), keyword()) :: ExUssd.t() + def set(menu, opts) + + def set(%ExUssd{} = menu, nav: %ExUssd.Nav{type: type} = nav) + when type in [:home, :next, :back] do + case Enum.find_index(menu.nav, fn nav -> nav.type == type end) do + nil -> + menu + + index -> + Map.put(menu, :nav, List.update_at(menu.nav, index, fn _ -> nav end)) + end + end + + def set(%ExUssd{resolve: existing_resolve} = menu, resolve: resolve) + when not is_nil(existing_resolve) do + %{menu | navigate: resolve} + end + + def set(%ExUssd{resolve: nil} = menu, resolve: resolve) + when is_function(resolve) or is_atom(resolve) do + %{menu | resolve: resolve} + end + + def set(%ExUssd{resolve: nil}, resolve: resolve) do + raise %ArgumentError{ + message: "resolve should be a function or a resolver module, found #{inspect(resolve)}" + } + end + + def set(%ExUssd{}, nav: %ExUssd.Nav{type: type}) do + raise %ArgumentError{message: "nav has unknown type #{inspect(type)}"} + end + + def set(%ExUssd{} = menu, nav: nav) when is_list(nav) do + if Enum.all?(nav, &is_struct(&1, ExUssd.Nav)) do + Map.put(menu, :nav, Enum.uniq_by(nav ++ menu.nav, fn n -> n.type end)) + else + raise %ArgumentError{ + message: "nav should be a list of ExUssd.Nav struct, found #{inspect(nav)}" + } + end + end + + def set(%ExUssd{} = menu, opts) do + fun = fn menu, opts -> + if MapSet.subset?(MapSet.new(Keyword.keys(opts)), MapSet.new(@allowed_fields)) do + Map.merge(menu, Enum.into(opts, %{})) + end + end + + with nil <- apply(fun, [menu, opts]) do + message = + "Expected field in allowable fields #{inspect(@allowed_fields)} found #{inspect(Keyword.keys(opts))}" + + raise %ArgumentError{message: message} + end + end + + @doc """ + Returns Menu string + + ## Example + ```elixir + iex> menu = ExUssd.new(name: "home", resolve: fn menu, _payload -> menu |> ExUssd.set(title: "Welcome") end) + + iex> ExUssd.to_string(menu, []) + {:ok, %{menu_string: "Welcome", should_close: false}} + + iex> ExUssd.to_string(menu, :ussd_init, []) + {:ok, %{menu_string: "Welcome", should_close: false}} + + iex> menu = ExUssd.new(name: "home", resolve: HomeResolver) + + iex> ExUssd.to_string(menu, :ussd_init, []) + {:ok, %{menu_string: "Enter your PIN", should_close: false}} + + iex> ExUssd.to_string(menu, :ussd_callback, [payload: %{text: "1"}, init_text: "1"]) + {:ok, %{menu_string: "Invalid Choice\nEnter your PIN", should_close: false}} + + iex> ExUssd.to_string(menu, :ussd_callback, [payload: %{text: "5555", attempts: 3}, init_text: "1", init_data: %{name: "John"}]) + {:ok, %{menu_string: "You have Entered the Secret Number, 5555", should_close: true}} + ``` + """ + + @spec to_string(ExUssd.t(), keyword()) :: + {:ok, %{menu_string: String.t(), should_close: boolean()}} + def to_string(%ExUssd{} = menu, opts), do: to_string(menu, :ussd_init, opts) + + @spec to_string(ExUssd.t(), :ussd_init, keyword()) :: + {:ok, %{menu_string: String.t(), should_close: boolean()}} + def to_string(%ExUssd{} = menu, :ussd_init, opts) do + init_data = Keyword.get(opts, :init_data) + + payload = Keyword.get(opts, :payload, %{text: "set_opts_payload_text"}) + + fun = fn + menu, payload -> + menu + |> Executer.execute_navigate(payload) + |> Executer.execute_init_callback!(payload) + |> Display.to_string(Route.get_route(%{text: "*test#", service_code: "*test#"})) + end + + apply(fun, [%{menu | data: init_data}, payload]) + end + + @spec to_string(ExUssd.t(), :ussd_callback, keyword()) :: + {:ok, %{menu_string: String.t(), should_close: boolean()}} + def to_string(%ExUssd{default_error: error} = menu, :ussd_callback, opts) do + init_data = Keyword.get(opts, :init_data) + + payload = Keyword.get(opts, :payload) + + fun = fn + _menu, opts, nil -> + raise ArgumentError, "`:payload` not found, #{inspect(Keyword.new(opts))}" + + menu, %{init_text: init_text}, %{text: _} = payload -> + init_payload = Map.put(payload, :text, init_text) + + init_menu = + menu + |> Executer.execute_navigate(init_payload) + |> Executer.execute_init_callback!(init_payload) + + callback_menu = + with nil <- Executer.execute_callback!(init_menu, payload, state: false) do + %{init_menu | error: error} + end + + Display.to_string( + callback_menu, + Route.get_route(%{text: "*test#", service_code: "*test#"}) + ) + + _menu, opts, %{text: _} -> + raise ArgumentError, "opts missing `:init_text`, #{inspect(Keyword.new(opts))}" + + _menu, _, payload -> + raise ArgumentError, "payload missing `:text`, #{inspect(payload)}" + end + + apply(fun, [%{menu | data: init_data}, Map.new(opts), payload]) + end + + @spec to_string(ExUssd.t(), :ussd_after_callback, keyword()) :: + {:ok, %{menu_string: String.t(), should_close: boolean()}} + def to_string(%ExUssd{default_error: error} = menu, :ussd_after_callback, opts) do + init_data = Keyword.get(opts, :init_data) + + payload = Keyword.get(opts, :payload) + + fun = fn + _menu, opts, nil -> + raise ArgumentError, "`:payload` not found, #{inspect(Keyword.new(opts))}" + + menu, %{init_text: init_text}, %{text: _} = payload -> + init_payload = Map.put(payload, :text, init_text) + + init_menu = + menu + |> Executer.execute_navigate(init_payload) + |> Executer.execute_init_callback!(init_payload) + + callback_menu = + with nil <- Executer.execute_callback!(init_menu, payload, state: false) do + %{init_menu | error: error} + end + + after_callback_menu = + with nil <- Executer.execute_after_callback!(callback_menu, payload, state: false) do + callback_menu + end + + Display.to_string( + after_callback_menu, + Route.get_route(%{text: "*544#", service_code: "*544#"}) + ) + + _menu, %{callback_text: _} = opts, %{text: _} -> + raise ArgumentError, "opts missing `:init_text`, #{inspect(Keyword.new(opts))}" + + _menu, _, payload -> + raise ArgumentError, "payload missing `:text`, #{inspect(payload)}" + end + + apply(fun, [%{menu | data: init_data}, Map.new(opts), payload]) + end + + @spec validate_new(nil | ExUssd.t(), any()) :: ExUssd.t() | {:error, String.t()} + defp validate_new(menu, opts) + + defp validate_new(nil, opts) do + {:error, + "Expected a keyword list opts or callback function with arity of 2, found #{inspect(opts)}"} + end + + defp validate_new(%ExUssd{orientation: orientation} = menu, opts) + when orientation in [:vertical, :horizontal] do + fun = fn opts, key -> + if not Keyword.has_key?(opts, key) do + {:error, "Expected #{inspect(key)} in opts, found #{inspect(Keyword.keys(opts))}"} + end + end + + Enum.reduce_while([:name], menu, fn key, _ -> + case apply(fun, [opts, key]) do + nil -> {:cont, menu} + error -> {:halt, error} + end + end) + end + + defp validate_new(%ExUssd{orientation: orientation}, _opts) do + {:error, "Unknown orientation value, #{inspect(orientation)}"} + end +end diff --git a/lib/ex_ussd/registry.ex b/lib/ex_ussd/registry.ex new file mode 100644 index 0000000..2bf8a6f --- /dev/null +++ b/lib/ex_ussd/registry.ex @@ -0,0 +1,109 @@ +defmodule ExUssd.Registry do + @moduledoc false + use GenServer + + defmodule State do + @moduledoc false + defstruct [:home, :current, route: []] + end + + def init(_opts), do: {:ok, %State{}} + + defp via_tuple(session), do: {:via, Registry, {:session_registry, session}} + + def start(session), do: GenServer.start_link(__MODULE__, [], name: via_tuple(session)) + + def lookup(session) do + case Registry.lookup(:session_registry, session) do + [{pid, _}] -> {:ok, pid} + [] -> {:error, :not_found} + end + end + + def stop(session_id) do + case lookup(session_id) do + {:ok, pid} -> Process.exit(pid, :shutdown) + _ -> {:error, :not_found} + end + end + + def add_route(session, route), do: GenServer.call(via_tuple(session), {:add_route, route}) + def add_attempt(session, text), do: GenServer.call(via_tuple(session), {:add_attempt, text}) + def fetch_current(session), do: GenServer.call(via_tuple(session), {:fetch_current}) + def fetch_home(session), do: GenServer.call(via_tuple(session), {:fetch_home}) + def fetch_state(session), do: GenServer.call(via_tuple(session), {:fetch_state}) + def fetch_route(session), do: GenServer.call(via_tuple(session), {:fetch_route}) + def next_route(session), do: GenServer.call(via_tuple(session), {:next_route}) + def route_back(session), do: GenServer.call(via_tuple(session), {:route_back}) + def set(session, route), do: GenServer.call(via_tuple(session), {:set, route}) + def set_current(session, menu), do: GenServer.call(via_tuple(session), {:set_current, menu}) + def set_home(session, menu), do: GenServer.call(via_tuple(session), {:set_home, menu}) + def set_depth(session, depth), do: GenServer.call(via_tuple(session), {:set_depth, depth}) + def reset_attempt(session), do: GenServer.call(via_tuple(session), {:reset_attempt}) + + def handle_call({:add_route, route}, _from, %State{route: routes} = state) when is_map(route) do + state = Map.put(state, :route, [route | routes]) + {:reply, state, state} + end + + def handle_call( + {:add_attempt, text}, + _from, + %State{route: [%{attempt: %{count: count, inputs: inputs}} = head | tail]} = state + ) do + attempt = %{count: count + 1, inputs: [text | inputs]} + new_state = [%{head | attempt: attempt} | tail] + + {:reply, new_state, Map.put(state, :route, new_state)} + end + + def handle_call({:fetch_current}, _from, %State{current: current} = state), + do: {:reply, current, state} + + def handle_call({:fetch_home}, _from, %State{home: home} = state), do: {:reply, home, state} + + def handle_call({:fetch_state}, _from, state), do: {:reply, state, state} + + def handle_call({:fetch_route}, _from, %State{route: route} = state), do: {:reply, route, state} + + def handle_call({:next_route}, _from, %State{route: [head | tail]} = state) do + new_state = Map.put(state, :route, [Map.put(head, :depth, head[:depth] + 1) | tail]) + {:reply, new_state, new_state} + end + + def handle_call({:route_back}, _from, %State{route: [%{depth: depth} = head | tail]} = state) do + if head[:depth] == 1 do + route = with [] <- tail, do: [%ExUssd.Route.State{depth: 1, text: "555"}] + {:reply, head, Map.put(state, :route, route)} + else + new_head = Map.put(head, :depth, depth - 1) + new_state = [new_head | tail] + {:reply, new_head, Map.put(state, :route, new_state)} + end + end + + def handle_call({:set, route}, _from, state) when is_list(route) do + state = Map.put(state, :route, route) + {:reply, state, state} + end + + def handle_call({:set_current, menu}, _from, state) do + {:reply, menu, Map.put(state, :current, %{menu | error: nil})} + end + + def handle_call({:set_home, menu}, _from, state) do + {:reply, menu, Map.put(state, :home, menu)} + end + + def handle_call({:set_depth, depth}, _from, %State{route: [head | tail]} = state) do + new_state = Map.put(state, :route, [Map.put(head, :depth, depth) | tail]) + {:reply, new_state, new_state} + end + + def handle_call({:reset_attempt}, _from, %State{route: [head | tail]} = state) do + attempt = %{count: 0, inputs: []} + new_state = [%{head | attempt: attempt} | tail] + + {:reply, new_state, Map.put(state, :route, new_state)} + end +end diff --git a/lib/ex_ussd/route.ex b/lib/ex_ussd/route.ex new file mode 100644 index 0000000..27a101b --- /dev/null +++ b/lib/ex_ussd/route.ex @@ -0,0 +1,107 @@ +defmodule ExUssd.Route do + @moduledoc false + + alias __MODULE__ + alias ExUssd.Registry + + @type t :: %__MODULE__{ + mode: term(), + route: list() | map() + } + defstruct [:route, mode: :serial] + + defmodule State do + @moduledoc false + + defstruct [:depth, :text, attempt: %{count: 0, inputs: []}] + + @behaviour Access + # https://gist.github.com/andykingking/4982353b8c69ea301c698e97f6d34635 + # Structs by default do not implement this. It's easy to delegate this to the Map implementation however. + defdelegate get(route, key, default), to: Map + defdelegate fetch(route, key), to: Map + defdelegate get_and_update(route, key, func), to: Map + defdelegate pop(route, key), to: Map + end + + @doc """ + Initialize the route. + + ## Parameters + - `opts` - contains text string and the USSD service code. + + ## Examples + iex> ExUssd.Route.get_route(%{text: "", service_code: "*544#"}) + %Route{mode: :parallel, route: [%{depth: 1, text: "555"}]} + + iex> ExUssd.Route.get_route(%{text: "2", service_code: "*544#"}) + %Route{mode: :serial, route: %{depth: 1, text: "2"}} + + iex> ExUssd.Route.get_route(%{text: "*544*2*3#", service_code: "*544#"}) + %Route{mode: :parallel, route: [%{depth: 1, text: "3"}, %{depth: 1, text: "2"}, %{depth: 1, text: "555"}]} + """ + + @spec get_route(map()) :: Route.t() + def get_route(%{text: text, service_code: service_code} = opts) do + text = String.replace(text, "#", "") + + service_code = String.replace(service_code, "#", "") + + session = Map.get(opts, :session_id, "#{System.unique_integer()}") + + mode = + case Registry.lookup(session) do + {:error, :not_found} -> :parallel + _ -> :serial + end + + opts = + Map.merge(opts, %{ + mode: mode, + text: text, + service_code: service_code, + equivalent: String.equivalent?(text, service_code), + contains: String.contains?(text, service_code), + text_list: String.split(text, "*") + }) + + fun = fn + %{text: _text, mode: :parallel, equivalent: true, contains: true} -> + %Route{mode: :parallel, route: [%State{depth: 1, text: "555"}]} + + %{text: text, mode: :parallel, equivalent: false, contains: false} -> + list = String.split(text, "*") + + route = Enum.reduce(list, [%State{depth: 1, text: "555"}], &reduce_route/2) + + %Route{mode: :parallel, route: route} + + %{mode: :parallel, service_code: code, equivalent: false, contains: true, text_list: list} -> + list = list -- String.split(code, "*") + + route = + Enum.reduce(list, [%State{depth: 1, text: "555"}], fn text, acc -> + [%State{depth: 1, text: text} | acc] + end) + + %Route{mode: :parallel, route: route} + + %{text: text, mode: :serial, equivalent: false, contains: false, text_list: [_ | []]} -> + %Route{route: %State{depth: 1, text: text}} + + %{text: _text, mode: :serial, text_list: text_list} -> + %Route{route: %State{depth: 1, text: List.last(text_list)}} + end + + apply(fun, [opts]) + end + + @spec reduce_route(String.t(), list(Route.t())) :: list(Route.t()) + defp reduce_route(text, acc) do + if String.equivalent?(text, "") do + acc + else + [%State{depth: 1, text: text} | acc] + end + end +end diff --git a/lib/ex_ussd/utils.ex b/lib/ex_ussd/utils.ex new file mode 100644 index 0000000..db415a3 --- /dev/null +++ b/lib/ex_ussd/utils.ex @@ -0,0 +1,117 @@ +defmodule ExUssd.Utils do + @moduledoc false + + @default_value 436_739_010_658_356_127_157_159_114_145 + + @spec to_int(term() | {integer(), String.t()}, ExUssd.t(), map(), String.t()) :: integer() + def to_int(input, menu, _, input_value) + + def to_int({0, _}, menu, payload, input_value), + do: to_int({@default_value, ""}, menu, payload, input_value) + + def to_int( + {value, ""}, + %ExUssd{split: split, nav: nav, menu_list: menu_list, orientation: orientation}, + %{session_id: session}, + input_value + ) do + %ExUssd.Nav{match: next, show: show_next} = Enum.find(nav, &(&1.type == :next)) + %ExUssd.Nav{match: home, show: show_home} = Enum.find(nav, &(&1.type == :home)) + %ExUssd.Nav{match: back, show: show_back} = Enum.find(nav, &(&1.type == :back)) + + %{depth: depth} = + session + |> ExUssd.Registry.fetch_route() + |> List.first() + + # 1 * 7 + position = depth * split + + element = Enum.at(menu_list, position) + menu = Enum.at(menu_list, value - 1) + + case input_value do + v + when v == next and show_next and orientation == :horizontal and depth < length(menu_list) -> + 605_356_150_351_840_375_921_999_017_933 + + v when v == next and show_next and orientation == :vertical and not is_nil(element) -> + 605_356_150_351_840_375_921_999_017_933 + + v when v == back and show_back -> + 128_977_754_852_657_127_041_634_246_588 + + v when v == home and show_home -> + 705_897_792_423_629_962_208_442_626_284 + + _v when orientation == :horizontal and is_nil(menu) -> + @default_value + + _ -> + value + end + end + + def to_int(:error, _menu, _, _input_value), do: @default_value + + def to_int(_, _, _, _), do: @default_value + + @spec truncate(String.t(), keyword()) :: String.t() + def truncate(text, options \\ []) do + len = options[:length] || 30 + omi = options[:omission] || "..." + + cond do + !String.valid?(text) -> + text + + String.length(text) < len -> + text + + true -> + stop = len - String.length(omi) + + "#{String.slice(text, 0, stop)}#{omi}" + end + end + + @doc """ + Generates an unique id. + """ + def new_id, do: "#{System.unique_integer()}" + + @spec format(map()) :: map() + def format(payload) do + Map.new(payload, fn {key, val} -> + try do + {String.to_existing_atom(key), val} + rescue + _e in ArgumentError -> + {String.to_atom(key), val} + end + end) + end + + @spec fetch_metadata(map()) :: map() + def fetch_metadata(%{session_id: session, service_code: service_code, text: text}) do + %{route: [%{attempt: attempt} | _] = routes} = ExUssd.Registry.fetch_state(session) + + routes_string = + routes + |> Enum.reverse() + |> get_in([Access.all(), Access.key(:text)]) + |> tl() + |> Enum.join("*") + + service_code = String.replace(service_code, "#", "") + + routes_string = + if(String.equivalent?(routes_string, ""), + do: IO.iodata_to_binary([service_code, "#"]), + else: IO.iodata_to_binary([service_code, "*", routes_string, "#"]) + ) + + invoked_at = DateTime.truncate(DateTime.utc_now(), :second) + %{attempt: attempt, invoked_at: invoked_at, route: routes_string, text: text} + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..d7d4986 --- /dev/null +++ b/mix.exs @@ -0,0 +1,56 @@ +defmodule ExUssd.MixProject do + use Mix.Project + + def project do + [ + app: :ex_ussd, + version: "1.0.0-rc-1", + elixir: "~> 1.12", + start_permanent: Mix.env() == :prod, + description: "ExUssd lets you create simple, flexible, and customizable USSD interface.", + deps: deps(), + package: package(), + deps: deps(), + name: "ExUssd", + source_url: "https://github.com/beamkenya/ex_ussd.git", + docs: [ + # The main page in the docs + main: "readme", + canonical: "http://hexdocs.pm/ex_ussd", + source_url: "https://github.com/beamkenya/ex_ussd.git", + extras: ["README.md", "contributing.md"] + ] + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger], + mod: {ExUssd.Application, []} + ] + end + + defp package do + [ + name: "ex_ussd", + licenses: ["MIT"], + maintainers: [], + links: %{ + "GitHub" => "https://github.com/beamkenya/ex_ussd.git", + "README" => "https://hexdocs.pm/ex_ussd/readme.html" + }, + homepage_url: "https://github.com/beamkenya/ex_ussd" + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:credo, "~> 1.5", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false}, + {:ex_doc, "~> 0.24", only: [:dev, :test], runtime: false}, + {:faker, "~> 0.15", only: [:test, :dev]} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..d0230a9 --- /dev/null +++ b/mix.lock @@ -0,0 +1,23 @@ +%{ + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, + "credo": {:hex, :credo, "1.5.6", "e04cc0fdc236fefbb578e0c04bd01a471081616e741d386909e527ac146016c6", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4b52a3e558bd64e30de62a648518a5ea2b6e3e5d2b164ef5296244753fc7eb17"}, + "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ex_doc": {:hex, :ex_doc, "0.24.2", "e4c26603830c1a2286dae45f4412a4d1980e1e89dc779fcd0181ed1d5a05c8d9", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "e134e1d9e821b8d9e4244687fb2ace58d479b67b282de5158333b0d57c6fb7da"}, + "faker": {:hex, :faker, "0.16.0", "1e2cf3e8d60d44a30741fb98118fcac18b2020379c7e00d18f1a005841b2f647", [:mix], [], "hexpm", "fbcb9bf1299dff3c9dd7e50f41802bbc472ffbb84e7656394c8aa913ec315141"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, + "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, + "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, + "phoenix": {:hex, :phoenix, "1.5.9", "a6368d36cfd59d917b37c44386e01315bc89f7609a10a45a22f47c007edf2597", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7e4bce20a67c012f1fbb0af90e5da49fa7bf0d34e3a067795703b74aef75427d"}, + "phoenix_html": {:hex, :phoenix_html, "2.14.3", "51f720d0d543e4e157ff06b65de38e13303d5778a7919bcc696599e5934271b8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "efd697a7fff35a13eeeb6b43db884705cba353a1a41d127d118fda5f90c8e80f"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.15.7", "09720b8e5151b3ca8ef739cd7626d4feb987c69ba0b509c9bbdb861d5a365881", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 0.5", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a756cf662420272d0f1b3b908cce5222163b5a95aa9bab404f9d29aff53276e"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, + "plug": {:hex, :plug, "1.11.1", "f2992bac66fdae679453c9e86134a4201f6f43a687d8ff1cd1b2862d53c80259", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "23524e4fefbb587c11f0833b3910bfb414bf2e2534d61928e920f54e3a1b881f"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, + "telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"}, +} diff --git a/test/ex_ussd/display_test.exs b/test/ex_ussd/display_test.exs new file mode 100644 index 0000000..c9945da --- /dev/null +++ b/test/ex_ussd/display_test.exs @@ -0,0 +1,36 @@ +defmodule ExUssd.DisplayTest do + @moduledoc false + use ExUnit.Case + alias ExUssd.Display + + setup do + resolve = fn menu, _payload, _metadata -> menu end + + menu = + ExUssd.new(name: Faker.Company.name(), resolve: resolve) + |> ExUssd.set(title: "Welcome") + |> ExUssd.add(ExUssd.new(name: "menu 1")) + |> ExUssd.add(ExUssd.new(name: "menu 2")) + |> ExUssd.add(ExUssd.new(name: "menu 3")) + + route = ExUssd.Route.get_route(%{text: "*544#", service_code: "*544#"}) + %{menu: menu, route: route} + end + + describe "to_string/3" do + test "successfully converts ExUssd menu struct into display string", %{ + menu: menu, + route: route + } do + assert {:ok, %{menu_string: "Welcome\n1:menu 1\n2:menu 2\n3:menu 3", should_close: false}} == + Display.to_string(menu, route) + end + + test "successfully converts ExUssd :horizontal menu struct into display string", %{menu: menu} do + menu = Map.put(menu, :orientation, :horizontal) + + assert {:ok, %{menu_string: "1:3\nmenu 1\n00:HOME\nBACK:0 MORE:98", should_close: false}} == + Display.to_string(menu, %{route: [%{depth: 1, text: "1"}]}) + end + end +end diff --git a/test/ex_ussd/executer_test.exs b/test/ex_ussd/executer_test.exs new file mode 100644 index 0000000..c20e7b0 --- /dev/null +++ b/test/ex_ussd/executer_test.exs @@ -0,0 +1,28 @@ +defmodule ExUssd.ExecuterTest do + @moduledoc false + use ExUnit.Case + alias ExUssd.Executer + + describe "execute/3" do + test "successfully executes anonymous resolve fn" do + menu = + ExUssd.new( + name: Faker.Company.name(), + resolve: fn menu, _payload -> menu |> ExUssd.set(title: "Welcome") end + ) + + title = "Welcome" + assert {:ok, %ExUssd{title: ^title}} = Executer.execute_init_callback(menu, Map.new()) + end + + test "raise BadArityError if resolve function does not take arity of 2" do + menu = + ExUssd.new( + name: Faker.Company.name(), + resolve: fn menu, _payload, _metadata -> menu |> ExUssd.set(title: "Welcome") end + ) + + assert_raise BadArityError, fn -> Executer.execute_init_callback(menu, Map.new()) end + end + end +end diff --git a/test/ex_ussd/nav_test.exs b/test/ex_ussd/nav_test.exs new file mode 100644 index 0000000..c9e0bf7 --- /dev/null +++ b/test/ex_ussd/nav_test.exs @@ -0,0 +1,39 @@ +defmodule ExUssd.NavTest do + @moduledoc false + use ExUnit.Case + + describe "to_string/3" do + test "successfully converts nav type to string" do + next = ExUssd.Nav.new(type: :next, name: "MORE", match: "98") + assert "MORE:98" == ExUssd.Nav.to_string(next) + end + + test "successfully pad to left" do + next = ExUssd.Nav.new(type: :next, name: "MORE", match: "98", left: 1) + assert " MORE:98" == ExUssd.Nav.to_string(next) + end + + test "successfully pad to right" do + next = ExUssd.Nav.new(type: :next, name: "MORE", match: "98", right: 1) + assert "MORE:98 " == ExUssd.Nav.to_string(next) + end + + test "successfully pad to top" do + next = ExUssd.Nav.new(type: :next, name: "MORE", match: "98", top: 1) + + assert "\nMORE:98" == ExUssd.Nav.to_string(next) + end + + test "successfully pad to down" do + next = + ExUssd.Nav.new(type: :next, name: "MORE", match: "98", down: 1, orientation: :vertical) + + assert "\nMORE:98\n" == ExUssd.Nav.to_string(next) + end + + test "successfully hides nav" do + next = ExUssd.Nav.new(type: :next, name: "MORE", match: "98", show: false) + assert "" == ExUssd.Nav.to_string(next) + end + end +end diff --git a/test/ex_ussd/op_test.exs b/test/ex_ussd/op_test.exs new file mode 100644 index 0000000..f7ae9e2 --- /dev/null +++ b/test/ex_ussd/op_test.exs @@ -0,0 +1,243 @@ +defmodule ExUssd.OpTest.Module do + @moduledoc false + def ussd_init(menu, _) do + menu + |> ExUssd.set(title: "Enter your PIN") + end + + def ussd_callback(menu, payload, _) do + if payload.text == "5555" do + menu + |> ExUssd.set(title: "You have Entered the Secret Number, 5555") + |> ExUssd.set(should_close: true) + end + end + + def simple(menu, _) do + menu + |> ExUssd.set(title: "Welcome") + |> ExUssd.add( + ExUssd.new( + name: "menu 1", + resolve: &simple/2 + ) + |> ExUssd.set(split: 3) + ) + |> ExUssd.add( + ExUssd.new( + name: "menu 2", + resolve: fn menu, _ -> ExUssd.set(menu, title: "menu 2") end + ) + ) + |> ExUssd.add( + ExUssd.new( + name: "menu 3", + resolve: fn menu, _ -> ExUssd.set(menu, title: "menu 3") end + ) + ) + |> ExUssd.add( + ExUssd.new( + name: "menu 4", + resolve: fn menu, _ -> ExUssd.set(menu, title: "menu 4") end + ) + ) + |> ExUssd.add( + ExUssd.new( + name: "menu 5", + resolve: fn menu, _ -> ExUssd.set(menu, title: "menu 5") end + ) + ) + end +end + +defmodule ExUssd.OpTest do + @moduledoc false + use ExUnit.Case + + setup do + resolve = fn menu, _payload -> menu |> ExUssd.set(title: "Welcome") end + + menu = ExUssd.new(name: Faker.Company.name(), resolve: resolve) + + %{resolve: resolve, menu: menu} + end + + describe "new/1" do + test "successfully sets the hander field", %{resolve: resolve} do + name = Faker.Company.name() + options = [name: name, resolve: resolve] + assert %ExUssd{name: ^name, resolve: ^resolve} = ExUssd.new(options) + end + + test "raise ArgumentError if the name is not provided", %{resolve: resolve} do + assert_raise ArgumentError, "Expected :name in opts, found [:resolve]", fn -> + ExUssd.new(resolve: resolve) + end + end + + test "raise ArgumentError if the orientation is unknown", %{resolve: resolve} do + name = Faker.Company.name() + orientation = :top + + assert_raise ArgumentError, "Unknown orientation value, #{inspect(orientation)}", fn -> + ExUssd.new(orientation: orientation, name: name, resolve: resolve) + end + end + + test "raise ArgumentError if opt is not a key wordlist", %{resolve: resolve} do + assert_raise ArgumentError, fn -> ExUssd.new(%{resolve: resolve}) end + end + end + + describe "set/2" do + test "successfully sets the title and should_close field", %{menu: menu} do + title = Faker.Lorem.sentence(4..10) + + assert %ExUssd{title: ^title, should_close: true} = + ExUssd.set(menu, title: title, should_close: true) + end + + test "raise ArgumentError if opts value is not part of the allowed_fields", %{menu: menu} do + assert_raise ArgumentError, fn -> ExUssd.set(menu, close: true) end + end + end + + describe "add/2" do + test "vertical: successfully add menu to menu list", %{menu: menu, resolve: resolve} do + menu1 = ExUssd.new(name: Faker.Company.name(), resolve: resolve) + menu2 = ExUssd.new(name: Faker.Company.name(), resolve: resolve) + assert %ExUssd{menu_list: [^menu2, ^menu1]} = menu |> ExUssd.add(menu1) |> ExUssd.add(menu2) + end + + test "horizontal: successfully add menu to menu list", %{resolve: resolve} do + home = ExUssd.new(name: Faker.Company.name(), resolve: resolve, orientation: :horizontal) + menu1 = ExUssd.new(name: Faker.Company.name(), resolve: resolve) + menu2 = ExUssd.new(name: Faker.Company.name(), resolve: resolve) + assert %ExUssd{menu_list: [^menu2, ^menu1]} = home |> ExUssd.add(menu1) |> ExUssd.add(menu2) + end + + test "vertical: successfully add menus to menu list", %{resolve: resolve} do + home = ExUssd.new(name: Faker.Company.name(), resolve: resolve, orientation: :vertical) + menu1 = ExUssd.new(name: Faker.Company.name(), resolve: resolve) + menu2 = ExUssd.new(name: Faker.Company.name(), resolve: resolve) + + assert %ExUssd{menu_list: [^menu2, ^menu1]} = + home |> ExUssd.add([menu1, menu2], resolve: resolve) + end + + test "horizontal: successfully add menus to menu list", %{resolve: resolve} do + home = ExUssd.new(name: Faker.Company.name(), resolve: resolve, orientation: :horizontal) + menu1 = ExUssd.new(name: Faker.Company.name()) + menu2 = ExUssd.new(name: Faker.Company.name()) + + assert %ExUssd{menu_list: [^menu2, ^menu1]} = home |> ExUssd.add([menu1, menu2]) + end + end + + describe "goto/1 simple" do + setup do + %{ + menu: + ExUssd.new(fn menu, _ -> menu |> ExUssd.set(resolve: &ExUssd.OpTest.Module.simple/2) end), + session: "#{System.unique_integer()}" + } + end + + test "successfully navigates to the first layer", %{menu: menu, session: session} do + assert {:ok, + %{ + menu_string: "Welcome\n1:menu 1\n2:menu 2\n3:menu 3\n4:menu 4\n5:menu 5", + should_close: false + }} == + ExUssd.goto(%{ + payload: %{session_id: session, text: "", service_code: "*544#"}, + menu: menu + }) + end + + test "successfully navigates to the first menu option", %{menu: menu, session: session} do + assert {:ok, + %{ + menu_string: "Welcome\n1:menu 1\n2:menu 2\n3:menu 3\n00:HOME\nBACK:0 MORE:98", + should_close: false + }} == + ExUssd.goto(%{ + payload: %{session_id: session, text: "1", service_code: "*544#"}, + menu: ExUssd.set(menu, split: 3) + }) + end + + test "successfully navigates to the nested menu", %{menu: menu, session: session} do + assert {:ok, + %{ + menu_string: "Welcome\n4:menu 4\n5:menu 5\n00:HOME\nBACK:0", + should_close: false + }} == + ExUssd.goto(%{ + payload: %{session_id: session, text: "98", service_code: "*544#"}, + menu: ExUssd.set(menu, split: 3) + }) + end + + test "successfully navigates back the nested menu", %{menu: menu, session: session} do + assert {:ok, + %{ + menu_string: "Welcome\n1:menu 1\n2:menu 2\n3:menu 3\n00:HOME\nBACK:0 MORE:98", + should_close: false + }} == + ExUssd.goto(%{ + payload: %{session_id: session, text: "0", service_code: "*544#"}, + menu: ExUssd.set(menu, split: 3) + }) + end + + test "successfully navigates back to home menu", %{menu: menu, session: session} do + assert {:ok, + %{ + menu_string: "Welcome\n1:menu 1\n2:menu 2\n3:menu 3\n4:menu 4\n5:menu 5", + should_close: false + }} == + ExUssd.goto(%{ + payload: %{session_id: session, text: "0", service_code: "*544#"}, + menu: menu + }) + end + + test "successfully navigates to the home menu", %{menu: menu, session: session} do + assert {:ok, + %{ + menu_string: "Welcome\n1:menu 1\n2:menu 2\n3:menu 3\n4:menu 4\n5:menu 5", + should_close: false + }} == + ExUssd.goto(%{ + payload: %{session_id: session, text: "00", service_code: "*544#"}, + menu: menu + }) + end + end + + describe "goto/1 with callback" do + setup do + %{ + menu: ExUssd.new(name: Faker.Company.name(), resolve: ExUssd.OpTest.Module), + 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: "", service_code: "*444#"}, + menu: menu + }) + end + + test "successfully calls the 'ussd_callback/3' function", %{menu: menu, session: session} do + assert {:ok, %{menu_string: "You have Entered the Secret Number, 5555", should_close: true}} == + ExUssd.goto(%{ + payload: %{session_id: session, text: "5555", service_code: "*444#"}, + menu: menu + }) + end + end +end diff --git a/test/ex_ussd/route_test.exs b/test/ex_ussd/route_test.exs new file mode 100644 index 0000000..8c46af0 --- /dev/null +++ b/test/ex_ussd/route_test.exs @@ -0,0 +1,48 @@ +defmodule ExUssd.RouteTest do + @moduledoc false + + use ExUnit.Case + + describe "get_route/2" do + test "get route when text is equivalent to service code" do + assert %ExUssd.Route{mode: :parallel, route: [%{depth: 1, text: "555"}]} = + ExUssd.Route.get_route(%{text: "*544#", service_code: "*544#"}) + end + + test "get route when text is contains service code" do + assert %ExUssd.Route{ + mode: :parallel, + route: [ + %{depth: 1, text: "3"}, + %{depth: 1, text: "2"}, + %{depth: 1, text: "555"} + ] + } = ExUssd.Route.get_route(%{text: "*544*2*3#", service_code: "*544#"}) + end + + test "get route when text does not contains service code" do + assert %ExUssd.Route{ + mode: :parallel, + route: [ + %{depth: 1, text: "3"}, + %{depth: 1, text: "2"}, + %{depth: 1, text: "555"} + ] + } = ExUssd.Route.get_route(%{text: "2*3#", service_code: "*544#"}) + end + + test "get route when text does not contains service code and session already exist" do + assert ExUssd.Registry.start("session_01") + + assert %ExUssd.Route{ + mode: :serial, + route: %{depth: 1, text: "2"} + } = + ExUssd.Route.get_route(%{ + text: "3*2", + service_code: "*544#", + session_id: "session_01" + }) + end + end +end diff --git a/test/ex_ussd_test.exs b/test/ex_ussd_test.exs new file mode 100644 index 0000000..a220c9c --- /dev/null +++ b/test/ex_ussd_test.exs @@ -0,0 +1,3 @@ +defmodule ExUssdTest do + use ExUnit.Case +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..acc2de6 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Faker.start()