diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 00000000..15a3e4e3 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,129 @@ +# 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/", "web/", "apps/"], + excluded: [~r"/_build/", ~r"/deps/"] + }, + # + # 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: [], + # + # Credo automatically checks for updates, like e.g. Hex does. + # You can disable this behaviour below: + check_for_updates: true, + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + strict: true, + # + # 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: [ + {Credo.Check.Consistency.ExceptionNames}, + {Credo.Check.Consistency.LineEndings}, + {Credo.Check.Consistency.MultiAliasImportRequireUse}, + {Credo.Check.Consistency.ParameterPatternMatching}, + {Credo.Check.Consistency.SpaceAroundOperators}, + {Credo.Check.Consistency.SpaceInParentheses}, + {Credo.Check.Consistency.TabsOrSpaces}, + + # For some checks, like AliasUsage, you can only customize the priority + # Priority values are: `low, normal, high, higher` + {Credo.Check.Design.AliasUsage, priority: :low}, + + # For others you can set parameters + + # If you don't want the `setup` and `test` macro calls in ExUnit tests + # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just + # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. + {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, + + # 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}, + + {Credo.Check.Readability.FunctionNames}, + {Credo.Check.Readability.LargeNumbers}, + {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 80}, + {Credo.Check.Readability.ModuleAttributeNames}, + {Credo.Check.Readability.ModuleDoc}, + {Credo.Check.Readability.ModuleNames}, + {Credo.Check.Readability.NoParenthesesWhenZeroArity}, + {Credo.Check.Readability.ParenthesesInCondition}, + {Credo.Check.Readability.PredicateFunctionNames}, + {Credo.Check.Readability.PreferImplicitTry}, + {Credo.Check.Readability.RedundantBlankLines}, + {Credo.Check.Readability.Specs}, + {Credo.Check.Readability.StringSigils}, + {Credo.Check.Readability.TrailingBlankLine}, + {Credo.Check.Readability.TrailingWhiteSpace}, + {Credo.Check.Readability.VariableNames}, + {Credo.Check.Refactor.DoubleBooleanNegation}, + + # {Credo.Check.Refactor.CaseTrivialMatches}, # deprecated in 0.4.0 + {Credo.Check.Refactor.ABCSize}, + {Credo.Check.Refactor.CondStatements}, + {Credo.Check.Refactor.CyclomaticComplexity}, + {Credo.Check.Refactor.FunctionArity}, + {Credo.Check.Refactor.MatchInCondition}, + {Credo.Check.Refactor.NegatedConditionsInUnless}, + {Credo.Check.Refactor.NegatedConditionsWithElse}, + {Credo.Check.Refactor.Nesting}, + {Credo.Check.Refactor.PipeChainStart}, + {Credo.Check.Refactor.UnlessWithElse}, + {Credo.Check.Refactor.VariableRebinding}, + + {Credo.Check.Warning.BoolOperationOnSameValues}, + {Credo.Check.Warning.IExPry}, + {Credo.Check.Warning.IoInspect}, + {Credo.Check.Warning.NameRedeclarationByAssignment}, + {Credo.Check.Warning.NameRedeclarationByCase}, + {Credo.Check.Warning.NameRedeclarationByDef}, + {Credo.Check.Warning.NameRedeclarationByFn}, + {Credo.Check.Warning.OperationOnSameValues}, + {Credo.Check.Warning.OperationWithConstantResult}, + {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}, + + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + ] +} diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..f8366a88 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +language: elixir +elixir: + - 1.4.1 +otp_release: + - 19.2 + +sudo: required +services: + - docker + +before_script: + - tools/travis-setup.sh + +env: + - PRESET=exunit MIX_ENV=test + - PRESET=credo MIX_ENV=test + - PRESET=dialyzer MIX=test + +script: tools/travis-test.sh diff --git a/README.md b/README.md index 3007898a..ed9d9713 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,144 @@ # MongoosePush -**TODO: Add description** +[![Build Status](https://travis-ci.org/esl/MongoosePush.svg?branch=master)](https://travis-ci.org/esl/MongoosePush) [![Coverage Status](https://coveralls.io/repos/github/esl/MongoosePush/badge.svg?branch=initial_implementation)](https://coveralls.io/github/esl/MongoosePush?branch=master) -## Installation +**MongoosePush** is simple (seriously) **REST** service written in **Elixir** providing ability to **send push +notification** to `FCM` (Firebase Cloud Messaging) and/or +`APNS` (Apple Push Notification Service) via their `HTTP/2` API. -If [available in Hex](https://hex.pm/docs/publish), the package can be installed -by adding `mongoose_push` to your list of dependencies in `mix.exs`: +## Quick start +### Docker + +Soon :) + +### Local build + +#### Perquisites + +* Elixir 1.4+ (http://elixir-lang.org/install.html) +* Rebar3 (just enter ```mix local.rebar```) + +#### Build and run + +Build step is really easy. Just type in root of the repository: +```bash +MIX_ENV=prod mix do deps.get, compile, certs.dev, release +``` + +After this step you may try to run the service via: +```bash +_build/prod/rel/mongoose_push/bin/mongoose_push console +``` + +Yeah, I know... It crashed. Running this service is fast and simple but unfortunately you can't have push notifications without properly configured `FCM` and/or `APNS` service. So, lets configure it! + +## Configuration + +The whole configuration is contained in file `config/{prod|dev|test}.exs` depending on which `MIX_ENV` you will be using. You should use `MIX_ENV=prod` for production installations and `MIX_ENV=dev` for your development. Anyway, lets take a look on `config/prod.exs`, part by part. + +### REST API configuration + +```elixir +config :maru, MongoosePush.Router, + versioning: [ + using: :path + ], + https: [ + ip: {127, 0, 0, 1}, + port: 8443, + keyfile: "priv/ssl/fake_key.pem", + certfile: "priv/ssl/fake_cert.pem", + otp_app: :mongoose_push + ] +``` + +This part of configuration relates only to `REST` endpoints that `MongoosePush` exposes. Here you can set bind IP adress (option: `ip`), port and paths to you `HTTP` `TLS` certificates. You should ignore other options unless you know what you're doing or you're going to get to know by reading [maru's documentation](https://maru.readme.io/docs). + +You may entirely skip the `maru` config entry to disable `REST` API and just use this project as `Elixir` library. + +### FCM configuration +Lets take a look at sample `FCM` service configuration: ```elixir -def deps do - [{:mongoose_push, "~> 0.1.0"}] -end +config :mongoose_push, fcm: [ + default: [ + key: "fake_app_key", + pool_size: 5, + mode: :prod + ] + ] ``` -Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) -and published on [HexDocs](https://hexdocs.pm). Once published, the docs can -be found at [https://hexdocs.pm/mongoose_push](https://hexdocs.pm/mongoose_push). +Here we can see definition of a pool. Each pool has a name and its configuration. You may have several named pools of different sizes and with different configurations. Currently the only reason you may want to do this is that, the `REST` client may switch between them by specifying matching `:mode` in their push request. + +Each `FCM` pool may be configured by setting the following fields: +* **key** (*required*) - you `FCM` Application Key for using Googles API +* **pool_size** (*required*) - maximum number of used `HTTP/2` connections to google's service +* **mode** (*either `:prod` or `:dev`*) - pool's mode. `REST` client may select pool used to push his notification by specifying matching option in his request +* **endpoint** (*optional*) - URL override for `FCM` service. Useful mainly in tests + +You may entirely skip the `FCM` config entry to disable `FCM` support. + +### APNS configuration + +Lets take a look at sample `APNS` service configuration: +```elixir +config :mongoose_push, apns: [ + dev: [ + cert: "priv/apns/dev_cert.pem", + key: "priv/apns/dev_key.pem", + mode: :dev, + use_2197: false, + pool_size: 5 + ], + prod: [ + cert: "priv/apns/prod_cert.pem", + key: "priv/apns/prod_key.pem", + mode: :prod, + use_2197: false, + pool_size: 5 + ] + ] + ``` +Analogically to `FCM` configuration, at top level we may specify named pools that have different configurations. For `APNS` this is specifically useful since Apple delivers different APS certificated for development and production use. As in `FCM`, `REST` client may select named pool by providing matching `:mode` in his `REST` request. + +Each `APNS` pool may be configured by setting the following fields: +* **cert** (*required*) - relative path to `APNS` `PEM` certificate issued by Apple. This certificate have to be somewhere in `priv` directory +* **key** (*required*) - relative path to `PEM` private key for `APNS` certificate issued by Apple. This file have to be somewhere in `priv` directory +* **pool_size** (*required*) - maximum number of used `HTTP/2` connections to google's service +* **mode** (*either `:prod` or `:dev`*) - pool's mode. `REST` client may select pool used to push his notification by specifying matching option in his request +* **endpoint** (*optional*) - URL override for `APNS` service. Useful mainly in tests +* **use_2197** (*optional `true` or `false`*) - whether use alternative port for `APNS`: 2197 + +You may entirely skip the `APNS` config entry to disable `APNS` support. + +## REST API + +### Swagger + +If for some reason you need `Swagger` spec for this `REST` service, after compiling and running this project with `MIX_ENV=dev`, there is swagger endpoint available at `REST` path `/swagger.json` + +### Just tell me what to send already + +There is only one endpoint at this moment: +* `POST /v1/notification/{device_id}` + +As you can imagine, `{device_id}` should be replaced with device ID/Token generated by your push notification provider (`FCM` or `APNS`). The notification should be sent as `JSON` payload of this request. Minimal `JSON` request could be like this: + +```json +{ + "service": "apns", + "body": "notification's text body", + "title": "notification's title" +} +``` +The full list of options contains the following: +* **service** (*required*, `apns` or `fcm`) - push notifications provider to be used for this notification +* **body** (*required*) - text body of notification +* **title** (*required*) - short title of notification +* **mode** (*optional*, `prod` (default) or `dev`) - allows for selecting named pool configured in `MongoosePush` +* **click_action** (*optional*) - for `FCM` its `activity` to run when notification is clicked. For `APNS` its `category` to invoke. Please refer to Android/iOS documentation for more details about this action +* **tag** (*optional*, `FCM` specific) - notifications aggregation key +* **badge** (*optional*, `APNS` specific) - unread notifications count +* **topic** (*optional*, `APNS` specific) - if APNS certificate configured in `MongoosePush` allows for multiple applications, this field selects the application. Please refer to `APNS` documentation for more datails diff --git a/config/config.exs b/config/config.exs index 2a777a38..d93af56c 100644 --- a/config/config.exs +++ b/config/config.exs @@ -21,10 +21,4 @@ use Mix.Config # config :logger, level: :info # -# It is also possible to import configuration files, relative to this -# directory. For example, you can emulate configuration per environment -# by uncommenting the line below and defining dev.exs, test.exs and such. -# Configuration from the imported file will override the ones defined -# here (which is why it is important to import them last). -# -# import_config "#{Mix.env}.exs" +import_config "#{Mix.env}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 00000000..54c98631 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,38 @@ +use Mix.Config + +config :maru, MongoosePush.Router, + versioning: [ + using: :path + ], + https: [ + ip: {127, 0, 0, 1}, + port: 8443, + keyfile: "priv/ssl/fake_key.pem", + certfile: "priv/ssl/fake_cert.pem", + otp_app: :mongoose_push + ] + +config :mongoose_push, fcm: [ + default: [ + key: "fake_app_key", + pool_size: 5, + mode: :prod + ] + ] + +config :mongoose_push, apns: [ + dev: [ + cert: "priv/apns/dev_cert.pem", + key: "priv/apns/dev_key.pem", + mode: :dev, + use_2197: false, + pool_size: 5 + ], + prod: [ + cert: "priv/apns/prod_cert.pem", + key: "priv/apns/prod_key.pem", + mode: :prod, + use_2197: false, + pool_size: 5 + ] + ] diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 00000000..50136506 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,38 @@ +use Mix.Config + +config :maru, MongoosePush.Router, + versioning: [ + using: :path + ], + https: [ + ip: {127, 0, 0, 1}, + port: 8443, + keyfile: "priv/ssl/fake_key.pem", + certfile: "priv/ssl/fake_cert.pem", + otp_app: :mongoose_push + ] + +config :mongoose_push, fcm: [ + prod: [ + key: "fake_app_key", + pool_size: 5, + mode: :prod + ] + ] + +config :mongoose_push, apns: [ + dev: [ + cert: "priv/apns/dev_cert.pem", + key: "priv/apns/dev_key.pem", + mode: :dev, + use_2197: false, + pool_size: 5 + ], + prod: [ + cert: "priv/apns/prod_cert.pem", + key: "priv/apns/prod_key.pem", + mode: :prod, + use_2197: false, + pool_size: 5 + ] + ] diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 00000000..107c2d88 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,56 @@ +use Mix.Config + +config :maru, MongoosePush.Router, + versioning: [ + using: :path + ], + https: [ + ip: {127, 0, 0, 1}, + port: 8443, + keyfile: "priv/ssl/fake_key.pem", + certfile: "priv/ssl/fake_cert.pem", + otp_app: :mongoose_push + ] + +config :mongoose_push, fcm: [ + default: [ + key: "fake_app_key", + endpoint: "localhost", + pool_size: 5, + mode: :prod + ] + ] + +config :mongoose_push, apns: [ + dev1: [ + endpoint: "localhost", + cert: "priv/apns/dev_cert.pem", + key: "priv/apns/dev_key.pem", + mode: :dev, + use_2197: true, + pool_size: 1 + ], + prod1: [ + endpoint: "localhost", + cert: "priv/apns/prod_cert.pem", + key: "priv/apns/prod_key.pem", + use_2197: true, + pool_size: 2 + ], + dev2: [ + endpoint: "localhost", + cert: "priv/apns/dev_cert.pem", + key: "priv/apns/dev_key.pem", + mode: :dev, + use_2197: true, + pool_size: 3 + ], + prod2: [ + endpoint: "localhost", + cert: "priv/apns/prod_cert.pem", + key: "priv/apns/prod_key.pem", + mode: :prod, + use_2197: true, + pool_size: 4 + ] + ] diff --git a/coveralls.json b/coveralls.json new file mode 100644 index 00000000..6355abae --- /dev/null +++ b/coveralls.json @@ -0,0 +1,5 @@ +{ + "skip_files": [ + "lib/mix/.*" + ] +} diff --git a/lib/mix/task_certs_dev.ex b/lib/mix/task_certs_dev.ex new file mode 100644 index 00000000..d92d1afa --- /dev/null +++ b/lib/mix/task_certs_dev.ex @@ -0,0 +1,59 @@ +defmodule Mix.Tasks.Certs.Dev do + @moduledoc """ + Generate fake certs (placeholders) for `HTTPS` endpoint and `APNS` service. + + Please be aware that `APNS` requires valid Apple Developer certificates, so it + will not accept those fake certificates. Generated certificates may be used + only with mock APNS service (like one provided by docker + `mobify/apns-http2-mock-server`). + """ + @shortdoc "Generate fake certs (placeholders) for HTTPS endpoint and APNS" + + use Mix.Task + + @spec run(term) :: :ok + def run(_) do + maybe_gen_dev_apns() + maybe_gen_prod_apns() + maybe_gen_https() + end + + defp maybe_gen_dev_apns do + maybe_gen_cert("priv/apns/dev_cert.pem", "priv/apns/dev_key.pem", + "mongoose-push-apns-dev") + end + + defp maybe_gen_prod_apns do + maybe_gen_cert("priv/apns/prod_cert.pem", "priv/apns/prod_key.pem", + "mongoose-push-apns-prod") + end + + defp maybe_gen_https do + maybe_gen_cert("priv/ssl/fake_cert.pem", "priv/ssl/fake_key.pem", + "mongoose-push") + end + + defp maybe_gen_cert(cert_file, key_file, common_name) do + if File.exists?(cert_file) and File.exists?(key_file) do + :ok + else + gen_cert(cert_file, key_file, common_name) + end + end + + defp gen_cert(cert_file, key_file, common_name) do + cert_dir = Path.dirname(cert_file) + key_dir = Path.dirname(key_file) + + :ok = File.mkdir_p(cert_dir) + :ok = File.mkdir_p(key_dir) + + {_, 0} = System.cmd("openssl", [ + "req", "-x509", "-nodes", "-days", "365", "-subj", + "/C=PL/ST=ML/L=Krakow/CN=" <> common_name, "-newkey", "rsa:2048", + "-keyout", key_file, "-out", cert_file + ]) + + :ok + end +end diff --git a/lib/mongoose_push.ex b/lib/mongoose_push.ex index ea87f24d..1f0817f5 100644 --- a/lib/mongoose_push.ex +++ b/lib/mongoose_push.ex @@ -1,18 +1,48 @@ defmodule MongoosePush do @moduledoc """ - Documentation for MongoosePush. + MongoosePush is simple (seriously) service providing ability to send push + notification to `FCM` (Firebase Cloud Messaging) and/or + `APNS` (Apple Push Notification Service). What makes it cool is not only + simplicity but also support for newest and fastest `HTTP/2` based APIs + for both services. + + At this moment only those two services are supported but in future + MongoosePush may and probably will support even more Push Notification Services. """ - @doc """ - Hello world. + require Logger + alias MongoosePush.Pools - ## Examples + @typedoc "Available keys in `request` map" + @type req_key :: :service | :body | :title | :bagde | :mode | :tag | + :topic | :click_action - iex> MongoosePush.hello - :world + @typedoc "Raw push request. The keys: `:service`, `:body` and `:title` are required" + @type request :: %{req_key => atom | String.t | integer} + @type service :: :fcm | :apns + @type mode :: :dev | :prod + + @doc """ + Push notification defined by `request` to device with `device_id`. + `request` has to define at least `:service` type (`:fcm` or `:apns`) and + both message `:title` and its `:body`. + + `:tag` is option + specific to FCM service, while `:topic` and `:bagde` are specific to APNS + (please consult their API for more informations). + + `:mode` option is also specific to APNS but it only selects appropriate + worker pool (with `:mode` set to either `:prod` or `:dev`). + Default value to `:mode` is `:prod`. """ - def hello do - :world + @spec push(String.t, request) :: :ok | {:error, term} + def push(device_id, %{:service => service} = request) do + mode = Map.get(request, :mode, :prod) + worker = Pools.select_worker(service, mode) + module = MongoosePush.Application.services()[service] + + notification = module.prepare_notification(device_id, request) + module.push(notification, device_id, worker) end end diff --git a/lib/mongoose_push/api/v1.ex b/lib/mongoose_push/api/v1.ex new file mode 100644 index 00000000..107368e1 --- /dev/null +++ b/lib/mongoose_push/api/v1.ex @@ -0,0 +1,45 @@ +defmodule MongoosePush.API.V1 do + @moduledoc false + + use Maru.Router + version "v1" + + plug Plug.Parsers, + pass: ["application/json", "text/json"], + json_decoder: Poison, + parsers: [:urlencoded, :json, :multipart] + + params do + requires :service, type: Atom, values: [:fcm, :apns] + requires :body, type: String + requires :title, type: String + optional :badge, type: Integer + optional :click_action, type: String + optional :tag, type: String + optional :topic, type: String + optional :mode, type: Atom, values: [:prod, :dev] + end + + namespace :notification do + route_param :device_id do + post do + device_id = params.device_id + case MongoosePush.push(device_id, Map.delete(params, :device_id)) do + :ok -> + conn + |> put_status(200) + |> json(nil) + {:error, reason} when is_atom(reason) -> + conn + |> put_status(500) + |> json(%{:details => reason}) + {:error, _reason} -> + conn + |> put_status(500) + |> json(nil) + end + end + end + end + +end diff --git a/lib/mongoose_push/application.ex b/lib/mongoose_push/application.ex index 65e506a1..0c1d72a1 100644 --- a/lib/mongoose_push/application.ex +++ b/lib/mongoose_push/application.ex @@ -4,19 +4,70 @@ defmodule MongoosePush.Application do @moduledoc false use Application + require Logger + @spec start(atom, list(term)) :: {:ok, pid} def start(_type, _args) do - import Supervisor.Spec, warn: false - # Define workers and child supervisors to be supervised - children = [ - # Starts a worker by calling: MongoosePush.Worker.start_link(arg1, arg2, arg3) - # worker(MongoosePush.Worker, [arg1, arg2, arg3]), - ] + + children = List.flatten(workers()) # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: MongoosePush.Supervisor] Supervisor.start_link(children, opts) end + + @spec pools_config(MongoosePush.service) :: term + def pools_config(service) do + pools_config = Application.get_env(:mongoose_push, service) + + Enum.map(pools_config, fn({pool_name, pool_config}) -> + normalized_pool_config = + pool_config + |> fix_priv_paths() + |> ensure_mode() + + {pool_name, normalized_pool_config} + end) + end + + def services do + [ + fcm: MongoosePush.Service.FCM, + apns: MongoosePush.Service.APNS + ] + end + + defp workers do + for {service, module} <- services() do + pools_config = pools_config(service) + Enum.map(pools_config, &module.workers/1) + end + end + + defp ensure_mode(config) do + case config[:mode] do + nil -> + Enum.into([mode: mode(config)], config) + _ -> + config + end + end + + defp fix_priv_paths(config) do + path_keys = [:cert, :key] + config + |> Enum.map(fn({key, value}) -> + case Enum.member?(path_keys, key) do + true -> + {key, Application.app_dir(:mongoose_push, value)} + false -> + {key, value} + end + end) + end + + defp mode(config), do: config[:mode] || :prod + end diff --git a/lib/mongoose_push/pools.ex b/lib/mongoose_push/pools.ex new file mode 100644 index 00000000..1ad13545 --- /dev/null +++ b/lib/mongoose_push/pools.ex @@ -0,0 +1,44 @@ +defmodule MongoosePush.Pools do + @moduledoc """ + This module is responsible for worker pools management. It provides several + utility functions that help with e.g. selecting workers for given pool of the + service. + """ + import MongoosePush.Application + + @doc "Returns size of the pool" + @spec pool_size(MongoosePush.service, atom) :: integer + def pool_size(service, name) do + config = pools_config(service) + config[name][:pool_size] + end + + @doc "Returns worker name based of the service type, worker name and its id" + @spec worker_name(atom, atom, integer) :: atom + def worker_name(type, name, num), do: String.to_atom(~s"#{type}_#{name}_#{num}") + + @doc "Returns lists of pool names that have selected `:mode` set" + @spec pools_by_mode(MongoosePush.service, MongoosePush.mode) :: list(atom) + def pools_by_mode(:fcm = service, _mode) do + config = pools_config(service) + Enum.map(config, &(elem(&1, 0))) + end + + def pools_by_mode(:apns = service, mode) do + config = pools_config(service) + + config + |> Enum.group_by(fn({_pool_name, pool_config}) -> + pool_config[:mode] + end) + |> Map.get(mode) + |> Keyword.keys() + end + + @doc "Return random worker name for given service and with given `:mode` set" + @spec select_worker(MongoosePush.service, MongoosePush.mode) :: atom + def select_worker(service, mode) do + [pool | _] = pools_by_mode(service, mode) + worker_name(service, pool, Enum.random(1..pool_size(service, pool))) + end +end diff --git a/lib/mongoose_push/router.ex b/lib/mongoose_push/router.ex new file mode 100644 index 00000000..7c060f1e --- /dev/null +++ b/lib/mongoose_push/router.ex @@ -0,0 +1,64 @@ +defmodule MongoosePush.Router do + @moduledoc false + + use Maru.Router + use MaruSwagger + require Logger + @test false + + plug Plug.Logger, log: :debug + + swagger at: "/swagger.json", + pretty: true, + except: [:prod], + force_json: true, + + swagger_inject: [ + basePath: "/", + schemes: ["https"], + consumes: ["application/json"], + produces: [ + "application/json", + ] + ] + + mount MongoosePush.API.V1 + + rescue_from Maru.Exceptions.NotFound do + conn + |> put_status(404) + |> json(%{details: "This is not the endpoint you are looking for."}) + end + + rescue_from Maru.Exceptions.InvalidFormat, as: e do + conn + |> put_status(400) + |> json(%{details: ~s"#{ Exception.message e }"}) + end + + rescue_from Maru.Exceptions.Validation, as: e do + conn + |> put_status(400) + |> json(%{details: ~s"#{ Exception.message e }"}) + end + + rescue_from Maru.Exceptions.MethodNotAllowed do + conn + |> put_status(405) + |> json(nil) + end + + rescue_from :all, as: e do + status = Map.get e, :plug_status, 500 + log_level = + case status >= 500 do + true -> :error + false -> :info + end + Logger.log log_level, inspect e + conn + |> put_status(status) + |> json(nil) + end + +end diff --git a/lib/mongoose_push/service.ex b/lib/mongoose_push/service.ex new file mode 100644 index 00000000..4a154949 --- /dev/null +++ b/lib/mongoose_push/service.ex @@ -0,0 +1,13 @@ +defmodule MongoosePush.Service do + @moduledoc """ + Generic interface for push notifications services. + """ + + @type notification :: term + + @callback push(Service.notification(), String.t(), atom()) :: + :ok | {:error, term} + @callback prepare_notification(String.t(), MongoosePush.request) :: + Service.notification + @callback workers({atom, Keyword.t()} | nil) :: list(Supervisor.Spec.spec()) +end diff --git a/lib/mongoose_push/service/apns.ex b/lib/mongoose_push/service/apns.ex new file mode 100644 index 00000000..3c78f3f7 --- /dev/null +++ b/lib/mongoose_push/service/apns.ex @@ -0,0 +1,60 @@ +defmodule MongoosePush.Service.APNS do + @moduledoc """ + APNS (apple Push Notification Service) service provider implementation. + """ + + @behaviour MongoosePush.Service + alias Pigeon.APNS + alias Pigeon.APNS.Config + alias Pigeon.APNS.Notification + alias MongoosePush.Service + alias MongoosePush.Pools + + @spec prepare_notification(String.t(), MongoosePush.request) :: + Service.notification + def prepare_notification(device_id, request) do + %{ + "alert" => %{ + "title" => request.title, + "body" => request.body + }, + "badge" => request[:badge], + "category" => request[:click_action] + } + |> + Notification.new(device_id, request[:topic]) + end + + @spec push(Service.notification(), String.t(), atom()) :: + :ok | {:error, term} + def push(notification, _device_id, worker) do + case APNS.push(notification, [name: worker]) do + {:ok, _state} -> + :ok + {:error, reason, _state} -> + {:error, reason} + end + end + + @spec workers({atom, Keyword.t()} | nil) :: list(Supervisor.Spec.spec()) + def workers(nil), do: [] + def workers({pool_name, pool_config}) do + pool_size = pool_config[:pool_size] + pool_config = construct_apns_endpoint_options(pool_config) + Enum.map(1..pool_size, fn(id) -> + worker_name = Pools.worker_name(:apns, pool_name, id) + worker_config = Config.config(worker_name, pool_config) + Supervisor.Spec.worker(Pigeon.APNSWorker, + [worker_config], [id: worker_name]) + end) + end + + defp construct_apns_endpoint_options(config) do + new_key = case config[:mode] do + :dev -> :development_endpoint + :prod -> :production_endpoint + end + Enum.into([{new_key, config[:endpoint]}], config) + end + +end diff --git a/lib/mongoose_push/service/fcm.ex b/lib/mongoose_push/service/fcm.ex new file mode 100644 index 00000000..1599fe8f --- /dev/null +++ b/lib/mongoose_push/service/fcm.ex @@ -0,0 +1,48 @@ +defmodule MongoosePush.Service.FCM do + @moduledoc """ + FCM (Firebase Cloud Messaging) service provider implementation. + """ + + @behaviour MongoosePush.Service + alias Pigeon.GCM + alias Pigeon.GCM.Notification + alias MongoosePush.Pools + + @spec prepare_notification(String.t(), MongoosePush.request) :: + Service.notification + def prepare_notification(device_id, request) do + msg = [:body, :title, :click_action, :tag] + |> Enum.reduce(%{}, fn(field, map) -> + Map.put(map, field, request[field]) + end) + Notification.new(device_id, msg) + end + + @spec push(Service.notification(), String.t(), atom()) :: + :ok | {:error, term} + def push(notification, device_id, worker) do + case GCM.push(notification, [name: worker]) do + {:ok, state} -> + %Pigeon.GCM.NotificationResponse{ok: ok, update: update} = state + case Enum.member?(ok ++ update, device_id) do + true -> :ok + false -> + {:error, :invalid_device_token} + end + {:error, reason, _state} -> + {:error, reason} + end + end + + @spec workers({atom, Keyword.t()} | nil) :: list(Supervisor.Spec.spec()) + def workers(nil), do: [] + def workers({pool_name, pool_config}) do + pool_size = pool_config[:pool_size] + Enum.map(1..pool_size, fn(id) -> + worker_name = Pools.worker_name(:fcm, pool_name, id) + Supervisor.Spec.worker(Pigeon.GCMWorker, + [worker_name, pool_config], [id: worker_name]) + end) + end + +end diff --git a/mix.exs b/mix.exs index 1c06c791..de0dc723 100644 --- a/mix.exs +++ b/mix.exs @@ -2,12 +2,25 @@ defmodule MongoosePush.Mixfile do use Mix.Project def project do - [app: :mongoose_push, - version: "0.1.0", - elixir: "~> 1.4", - build_embedded: Mix.env == :prod, - start_permanent: Mix.env == :prod, - deps: deps()] + [ + app: :mongoose_push, + version: "0.1.0", + elixir: "~> 1.4", + build_embedded: Mix.env == :prod, + start_permanent: Mix.env == :prod, + deps: deps(), + + # Docs + name: "MongoosePush", + source_url: "https://github.com/esl/MongoosePush", + homepage_url: "https://github.com/esl/MongoosePush", + docs: [main: "MongoosePush", # The main page in the docs + extras: ["README.md"]], + + # Test Coverage + test_coverage: [tool: ExCoveralls], + preferred_cli_env: ["coveralls": :test, "coveralls.detail": :test, "coveralls.post": :test, "coveralls.html": :test] + ] end # Configuration for the OTP application @@ -29,6 +42,19 @@ defmodule MongoosePush.Mixfile do # # Type "mix help deps" for more examples and options defp deps do - [] + [ + {:pigeon, git: "https://github.com/rslota/pigeon.git", tag: "6d1e4e3"}, + {:maru, "~> 0.11"}, + {:poison, "~> 3.0"}, + {:httpoison, "~> 0.10.0"}, + {:maru_swagger, github: "elixir-maru/maru_swagger"}, + {:distillery, "~> 1.0"}, + # Below only :dev / :test deps + {:mock, "~> 0.2.0", only: :test}, + {:excoveralls, "~> 0.6", only: :test}, + {:dialyxir, "~> 0.4", only: [:dev, :test], runtime: false}, + {:credo, "~> 0.5", only: [:dev, :test]}, + {:ex_doc, "~> 0.14", only: :dev} + ] end end diff --git a/mix.lock b/mix.lock new file mode 100644 index 00000000..16482f89 --- /dev/null +++ b/mix.lock @@ -0,0 +1,38 @@ +%{"bbmustache": {:hex, :bbmustache, "1.0.1", "6f8504fa069e6e3595593260384c6c372065dd4ead557bf5b61a2b2bbb4d80f9", [:rebar], []}, + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], []}, + "certifi": {:hex, :certifi, "0.7.0", "861a57f3808f7eb0c2d1802afeaae0fa5de813b0df0979153cbafcd853ababaf", [:rebar3], []}, + "conform": {:hex, :conform, "0.16.0", "f6d6b207c693f2f939af8edeab7c735c4fd48552c63c0a019a52b6d2d98a1ca4", [:mix], [{:neotoma, "~> 1.7.3", [hex: :neotoma, optional: false]}]}, + "cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, optional: false]}]}, + "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], []}, + "credo": {:hex, :credo, "0.6.1", "a941e2591bd2bd2055dc92b810c174650b40b8290459c89a835af9d59ac4a5f8", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, optional: false]}]}, + "dialyxir": {:hex, :dialyxir, "0.4.4", "e93ff4affc5f9e78b70dc7bec7b07da44ae1ed3fef38e7113568dd30ad7b01d3", [:mix], []}, + "distillery": {:hex, :distillery, "1.1.0", "e9943bd29557e9c252a051d8ac4b47e597cd9bf2a74332b8628eab4954eb51d7", [:mix], []}, + "earmark": {:hex, :earmark, "1.1.1", "433136b7f2e99cde88b745b3a0cfc3fbc81fe58b918a09b40fce7f00db4d8187", [:mix], []}, + "erlware_commons": {:hex, :erlware_commons, "0.13.0", "b86d493f3c3e52acba16f0a78900c12525e9eb46db3450b0d25b419aa8221115", [:rebar], []}, + "ex_doc": {:hex, :ex_doc, "0.14.5", "c0433c8117e948404d93ca69411dd575ec6be39b47802e81ca8d91017a0cf83c", [:mix], [{:earmark, "~> 1.0", [hex: :earmark, optional: false]}]}, + "excoveralls": {:hex, :excoveralls, "0.6.2", "0e993d096f1fbb6e70a3daced5c89aac066bda6bce57829622aa2d1e2b338cfb", [:mix], [{:exjsx, "~> 3.0", [hex: :exjsx, optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, optional: false]}]}, + "exjsx": {:hex, :exjsx, "3.2.1", "1bc5bf1e4fd249104178f0885030bcd75a4526f4d2a1e976f4b428d347614f0f", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, optional: false]}]}, + "exrm": {:hex, :exrm, "0.18.8", "8bc43a5da2cd54eb18fdffc6bb1608424f3897f97e816bcf752fb931c0ef1af4", [:mix], [{:conform, "~> 0.16.0", [hex: :conform, optional: false]}, {:relx, "~> 3.1.0", [hex: :relx, optional: false]}]}, + "getopt": {:hex, :getopt, "0.8.2", "b17556db683000ba50370b16c0619df1337e7af7ecbf7d64fbf8d1d6bce3109b", [:rebar], []}, + "hackney": {:hex, :hackney, "1.6.5", "8c025ee397ac94a184b0743c73b33b96465e85f90a02e210e86df6cbafaa5065", [:rebar3], [{:certifi, "0.7.0", [hex: :certifi, optional: false]}, {:idna, "1.2.0", [hex: :idna, optional: false]}, {:metrics, "1.0.1", [hex: :metrics, optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, optional: false]}]}, + "hpack": {:hex, :hpack, "1.0.2", "94f6e7214e3184d2ab9670a22c80c80b46651a17e35fd02e5c3c4d2ce2466d02", [:mix], []}, + "httpoison": {:hex, :httpoison, "0.10.0", "4727b3a5e57e9a4ff168a3c2883e20f1208103a41bccc4754f15a9366f49b676", [:mix], [{:hackney, "~> 1.6.3", [hex: :hackney, optional: false]}]}, + "idna": {:hex, :idna, "1.2.0", "ac62ee99da068f43c50dc69acf700e03a62a348360126260e87f2b54eced86b2", [:rebar3], []}, + "jsx": {:hex, :jsx, "2.8.2", "7acc7d785b5abe8a6e9adbde926a24e481f29956dd8b4df49e3e4e7bcc92a018", [:mix, :rebar3], []}, + "kadabra": {:git, "https://github.com/rslota/kadabra.git", "9f6e41076f5ad93866a7d4486261c830642a8f63", [tag: "9f6e4107"]}, + "maru": {:hex, :maru, "0.11.3", "0bf2f26955430c4878dab91fe44aeb8b3aaed338b4f6ffd0729ea119284c374d", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: false]}, {:plug, "~> 1.0", [hex: :plug, optional: false]}, {:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, optional: false]}]}, + "maru_swagger": {:git, "https://github.com/elixir-maru/maru_swagger.git", "d4376dd2e3470b66ffde3ec94caaf9f596997804", []}, + "meck": {:hex, :meck, "0.8.4", "59ca1cd971372aa223138efcf9b29475bde299e1953046a0c727184790ab1520", [:make, :rebar], []}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], []}, + "mime": {:hex, :mime, "1.0.1", "05c393850524767d13a53627df71beeebb016205eb43bfbd92d14d24ec7a1b51", [:mix], []}, + "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], []}, + "mock": {:hex, :mock, "0.2.1", "bfdba786903e77f9c18772dee472d020ceb8ef000783e737725a4c8f54ad28ec", [:mix], [{:meck, "~> 0.8.2", [hex: :meck, optional: false]}]}, + "neotoma": {:hex, :neotoma, "1.7.3", "d8bd5404b73273989946e4f4f6d529e5c2088f5fa1ca790b4dbe81f4be408e61", [:rebar], []}, + "pigeon": {:git, "https://github.com/rslota/pigeon.git", "6d1e4e3446e30822b6cede650c91275f7b15afdd", [tag: "6d1e4e3"]}, + "plug": {:hex, :plug, "1.3.0", "6e2b01afc5db3fd011ca4a16efd9cb424528c157c30a44a0186bcc92c7b2e8f3", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, optional: true]}, {:mime, "~> 1.0", [hex: :mime, optional: false]}]}, + "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], []}, + "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []}, + "providers": {:hex, :providers, "1.4.1", "e74176b29d4771544410af5022fe1306b100d0f57c9e0f3656bcff5962f87cd2", [:rebar], [{:getopt, "0.8.2", [hex: :getopt, optional: false]}]}, + "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], []}, + "relx": {:hex, :relx, "3.1.0", "71b7e00e83543c92512d785a268db78a970fe5d811171329623784daca7d23dc", [:rebar], [{:bbmustache, "1.0.1", [hex: :bbmustache, optional: false]}, {:erlware_commons, "0.13.0", [hex: :erlware_commons, optional: false]}, {:getopt, "0.8.2", [hex: :getopt, optional: false]}, {:providers, "1.4.1", [hex: :providers, optional: false]}]}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], []}} diff --git a/priv/.keep b/priv/.keep new file mode 100644 index 00000000..e69de29b diff --git a/rel/config.exs b/rel/config.exs new file mode 100644 index 00000000..720edb8d --- /dev/null +++ b/rel/config.exs @@ -0,0 +1,43 @@ +# Import all plugins from `rel/plugins` +# They can then be used by adding `plugin MyPlugin` to +# either an environment, or release definition, where +# `MyPlugin` is the name of the plugin module. +Path.join(["rel", "plugins", "*.exs"]) +|> Path.wildcard() +|> Enum.map(&Code.eval_file(&1)) + +use Mix.Releases.Config, + # This sets the default release built by `mix release` + default_release: :default, + # This sets the default environment used by `mix release` + default_environment: :prod + +# For a full list of config options for both releases +# and environments, visit https://hexdocs.pm/distillery/configuration.html + + +# You may define one or more environments in this file, +# an environment's settings will override those of a release +# when building in that environment, this combination of release +# and environment configuration is called a profile + +environment :dev do + set dev_mode: true + set include_erts: false + set cookie: :monster +end + +environment :prod do + set include_erts: true + set include_src: false + set cookie: :"_*0hiIDApqi?*1LY**H3eWMkC.Lu,VTDF;9.D/y^o4?*?3,V5u/~,c,/o5/`myw." +end + +# You may define one or more releases in this file. +# If you have not set a default release, or selected one +# when running `mix release`, the first release in the file +# will be used by default + +release :mongoose_push do + set version: current_version(:mongoose_push) +end diff --git a/test/mongoose_push_application_test.exs b/test/mongoose_push_application_test.exs new file mode 100644 index 00000000..08e8c4cf --- /dev/null +++ b/test/mongoose_push_application_test.exs @@ -0,0 +1,56 @@ +defmodule MongoosePushApplicationTest do + use ExUnit.Case + import MongoosePush.Application + import MongoosePush.Pools + doctest MongoosePush.Application + + setup do + # Validate config/text.exs that is need for this test suite + apns_pools = Keyword.keys(Application.get_env(:mongoose_push, :apns)) + [:dev1, :dev2, :prod1, :prod2] = Enum.sort(apns_pools) + + fcm_pools = Keyword.keys(Application.get_env(:mongoose_push, :fcm)) + [:default] = fcm_pools + + :ok + end + + test "pools online" do + assert Process.alive?(Process.whereis(worker_name(:apns, :prod1, 1))) + assert Process.alive?(Process.whereis(worker_name(:apns, :prod1, 2))) + + assert Process.alive?(Process.whereis(worker_name(:apns, :prod2, 1))) + assert Process.alive?(Process.whereis(worker_name(:apns, :prod2, 4))) + + assert Process.alive?(Process.whereis(worker_name(:apns, :dev1, 1))) + + assert Process.alive?(Process.whereis(worker_name(:apns, :dev2, 1))) + assert Process.alive?(Process.whereis(worker_name(:apns, :dev2, 3))) + + assert Process.alive?(Process.whereis(worker_name(:fcm, :default, 1))) + assert Process.alive?(Process.whereis(worker_name(:fcm, :default, 5))) + end + + test "pools have corrent size" do + assert nil == Process.whereis(worker_name(:apns, :dev1, 2)) + assert nil == Process.whereis(worker_name(:apns, :prod1, 3)) + assert nil == Process.whereis(worker_name(:apns, :dev2, 4)) + assert nil == Process.whereis(worker_name(:apns, :prod2, 5)) + assert nil == Process.whereis(worker_name(:fcm, :default, 6)) + end + + test "application starts and stops" do + :ok = Application.stop(:mongoose_push) + :ok = Application.start(:mongoose_push, :temporary) + end + + test "workers are stoped along with the application" do + :ok = Application.stop(:mongoose_push) + assert nil == Process.whereis(worker_name(:apns, :dev1, 1)) + assert nil == Process.whereis(worker_name(:apns, :prod1, 1)) + assert nil == Process.whereis(worker_name(:apns, :dev2, 1)) + assert nil == Process.whereis(worker_name(:apns, :prod2, 1)) + assert nil == Process.whereis(worker_name(:fcm, :default, 1)) + :ok = Application.start(:mongoose_push, :temporary) + end +end diff --git a/test/mongoose_push_pools_test.exs b/test/mongoose_push_pools_test.exs new file mode 100644 index 00000000..1a52eaee --- /dev/null +++ b/test/mongoose_push_pools_test.exs @@ -0,0 +1,36 @@ +defmodule MongoosePushPoolsTest do + use ExUnit.Case + import MongoosePush.Pools + doctest MongoosePush.Pools + + setup do + # Validate config/text.exs that is need for this test suite + apns_pools = Keyword.keys(Application.get_env(:mongoose_push, :apns)) + [:dev1, :dev2, :prod1, :prod2] = Enum.sort(apns_pools) + + fcm_pools = Keyword.keys(Application.get_env(:mongoose_push, :fcm)) + [:default] = fcm_pools + + :ok + end + + test "worker name" do + assert :apns_name1_1 == worker_name(:apns, :name1, 1) + assert :fcm_name2_12 == worker_name(:fcm, :name2, 12) + end + + test "pool groups" do + assert [:dev1, :dev2] == pools_by_mode(:apns, :dev) + assert [:prod1, :prod2] == pools_by_mode(:apns, :prod) + assert [:default] == pools_by_mode(:fcm, :default) + end + + test "pool size" do + assert 1 == pool_size(:apns, :dev1) + assert 2 == pool_size(:apns, :prod1) + assert 3 == pool_size(:apns, :dev2) + assert 4 == pool_size(:apns, :prod2) + assert 5 == pool_size(:fcm, :default) + end + +end diff --git a/test/mongoose_push_test.exs b/test/mongoose_push_test.exs index 8aee607a..ebe0b46f 100644 --- a/test/mongoose_push_test.exs +++ b/test/mongoose_push_test.exs @@ -1,8 +1,145 @@ defmodule MongoosePushTest do use ExUnit.Case + import MongoosePush + import Mock doctest MongoosePush - test "the truth" do - assert 1 + 1 == 2 + setup do + reset(:fcm) + reset(:apns) + end + + test "simple push to apns succeeds" do + assert :ok == push("device_id", + %{:service => :apns, :title => "", :body => ""}) + end + + test "push to apns assign correct message fields" do + notification = + %{:service => :apns, + :title => "title value", + :body => "body value", + :badge => 5, + :click_action => "click.action" + } + + assert :ok == push("testdeviceid1234", notification) + + apns_request = last_activity(:apns) + aps_data = apns_request["request_data"]["aps"] + + assert "testdeviceid1234" == apns_request["device_token"] + assert notification[:title] == aps_data["alert"]["title"] + assert notification[:body] == aps_data["alert"]["body"] + assert notification[:badge] == aps_data["badge"] + assert notification[:click_action] == aps_data["category"] + + end + + test "push to fcm assign correct message fields" do + notification = + %{:service => :fcm, + :title => "title value", + :body => "body value", + :click_action => "click.action", + :tag => "tag value" + } + + assert :ok == push("androidtestdeviceid12", notification) + fcm_request = last_activity(:fcm) + fcm_data = fcm_request["request_data"]["notification"] + + assert "androidtestdeviceid12" == fcm_request["device_token"] + assert notification[:title] == fcm_data["title"] + assert notification[:body] == fcm_data["body"] + assert notification[:click_action] == fcm_data["click_action"] + assert notification[:tag] == fcm_data["tag"] + + end + + test "push to fcm with unknown token fails" do + notification = + %{:service => :fcm, + :title => "title value", + :body => "body value", + :click_action => "click.action", + :tag => "tag value" + } + fail_tokens(:fcm, [%{device_token: "androidtestdeviceid65", status: 200, + reason: "InvalidRegistration"}]) + + assert {:error, _} = push("androidtestdeviceid65", notification) + end + + test "push to apns allows choosing mode" do + notification = + %{:service => :apns, + :title => "title value", + :body => "body value", + } + dev_notification = Map.put(notification, :mode, :dev) + prod_notification = Map.put(notification, :mode, :prod) + + # Default should be mode: :prod + with_mock MongoosePush.Pools, [:passthrough], [] do + assert :ok = push("androidtestdeviceid65", notification) + assert called(MongoosePush.Pools.select_worker(:_, :prod)) + end + + with_mock MongoosePush.Pools, [:passthrough], [] do + assert :ok = push("androidtestdeviceid65", dev_notification) + assert called(MongoosePush.Pools.select_worker(:_, :dev)) + end + + with_mock MongoosePush.Pools, [:passthrough], [] do + assert :ok = push("androidtestdeviceid65", prod_notification) + assert called(MongoosePush.Pools.select_worker(:_, :prod)) + end + end + + defp reset(service) do + {:ok, conn} = get_connection(service) + Kadabra.post(conn, "/reset", "") + get_response() + :ok + end + + defp fail_tokens(service, json) do + {:ok, conn} = get_connection(service) + payload = Poison.encode!(json) + + headers = [ + {":method", "POST"}, + {":path", "/error-tokens"}, + {"content-length", "#{byte_size(payload)}"}, + {"content-type", "application/json"} + ] + Kadabra.request(conn, headers, payload) + get_response() + :ok + end + + defp last_activity(service) do + {:ok, conn} = get_connection(service) + Kadabra.get(conn, "/activity") + get_response() + |> Poison.decode! + |> Map.get("logs") + |> List.last + end + + defp get_connection(:apns) do + Kadabra.open('localhost', :https, [port: 2197]) + end + + defp get_connection(:fcm) do + Kadabra.open('localhost', :https, [port: 443]) + end + + defp get_response() do + receive do + {:end_stream, %Kadabra.Stream{body: body}} -> + body + end end end diff --git a/test/rest_v1_test.exs b/test/rest_v1_test.exs new file mode 100644 index 00000000..62c593bc --- /dev/null +++ b/test/rest_v1_test.exs @@ -0,0 +1,75 @@ +defmodule RestV1Test do + use ExUnit.Case + import Mock + alias HTTPoison.Response + doctest MongoosePush.API.V1 + + test "incorrect path returns 404" do + assert 404 = post("/notification", %{}) + assert 404 = post("/v1/notification", %{}) + assert 404 = post("/v1/notifications/test", %{}) + end + + test "incorrect params returns 400" do + url = "/v1/notification/f534534543" + assert 400 = post(url, %{service: :apns}) + assert 400 = post(url, %{service: :fcm}) + assert 400 = post(url, %{service: :apns, body: "body"}) + assert 400 = post(url, %{service: :fcm, title: "title"}) + assert 400 = post(url, %{service: "other", body: "body", title: "title"}) + end + + test "invalid method returns 405" do + assert 405 = get("/v1/notification/test") + end + + test "api crash returns 500" do + url = "/v1/notification/f534534543" + with_mock MongoosePush, [push: fn(_, _) -> raise "oops" end] do + assert 500 = post(url, %{service: :fcm, body: "body", title: "title"}) + end + end + + test "correct params return 200" do + url = "/v1/notification/f534534543" + with_mock MongoosePush, [push: fn(_, _) -> :ok end] do + assert 200 = post(url, %{service: :apns, body: "body", title: "title"}) + assert 200 = post(url, %{service: :fcm, body: "body", title: "title"}) + end + end + + test "push error returns 500" do + url = "/v1/notification/f534534543" + with_mock MongoosePush, [push: fn(_, _) -> {:error, :something} end] do + assert 500 = post(url, %{service: :apns, body: "body", title: "title"}) + assert 500 = post(url, %{service: :fcm, body: "body", title: "title"}) + end + end + + test "api gets corrent request arguments" do + url = "/v1/notification/f534534543" + with_mock MongoosePush, [push: fn(_, _) -> :ok end] do + args = %{ + service: :fcm, body: "body654", title: "title345", mode: :dev, + topic: "apns topic", badge: 10, tag: "tag123", click_action: "on.click" + } + assert 200 = post(url, args) + assert called MongoosePush.push("f534534543", args) + end + end + + defp post(path, json) do + %Response{status_code: status_code} = + HTTPoison.post!("https://localhost:8443" <> path, Poison.encode!(json), + [{"Content-Type", "application/json"}], + hackney: [:insecure]) + status_code + end + + defp get(path) do + %Response{status_code: status_code} = + HTTPoison.get!("https://localhost:8443" <> path, [], hackney: [:insecure]) + status_code + end + +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e7..7031df9d 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,2 @@ ExUnit.start() +Maru.Test.start() diff --git a/tools/travis-setup.sh b/tools/travis-setup.sh new file mode 100755 index 00000000..1e75d368 --- /dev/null +++ b/tools/travis-setup.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -e + +# Skip this setup for jobs that don't run exunit +test "${PRESET}" == "exunit" || exit 0 + +## Generate fake certs +mix certs.dev + +## Start mocks +docker run -d -p 2197:2197 mobify/apns-http2-mock-server +docker run -d -p 443:443 rslota/fcm-http2-mock-server diff --git a/tools/travis-test.sh b/tools/travis-test.sh new file mode 100755 index 00000000..07a0a9f6 --- /dev/null +++ b/tools/travis-test.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -e + +case "${PRESET}" in + "exunit" ) + mix coveralls.travis + ;; + "dialyzer" ) + mix dialyzer + ;; + "credo" ) + mix credo --ignore design + ;; + * ) + echo "Unknown PRESET value" || exit 1 +esac