diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ad6344e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up BEAM + uses: erlef/setup-beam@v1 + with: + otp-version: "26.2.5.8" + elixir-version: "1.18.3" + + - name: Install dependencies + run: mix deps.get + + - name: Check formatting + run: mix format --check-formatted + + - name: Compile + run: mix compile --warnings-as-errors + + - name: Audit dependencies + run: mix deps.audit + + - name: Run Credo + run: mix credo --strict + + - name: Run tests with coverage + run: mix test --cover + + - name: Run Dialyzer + run: mix dialyzer --format short diff --git a/.tool-versions b/.tool-versions index 74feae7..5a46bb6 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ erlang 26.2.5.8 -elixir 1.17.3-otp-26 +elixir 1.18.3-otp-26 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..347449a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,53 @@ +# AGENTS.md + +Guidance for automated coding agents working in this repository. + +## Project Context + +Rocket is an Elixir library for Firebase Cloud Messaging HTTP v1. Phases 1-3 of the cleanup plan are complete: tests, reproducible tooling, correctness fixes, modernization, CI, static analysis, and documentation are in place. New features are still Phase 4 future work and should not be mixed into cleanup changes. + +## Current State + +- The supported local toolchain is Elixir 1.18.3 / Erlang 26.2.5.8. +- The test suite covers configuration, request handling, response parsing, default response logging, GenStage flow, supervision, and the public `Rocket.push/1` boundary. +- Built-in Mix coverage enforces a 90% threshold; the latest local run reported 91.43% total coverage. +- CI runs formatting, compilation with warnings as errors, dependency audit, Credo, tests with coverage, and Dialyzer. +- Response handlers implement `call/3`. +- HTTP clients implement `Rocket.HTTPClient`; Rocket defaults to `Rocket.HTTPClient.Finch`. +- `Rocket.push/1` is documented as the synchronous public request API. +- GenStage producer/consumer modules remain available for queued internal processing. +- Supervisor child specs and config syntax have been modernized. + +## Working Rules + +- Keep tests ahead of runtime behavior changes. Add or update tests that describe the intended behavior before changing it. +- Maintain very high coverage. Coverage should include error paths, not only happy paths. +- Avoid live network calls in tests. Stub or inject HTTP and Goth interactions. +- Preserve the existing public API unless a breaking change is deliberately documented and agreed. +- Keep cleanup PRs small and focused. Separate test scaffolding, toolchain updates, refactors, dependency changes, and future feature work. +- Do not commit secrets, Firebase credentials, service-account JSON, `.env` files, `_build/`, `deps/`, `cover/`, or generated docs. +- Prefer clear return values over log-only behavior when making correctness fixes, but document any API change before implementing it. + +## Expected Commands + +These commands should work from a fresh checkout: + +```sh +mix deps.get +mix format --check-formatted +mix compile --warnings-as-errors +mix deps.audit +mix credo --strict +mix test +mix test --cover +mix dialyzer --format short +mix docs +``` + +If the selected coverage or static-analysis tools change, update this file and `TODO.md`. + +## Suggested Cleanup Order + +1. Keep Phase 1-3 maintenance checks green. +2. Treat Phase 4 new features as separate work. +3. For future features, add tests, docs, and compatibility notes with the implementation. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a3cd7ed --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## Unreleased + +- Replaced HTTPoison with Finch for FCM HTTP requests. +- Added a supervised Finch pool named `Rocket.Finch`. +- Added `Rocket.HTTPClient` so applications can provide a custom HTTP client; Finch remains the default adapter and is declared as an optional direct dependency. +- Preserved the stable public API while preparing a minor `0.1.0` release. +- Added a high-coverage ExUnit test suite around request handling, response parsing, configuration, GenStage flow, and supervision. +- Updated the supported local toolchain to Elixir 1.18.3 / Erlang 26.2.5.8. +- Switched to built-in Mix coverage with a 90% threshold. +- Added CI for formatting, compilation, dependency audit, Credo, tests with coverage, and Dialyzer. +- Modernized supervisor child specs and configuration syntax. +- Fixed response handler callback compatibility. +- Normalized request and response errors into structured `{:ok, term}` and `{:error, term}` results. +- Added project documentation and package documentation metadata. diff --git a/README.md b/README.md new file mode 100644 index 0000000..eba6bcb --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Rocket + +Rocket is an Elixir client for Firebase Cloud Messaging HTTP v1. + +The current maintenance focus is test coverage, correctness, and modernization. New features are intentionally deferred until the cleanup baseline is stable. + +## Requirements + +- Elixir 1.18.x +- Erlang/OTP 26.2.x + +The local toolchain is documented in `.tool-versions`. + +## Installation + +Add Rocket to your dependencies: + +```elixir +def deps do + [ + {:rocket, "~> 0.1"} + ] +end +``` + +## Configuration + +Rocket expects Google service-account credentials as JSON. The default configuration reads credentials from the `GCP_CREDENTIALS` environment variable: + +```sh +export GCP_CREDENTIALS='{"project_id":"my-project", "...":"..."}' +``` + +The default response handler logs request outcomes: + +```elixir +config :rocket, + response_handler: Rocket.Response.DefaultHandler, + workers: 2 +``` + +Custom response handlers implement `Rocket.Response.ResponseHandler`. + +## Usage + +`Rocket.push/1` performs a synchronous FCM request and returns a structured result: + +```elixir +payload = %{ + "message" => %{ + "token" => "device-token", + "notification" => %{ + "title" => "Hello", + "body" => "World" + } + } +} + +Rocket.push(payload) +``` + +Successful responses return `{:ok, decoded_body}`. Request, configuration, encoding, HTTP, and FCM errors return `{:error, reason}`. + +The GenStage producer/consumer modules are used for queued internal processing. They are not the public boundary for new features unless that API is deliberately designed and documented. + +## Development + +Fetch dependencies: + +```sh +mix deps.get +``` + +Run the verification suite: + +```sh +mix format --check-formatted +mix compile --warnings-as-errors +mix deps.audit +mix credo --strict +mix test --cover +mix dialyzer --format short +``` + +Rocket defaults to Finch for HTTP requests and starts a supervised pool named `Rocket.Finch` when the default client is used. Finch is an optional dependency; applications that provide their own `:http_client` do not need to include it. + +The coverage threshold is enforced by `mix test --cover` and is currently set to 90%. + +## Release Notes + +See `CHANGELOG.md`. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..7fe630c --- /dev/null +++ b/TODO.md @@ -0,0 +1,102 @@ +# TODO + +This project is an outdated Elixir library for sending Firebase Cloud Messaging HTTP v1 requests. The first phase should improve confidence, maintainability, and release hygiene without adding new user-facing features. New features belong in future work after the baseline is tested and modernized. + +## Phase 1: Safety Baseline + +- [x] Add a comprehensive test suite before changing runtime behavior. + - Target very high coverage, ideally 90%+ line coverage and meaningful branch/error coverage. + - Cover `Rocket.Config`, `Rocket.Request`, `Rocket.Response`, `Rocket.Response.DefaultHandler`, `Rocket.PushCollector`, `Rocket.Pusher`, and `Rocket.Application`. + - Include success responses, client errors, server errors, invalid JSON bodies, Finch errors, missing/invalid Goth configuration, and GenStage producer/consumer flow. + - Avoid live FCM/GCP calls in normal tests. Use explicit test doubles, dependency injection, or controlled mocks for Goth and HTTP clients. +- [x] Make the local toolchain reproducible. + - Resolved the mismatch between `mix.exs` and `.tool-versions`. + - Supported local toolchain is Elixir 1.18.3 / Erlang 26.2.5.8. + - Ensure `mix test`, `mix format --check-formatted`, and coverage commands run from a fresh checkout. +- [x] Add CI that runs formatting, compilation with warnings treated seriously, tests, and coverage reporting. +- [x] Decide whether to keep ExCoveralls or move to a simpler built-in coverage flow, then enforce a minimum coverage threshold. + - Moved to built-in Mix coverage with a 90% threshold. +- [x] Add or restore basic project documentation, including installation, configuration, usage, testing, and release notes. + +## Phase 2: Correctness And Compatibility + +- [x] Fix the `Rocket.Response.ResponseHandler` behaviour mismatch. + - The behaviour defines `call/3`, while `Rocket.Response.DefaultHandler` currently implements `call/2`. + - `Rocket.Request` currently calls `handler.call(status, payload, decoded_body)`, which will fail with the default handler. +- [x] Decide and document the public API boundary. + - Clarify whether `Rocket.push/1` is meant to perform a synchronous request or enqueue work through `Rocket.PushCollector`. + - Preserve backwards compatibility unless a breaking change is explicitly planned. +- [x] Normalize response handling. + - Avoid duplicated response parsing between `Rocket.Request` and `Rocket.Response`. + - Return structured success/error tuples where practical instead of logging-only outcomes. + - Keep logging useful but avoid making logs the only observable result. +- [x] Harden request error handling. + - Handle configuration failures without pattern-match crashes. + - Handle Finch transport errors beyond simple timeout cases. + - Avoid raising on JSON encoding failures unless that is intentionally part of the API. +- [x] Add tests for concurrency and supervision. + - Verify the configured worker count. + - Verify pushed events are consumed. + - Verify failures do not permanently break the pipeline. + +## Phase 3: Modernization + +- [x] Replace deprecated supervisor child specs from `Supervisor.Spec` with modern child specs. +- [x] Review dependency versions and remove unused dependencies. + - `mix.lock` contains packages not listed in `mix.exs`, suggesting old development dependencies or stale lock entries. + - Confirm whether `mix_test_watch`, `gen_stage`, `finch`, and `goth` are still the right choices. +- [x] Update configuration style for modern Elixir. + - Replace `use Mix.Config` with `import Config` when the supported Elixir version allows it. + - Move environment-specific configuration into explicit files only when needed. +- [x] Add static analysis once the test baseline is in place. + - Consider Credo for style/readability. + - Consider Dialyzer after typespecs and return contracts are clarified. +- [x] Improve module docs and typespecs for public functions. +- [x] Review package metadata before any Hex release. + - Confirm maintainers, links, versioning, changelog, and documentation generation. + +## Phase 4: Future Work + +- [ ] Define the Phase 4 public API before adding implementation. + - Decide whether queued delivery should be exposed as `Rocket.push_async/1`, `Rocket.push_many/1`, a supervised worker API, or a separate module. + - Keep `Rocket.push/1` as the synchronous API unless a breaking change is explicitly planned. + - Document return contracts, failure semantics, ordering guarantees, and backpressure behaviour before implementation. +- [ ] Add payload validation helpers for FCM HTTP v1 messages. + - Validate that payloads include a `message` object and at least one supported target such as `token`, `topic`, or `condition`. + - Validate common notification, data, Android, APNs, and webpush shapes without trying to replace Firebase's full server-side validation. + - Return structured validation errors before encoding/posting. +- [ ] Add a first-class batch API. + - Support sending many messages while preserving per-message results. + - Decide whether batching should be sequential, concurrent with a bounded concurrency limit, or queued through GenStage. + - Include tests for partial success, partial failure, ordering, and backpressure. +- [ ] Design retry and rate-limit handling. + - Add configurable retry policy for retryable HTTP/client errors such as timeouts, 429, 500, and 503. + - Respect FCM retry hints if present in response headers or body. + - Ensure non-retryable validation/auth errors fail fast. + - Include tests for retry count, delay calculation, and eventual failure. +- [ ] Add request options without global configuration mutation. + - Allow per-call overrides for response handler, timeout, retry policy, config provider, HTTP client, and telemetry metadata where appropriate. + - Keep defaults in application config for normal use. +- [ ] Improve credential source support. + - Support service-account JSON from application config, environment variables, and file paths. + - Evaluate whether a supervised Goth token server should replace per-request token fetching. + - Document token caching, refresh behaviour, and deployment recommendations. +- [ ] Add telemetry instrumentation. + - Emit events for request start/stop/exception, FCM status codes, retry attempts, queue depth, and pusher failures. + - Keep logs as a default handler, but make telemetry the primary integration point for observability. +- [ ] Clarify and improve the queued delivery pipeline. + - Decide whether GenStage remains necessary or whether `Task.Supervisor`/Broadway is a better fit. + - Add configurable queue limits and overflow strategy. + - Add graceful shutdown/drain behaviour for queued messages. +- [ ] Add richer response types. + - Convert common FCM error responses into named error structs or tagged tuples. + - Preserve raw response details where useful for debugging. + - Document compatibility guarantees for response shapes. +- [ ] Add integration tests that can run explicitly against Firebase. + - Keep live tests disabled by default. + - Require opt-in environment variables and clear documentation. + - Ensure normal CI remains deterministic and does not require GCP credentials. +- [ ] Prepare for a Hex release. + - Decide the next version number and whether API changes are breaking. + - Finalize changelog entries, generated docs, package files, and release checklist. + - Consider adding examples for Phoenix apps, releases, and supervised production usage. diff --git a/config/config.exs b/config/config.exs index 8bc2d30..c914172 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,6 +1,4 @@ -# This file is responsible for configuring your application -# and its dependencies with the aid of the Mix.Config module. -use Mix.Config +import Config # This configuration is loaded before any dependency and is restricted # to this project. If another project depends on this project, this @@ -30,6 +28,9 @@ use Mix.Config # import_config "#{Mix.env}.exs" config :rocket, + config_provider: Rocket.Config, + http_client: Rocket.HTTPClient.Finch, + request_module: Rocket.Request, response_handler: Rocket.Response.DefaultHandler, workers: 2 diff --git a/lib/rocket.ex b/lib/rocket.ex index 3745fb5..25a565b 100644 --- a/lib/rocket.ex +++ b/lib/rocket.ex @@ -1,12 +1,14 @@ defmodule Rocket do @moduledoc """ - The Worker of Push Client for Exq - PushWorker can be used to issue Firebase Downstream Messages. + Firebase Cloud Messaging HTTP v1 client. """ alias Rocket.Request - require Logger + @doc """ + Sends a single payload to FCM synchronously. + """ + @spec push(term()) :: {:ok, map()} | {:error, term()} def push(payload) do Request.perform(payload) end diff --git a/lib/rocket/application.ex b/lib/rocket/application.ex index 5b6519f..9783948 100644 --- a/lib/rocket/application.ex +++ b/lib/rocket/application.ex @@ -1,19 +1,33 @@ defmodule Rocket.Application do + @moduledoc false + use Application - import Supervisor.Spec def start(_type, _args) do workers = Application.get_env(:rocket, :workers, 2) - children = [worker(Rocket.PushCollector, [0])] |> add_worker(workers, 1) + children = finch_children() ++ [Rocket.PushCollector] ++ pusher_children(workers) - # See https://hexdocs.pm/elixir/Supervisor.html - # for other strategies and supported options opts = [strategy: :one_for_one, name: Rocket.Supervisor] Supervisor.start_link(children, opts) end - defp add_worker(workers, amount, current) when amount == current, do: workers ++ [worker_id(current)] - defp add_worker(workers, amount, current), do: (workers ++ [worker_id(current)]) |> add_worker(amount, current + 1) + defp finch_children do + if http_client() == Rocket.HTTPClient.Finch and Code.ensure_loaded?(Finch) do + [{Finch, name: Application.get_env(:rocket, :finch_pool, Rocket.Finch)}] + else + [] + end + end + + defp pusher_children(workers) when workers > 0 do + for worker_id <- 1..workers do + Supervisor.child_spec({Rocket.Pusher, []}, id: {Rocket.Pusher, worker_id}) + end + end + + defp pusher_children(_workers), do: [] - defp worker_id(id), do: worker(Rocket.Pusher, [], id: id) + defp http_client do + Application.get_env(:rocket, :http_client, Rocket.HTTPClient.Finch) + end end diff --git a/lib/rocket/config.ex b/lib/rocket/config.ex index 3ae8237..324f80e 100644 --- a/lib/rocket/config.ex +++ b/lib/rocket/config.ex @@ -1,24 +1,41 @@ defmodule Rocket.Config do @moduledoc ~S" - A configuration for FCM + Builds request configuration for FCM HTTP v1. " + + @behaviour Rocket.ConfigProvider + use Goth.Config + @scope "https://www.googleapis.com/auth/firebase.messaging" + + @impl Goth.Config def init(config) do - {:ok, Keyword.put(config, :json, System.get_env("GCP_CREDENTIALS"))} + {:ok, Keyword.put_new_lazy(config, :json, fn -> System.get_env("GCP_CREDENTIALS") end)} + end + + @impl Rocket.ConfigProvider + def generate do + generate([]) end - def generate() do - with {:ok, %Goth.Token{token: access_token}} <- token(), + @spec generate(keyword()) :: {:ok, Rocket.ConfigProvider.request_config()} | {:error, term()} + def generate(opts) do + token_fun = Keyword.get(opts, :token_fun, &token/0) + project_id_fun = Keyword.get(opts, :project_id_fun, &get_project_id/0) + + with {:ok, %Goth.Token{token: access_token}} <- token_fun.(), header <- header(access_token), - {:ok, project_id} <- get_project_id(), + {:ok, project_id} <- project_id_fun.(), url <- get_url(project_id) do - {:ok, %{header: header, url: url}} + {:ok, %{headers: header, url: url}} else - _error -> {:error, "failed getting request configuration"} + {:error, reason} -> {:error, reason} + _error -> {:error, :request_configuration_failed} end end + @spec header(String.t()) :: [{String.t(), String.t()}] def header(access_token) do [ {"Content-Type", "application/json"}, @@ -26,15 +43,51 @@ defmodule Rocket.Config do ] end + @spec token() :: {:ok, Goth.Token.t()} | {:error, term()} def token do - Goth.Token.for_scope("https://www.googleapis.com/auth/firebase.messaging") + case credentials() do + {:ok, credentials} -> + Goth.Token.fetch(source: {:service_account, credentials, scopes: [@scope]}) + + {:error, reason} -> + {:error, reason} + end end + @spec get_project_id() :: {:ok, String.t()} | {:error, term()} def get_project_id do - Goth.Config.get(:project_id) + case credentials() do + {:ok, %{"project_id" => project_id}} when is_binary(project_id) and project_id != "" -> {:ok, project_id} + {:ok, _credentials} -> {:error, :missing_project_id} + {:error, reason} -> {:error, reason} + end end + @spec get_url(String.t()) :: String.t() def get_url(project_id) do "https://fcm.googleapis.com/v1/projects/#{project_id}/messages:send" end + + defp credentials do + with {:ok, json} <- credentials_json(), + {:ok, credentials} <- Jason.decode(json) do + {:ok, credentials} + else + {:error, %Jason.DecodeError{} = error} -> {:error, {:invalid_credentials_json, error}} + {:error, reason} -> {:error, reason} + end + end + + defp credentials_json do + cond do + is_binary(Application.get_env(:goth, :json)) -> + {:ok, Application.get_env(:goth, :json)} + + is_binary(System.get_env("GCP_CREDENTIALS")) -> + {:ok, System.get_env("GCP_CREDENTIALS")} + + true -> + {:error, :missing_credentials} + end + end end diff --git a/lib/rocket/config_provider.ex b/lib/rocket/config_provider.ex new file mode 100644 index 0000000..35df724 --- /dev/null +++ b/lib/rocket/config_provider.ex @@ -0,0 +1,12 @@ +defmodule Rocket.ConfigProvider do + @moduledoc """ + Behaviour for modules that build request configuration. + """ + + @type request_config :: %{ + required(:headers) => [{String.t(), String.t()}], + required(:url) => String.t() + } + + @callback generate() :: {:ok, request_config()} | {:error, term()} +end diff --git a/lib/rocket/http_client.ex b/lib/rocket/http_client.ex new file mode 100644 index 0000000..ba24653 --- /dev/null +++ b/lib/rocket/http_client.ex @@ -0,0 +1,10 @@ +defmodule Rocket.HTTPClient do + @moduledoc """ + Behaviour for HTTP clients used by `Rocket.Request`. + """ + + @type response :: %{required(:status) => integer(), required(:body) => binary()} + + @callback post(String.t(), [{String.t(), String.t()}], iodata(), keyword()) :: + {:ok, response()} | {:error, term()} +end diff --git a/lib/rocket/http_client/finch.ex b/lib/rocket/http_client/finch.ex new file mode 100644 index 0000000..b184b2b --- /dev/null +++ b/lib/rocket/http_client/finch.ex @@ -0,0 +1,35 @@ +defmodule Rocket.HTTPClient.Finch do + @moduledoc """ + Finch-backed HTTP client for Rocket. + """ + + @behaviour Rocket.HTTPClient + + @impl Rocket.HTTPClient + def post(url, headers, body, opts) do + with {:ok, finch} <- finch() do + request = finch.build(:post, url, headers, body) + + request + |> finch.request(pool_name(), opts) + |> normalize_response() + end + end + + defp normalize_response({:ok, %{status: status, body: body}}), do: {:ok, %{status: status, body: body}} + defp normalize_response({:error, reason}), do: {:error, reason} + + defp finch do + module = Application.get_env(:rocket, :finch, Finch) + + if Code.ensure_loaded?(module) do + {:ok, module} + else + {:error, :finch_not_available} + end + end + + defp pool_name do + Application.get_env(:rocket, :finch_pool, Rocket.Finch) + end +end diff --git a/lib/rocket/push_collector.ex b/lib/rocket/push_collector.ex index 3bdc1b4..73695fa 100644 --- a/lib/rocket/push_collector.ex +++ b/lib/rocket/push_collector.ex @@ -1,36 +1,37 @@ defmodule Rocket.PushCollector do - use GenStage + @moduledoc """ + GenStage producer that queues push requests for pusher workers. + """ - # Client + use GenStage + @spec start_link(term()) :: GenServer.on_start() def start_link(initial \\ []) do GenStage.start_link(__MODULE__, initial, name: __MODULE__) end + @spec push([term()]) :: :ok def push(push_requests) when is_list(push_requests) do GenServer.cast(Rocket.PushCollector, {:push, push_requests}) end + @spec push(term()) :: :ok def push(push_request) do GenServer.cast(Rocket.PushCollector, {:push, [push_request]}) end - # Server - + @impl GenStage def init(_args) do - # Run as producer and specify the max amount - # of push requests to buffer. {:producer, :ok} end + @impl GenStage def handle_cast({:push, push_requests}, state) do - # Dispatch the push_requests as events. - # These will be buffered if there are no consumers ready. {:noreply, push_requests, state} end + @impl GenStage def handle_demand(_demand, state) do - # Do nothing. Events will be dispatched as-is. {:noreply, [], state} end end diff --git a/lib/rocket/pusher.ex b/lib/rocket/pusher.ex index 8636f5b..d055c6e 100644 --- a/lib/rocket/pusher.ex +++ b/lib/rocket/pusher.ex @@ -1,20 +1,38 @@ defmodule Rocket.Pusher do + @moduledoc """ + GenStage consumer that performs queued FCM requests. + """ + use GenStage + require Logger - def start_link do + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(_opts \\ []) do GenStage.start_link(__MODULE__, :state_doesnt_matter) end + @impl GenStage def init(state) do {:consumer, state, subscribe_to: [Rocket.PushCollector]} end + @impl GenStage def handle_events(events, _from, state) do - for event <- events do - Rocket.Request.perform(event) - end + Enum.each(events, &perform_event/1) - # We are a consumer, so we would never emit items. {:noreply, [], state} end + + defp perform_event(event) do + case request_module().perform(event) do + {:error, reason} -> Logger.error("[Rocket] push failed: #{inspect(reason)}") + _result -> :ok + end + rescue + error -> Logger.error("[Rocket] push raised: #{Exception.message(error)}") + catch + kind, reason -> Logger.error("[Rocket] push exited: #{inspect({kind, reason})}") + end + + defp request_module, do: Application.get_env(:rocket, :request_module, Rocket.Request) end diff --git a/lib/rocket/request.ex b/lib/rocket/request.ex index 47aac6d..0eba936 100644 --- a/lib/rocket/request.ex +++ b/lib/rocket/request.ex @@ -1,30 +1,61 @@ defmodule Rocket.Request do @moduledoc ~S" - Perform request to FCM + Performs requests to FCM. " require Logger - alias Rocket.Config + alias Rocket.Response + @spec perform(term()) :: {:ok, map()} | {:error, term()} def perform(payload) do - payload |> post() |> handle_response(payload, response_handler()) + perform(payload, []) end - defp post(payload) do - {:ok, %{header: header, url: url}} = Config.generate() - HTTPoison.post(url, payload |> Jason.encode!(), header) + @spec perform(term(), keyword()) :: {:ok, map()} | {:error, term()} + def perform(payload, opts) do + handler = Keyword.get(opts, :response_handler, response_handler()) + + with :ok <- validate_encodable(payload), + {:ok, response} <- post(payload) do + handle_response(response, payload, handler) + end + end + + defp validate_encodable(payload) do + case Jason.encode(payload) do + {:ok, _encoded} -> :ok + {:error, error} -> {:error, {:encode_error, error}} + end end - defp handle_response({:ok, %HTTPoison.Response{status_code: status, body: body}}, payload, handler) do - case body |> Jason.decode() do - {:ok, decoded_body} -> handler.call(status, payload, decoded_body) - {:error, error} -> Logger.error("[Rocket] JSON decode error #{error}, #{inspect(body)}") + defp post(payload) do + with {:ok, %{headers: headers, url: url}} <- config_provider().generate() do + {:ok, http_client().post(url, headers, Jason.encode!(payload), receive_timeout: 20_000)} end + rescue + error in [Jason.EncodeError, Protocol.UndefinedError] -> {:error, {:encode_error, error}} + end + + defp handle_response({:error, {:encode_error, _error}} = error, _payload, _handler), do: error + + defp handle_response({:ok, %{status: status}} = response, payload, handler) do + parsed = Response.parse(response) + handler.call(status, payload, response_body(parsed)) + parsed end - defp handle_response({:error, %HTTPoison.Error{id: nil, reason: reason}}, _payload, _handler) do - Logger.error("[Rocket] connection error #{reason}") + defp handle_response({:error, reason}, _payload, _handler) do + Logger.error("[Rocket] connection error #{inspect(connection_reason(reason))}") + Response.parse({:error, reason}) end - defp response_handler(), do: Application.get_env(:rocket, :response_handler, Rocket.Response.DefaultHandler) + defp response_body({:ok, body}), do: body + defp response_body({:error, body}), do: body + + defp connection_reason(%{reason: reason}), do: reason + defp connection_reason(reason), do: reason + + defp config_provider, do: Application.get_env(:rocket, :config_provider, Rocket.Config) + defp http_client, do: Application.get_env(:rocket, :http_client, Rocket.HTTPClient.Finch) + defp response_handler, do: Application.get_env(:rocket, :response_handler, Rocket.Response.DefaultHandler) end diff --git a/lib/rocket/response.ex b/lib/rocket/response.ex index c5b8ed3..c2425af 100644 --- a/lib/rocket/response.ex +++ b/lib/rocket/response.ex @@ -1,19 +1,19 @@ defmodule Rocket.Response do @moduledoc ~S" - Responses for Rocket + Parses HTTP responses returned by the FCM API. " - alias HTTPoison.Response - alias HTTPoison.Error - @success_status 200..299 @client_error_status 400..499 @server_error_status 500..599 - def parse({:ok, %Response{status_code: status, body: body}}) when status in @success_status, - do: {:ok, Jason.decode!(body)} + @spec parse({:ok, Rocket.HTTPClient.response()} | {:error, term()}) :: + {:ok, map()} | {:error, term()} + def parse({:ok, %{status: status, body: body}}) when status in @success_status do + decode_body(body) + end - def parse({:ok, %Response{status_code: status, body: body}}) when status in @client_error_status do + def parse({:ok, %{status: status, body: body}}) when status in @client_error_status do body |> Jason.decode() |> case do @@ -22,8 +22,18 @@ defmodule Rocket.Response do end end - def parse({:ok, %Response{status_code: status, body: body}}) when status in @server_error_status, do: body - def parse({_, %Response{status_code: _, body: _} = response}), do: response - def parse({:error, %Error{id: nil, reason: :timeout}}), do: {:error, "Timeout"} - def parse({:error, %Error{id: nil, reason: _reason}}), do: {:error, "Unknown error"} + def parse({:ok, %{status: status, body: body}}) when status in @server_error_status, + do: {:error, body} + + def parse({_, %{status: _status, body: _body} = response}), do: {:error, response} + def parse({:error, %{reason: :timeout}}), do: {:error, :timeout} + def parse({:error, %{reason: reason}}), do: {:error, reason} + def parse({:error, reason}), do: {:error, reason} + + defp decode_body(body) do + case Jason.decode(body) do + {:ok, decoded} -> {:ok, decoded} + {:error, error} -> {:error, {:invalid_json, error, body}} + end + end end diff --git a/lib/rocket/response/default_handler.ex b/lib/rocket/response/default_handler.ex index bce4b23..b018e50 100644 --- a/lib/rocket/response/default_handler.ex +++ b/lib/rocket/response/default_handler.ex @@ -1,17 +1,15 @@ defmodule Rocket.Response.DefaultHandler do + @moduledoc """ + Default response handler that logs FCM response status. + """ + @behaviour Rocket.Response.ResponseHandler require Logger @impl Rocket.Response.ResponseHandler - def call(status, payload), do: do_call(status, payload) + @spec call(integer(), term(), term()) :: :ok + def call(status, payload, body), do: do_call(status, payload, body) - def do_call(200, _payload), do: Logger.info("[Rocket] success") - def do_call(400, _payload), do: Logger.error("[Rocket] error 400") - def do_call(401, _payload), do: Logger.error("[Rocket] error 401") - def do_call(404, _payload), do: Logger.error("[Rocket] error 404") - def do_call(403, _payload), do: Logger.error("[Rocket] error 403") - def do_call(429, _payload), do: Logger.error("[Rocket] error 429") - def do_call(500, _payload), do: Logger.error("[Rocket] error 500") - def do_call(503, _payload), do: Logger.error("[Rocket] error 503") - def do_call(status, _payload), do: Logger.error("[Rocket] error #{status} unkown") + def do_call(status, _payload, _body) when status in 200..299, do: Logger.info("[Rocket] success") + def do_call(status, _payload, body), do: Logger.error("[Rocket] error #{status}: #{inspect(body)}") end diff --git a/lib/rocket/response/response_handler.ex b/lib/rocket/response/response_handler.ex index 50cd21f..3577108 100644 --- a/lib/rocket/response/response_handler.ex +++ b/lib/rocket/response/response_handler.ex @@ -1,6 +1,10 @@ defmodule Rocket.Response.ResponseHandler do + @moduledoc """ + Behaviour for handling parsed FCM responses. + """ + @doc """ - Handles status_code with payload + Handles an HTTP status, the original payload, and a parsed response body. """ - @callback call(status_code :: Integer.t(), payload :: Map.t(), body :: Map.t()) :: any + @callback call(status_code :: integer(), payload :: term(), body :: term()) :: any() end diff --git a/mix.exs b/mix.exs index f1fb608..dd5482f 100644 --- a/mix.exs +++ b/mix.exs @@ -4,52 +4,49 @@ defmodule Rocket.Mixfile do def project do [ app: :rocket, - version: "0.0.1", - elixir: "~> 1.5", + version: "0.1.0", + elixir: "~> 1.18", description: description(), - build_embedded: Mix.env() == :prod, + docs: docs(), start_permanent: Mix.env() == :prod, package: package(), - test_coverage: [tool: ExCoveralls], + test_coverage: [summary: [threshold: 90]], preferred_cli_env: [ - coveralls: :test, - "coveralls.detail": :test, - "coveralls.post": :test, - "coveralls.html": :test, - vcr: :test, - "vcr.delete": :test, - "vcr.check": :test, - "vcr.show": :test + credo: :test, + "test.coverage": :test ], deps: deps() ] end - # Configuration for the OTP application def application do [extra_applications: [:logger], mod: {Rocket.Application, []}] end defp description do """ - A Firebase Cloud Message HTTP v1 client for Elixir + A Firebase Cloud Messaging HTTP v1 client for Elixir. """ end defp deps do [ - {:httpoison, "~> 2.0"}, - {:jason, "~> 1.1"}, - {:mix_test_watch, "~> 1.0", only: :dev, runtime: false}, - {:goth, "~> 1.0"}, {:gen_stage, "~> 1.0"}, - {:exvcr, "~> 0.13", only: :test} + {:finch, "~> 0.16 or ~> 0.22", optional: true}, + {:goth, "~> 1.4"}, + {:jason, "~> 1.4"}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:ex_doc, "~> 0.36", only: :dev, runtime: false}, + {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, + {:mix_test_watch, "~> 1.2", only: :dev, runtime: false}, + {:mox, "~> 1.2", only: :test} ] end defp package do [ - files: ["lib", "mix.exs", "README*", "LICENSE*"], + files: ["lib", "mix.exs", "README*", "CHANGELOG*", "LICENSE*"], maintainers: ["Tom Pesman"], licenses: ["MIT"], links: %{ @@ -58,4 +55,12 @@ defmodule Rocket.Mixfile do } ] end + + defp docs do + [ + main: "readme", + source_url: "https://github.com/9to5/rocket", + extras: ["README.md", "CHANGELOG.md"] + ] + end end diff --git a/mix.lock b/mix.lock index 495036a..88bdd42 100644 --- a/mix.lock +++ b/mix.lock @@ -1,44 +1,30 @@ %{ - "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm", "fab09b20e3f5db886725544cbcf875b8e73ec93363954eb8a1a9ed834aa8c1f9"}, - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, - "certifi": {:hex, :certifi, "2.14.0", "ed3bef654e69cde5e6c022df8070a579a79e8ba2368a00acf3d75b82d9aceeed", [:rebar3], [], "hexpm", "ea59d87ef89da429b8e905264fdec3419f84f2215bb3d81e07a18aac919026c3"}, - "credo": {:hex, :credo, "0.10.2", "03ad3a1eff79a16664ed42fc2975b5e5d0ce243d69318060c626c34720a49512", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, - "earmark": {:hex, :earmark, "1.3.1", "73812f447f7a42358d3ba79283cfa3075a7580a3a2ed457616d6517ac3738cb9", [:mix], [], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.19.3", "3c7b0f02851f5fc13b040e8e925051452e41248f685e40250d7e40b07b9f8c10", [:mix], [{:earmark, "~> 1.2", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, - "exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm", "1222419f706e01bfa1095aec9acf6421367dcfab798a6f67c54cf784733cd6b5"}, - "excoveralls": {:hex, :excoveralls, "0.10.4", "b86230f0978bbc630c139af5066af7cd74fd16536f71bc047d1037091f9f63a9", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, - "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"}, - "exvcr": {:hex, :exvcr, "0.17.0", "ca7f83f08d378d601b3082c60b0550544ec54241317e2bdb6361e2925c023532", [:mix], [{:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.16", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 1.0", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "093561e523d170ad2d48e8afef213b070f75821d51102d582f2a1680891c92b4"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, + "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, - "flow": {:hex, :flow, "0.14.3", "0d92991fe53035894d24aa8dec10dcfccf0ae00c4ed436ace3efa9813a646902", [:mix], [{:gen_stage, "~> 0.14.0", [hex: :gen_stage, repo: "hexpm", optional: false]}], "hexpm"}, "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, "goth": {:hex, :goth, "1.4.5", "ee37f96e3519bdecd603f20e7f10c758287088b6d77c0147cd5ee68cf224aade", [:mix], [{:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "0fc2dce5bd710651ed179053d0300ce3a5d36afbdde11e500d57f05f398d5ed5"}, - "hackney": {:hex, :hackney, "1.23.0", "55cc09077112bcb4a69e54be46ed9bc55537763a96cd4a80a221663a7eafd767", [:rebar3], [{:certifi, "~> 2.14.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "6cd1c04cd15c81e5a493f167b226a15f0938a84fc8f0736ebe4ddcab65c0b44e"}, - "hpack": {:hex, :hpack_erl, "0.2.3", "17670f83ff984ae6cd74b1c456edde906d27ff013740ee4d9efaa4f1bf999633", [:rebar3], [], "hexpm"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "httpoison": {:hex, :httpoison, "2.2.2", "15420e9e5bbb505b931b2f589dc8be0c3b21e2a91a2c6ba882d99bf8f3ad499d", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "de7ac49fe2ffd89219972fdf39b268582f6f7f68d8cd29b4482dacca1ce82324"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "joken": {:hex, :joken, "2.5.0", "09be497d804b8115eb6f07615cef2e60c2a1008fb89dc0aef0d4c4b4609b99aa", [:mix], [{:jose, "~> 1.11.2", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "22b25c89617c5ed8ca7b31026340a25ea0f9ca7160f9706b79be9ed81fdf74e7"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, - "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm", "fc3499fed7a726995aa659143a248534adc754ebd16ccd437cd93b649a95091f"}, - "kadabra": {:hex, :kadabra, "0.4.4", "29d7f4c231d44a59d99b550fa75489a80a572140b3316809f326bf608bce33d8", [:mix], [{:hpack, "~> 0.2.3", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm"}, - "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, - "meck": {:hex, :meck, "1.0.0", "24676cb6ee6951530093a93edcd410cfe4cb59fe89444b875d35c9d3909a15d0", [:rebar3], [], "hexpm", "680a9bcfe52764350beb9fb0335fb75fee8e7329821416cee0a19fec35433882"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, - "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, + "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, + "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, - "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, - "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm"}, - "retry": {:hex, :retry, "0.10.0", "5bdebcc88716aded650cbcd216dbf2cf05989d29335ae94d97752e970a816d86", [:mix], [], "hexpm"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, } diff --git a/test/rocket/config_test.exs b/test/rocket/config_test.exs new file mode 100644 index 0000000..b00479e --- /dev/null +++ b/test/rocket/config_test.exs @@ -0,0 +1,140 @@ +defmodule Rocket.ConfigTest do + use ExUnit.Case, async: false + + alias Rocket.Config + + describe "init/1" do + test "adds credentials JSON from GCP_CREDENTIALS when unset" do + previous = System.get_env("GCP_CREDENTIALS") + System.put_env("GCP_CREDENTIALS", ~s({"project_id":"demo"})) + + on_exit(fn -> + if previous, do: System.put_env("GCP_CREDENTIALS", previous), else: System.delete_env("GCP_CREDENTIALS") + end) + + assert {:ok, [json: ~s({"project_id":"demo"})]} = Config.init([]) + end + + test "does not replace explicit Goth JSON config" do + assert {:ok, [json: "explicit"]} = Config.init(json: "explicit") + end + end + + describe "generate/1" do + test "builds FCM request configuration" do + assert {:ok, config} = + Config.generate( + token_fun: fn -> {:ok, %Goth.Token{token: "access-token"}} end, + project_id_fun: fn -> {:ok, "project-1"} end + ) + + assert config == %{ + headers: [ + {"Content-Type", "application/json"}, + {"Authorization", "Bearer access-token"} + ], + url: "https://fcm.googleapis.com/v1/projects/project-1/messages:send" + } + end + + test "returns token errors" do + assert {:error, :missing_credentials} = + Config.generate( + token_fun: fn -> {:error, :missing_credentials} end, + project_id_fun: fn -> {:ok, "project-1"} end + ) + end + + test "returns project id errors" do + assert {:error, :missing_project_id} = + Config.generate( + token_fun: fn -> {:ok, %Goth.Token{token: "access-token"}} end, + project_id_fun: fn -> {:error, :missing_project_id} end + ) + end + end + + test "header/1 returns JSON and bearer headers" do + assert Config.header("token") == [ + {"Content-Type", "application/json"}, + {"Authorization", "Bearer token"} + ] + end + + test "get_url/1 builds the FCM HTTP v1 endpoint" do + assert Config.get_url("rocket-project") == + "https://fcm.googleapis.com/v1/projects/rocket-project/messages:send" + end + + describe "get_project_id/0" do + setup do + previous = Application.get_env(:goth, :json) + previous_env = System.get_env("GCP_CREDENTIALS") + + on_exit(fn -> + if previous, do: Application.put_env(:goth, :json, previous), else: Application.delete_env(:goth, :json) + if previous_env, do: System.put_env("GCP_CREDENTIALS", previous_env), else: System.delete_env("GCP_CREDENTIALS") + end) + + Application.delete_env(:goth, :json) + System.delete_env("GCP_CREDENTIALS") + + :ok + end + + test "reads project id from configured credentials JSON" do + Application.put_env(:goth, :json, ~s({"project_id":"configured-project"})) + + assert Config.get_project_id() == {:ok, "configured-project"} + end + + test "returns an error for credentials without project id" do + Application.put_env(:goth, :json, ~s({"client_email":"service@example.com"})) + + assert Config.get_project_id() == {:error, :missing_project_id} + end + + test "returns an error for invalid credentials JSON" do + Application.put_env(:goth, :json, "not json") + + assert {:error, {:invalid_credentials_json, %Jason.DecodeError{}}} = Config.get_project_id() + end + + test "reads project id from GCP_CREDENTIALS when application config is absent" do + System.put_env("GCP_CREDENTIALS", ~s({"project_id":"env-project"})) + + assert Config.get_project_id() == {:ok, "env-project"} + end + + test "returns an error when credentials are missing" do + assert Config.get_project_id() == {:error, :missing_credentials} + end + end + + describe "token/0" do + setup do + previous = Application.get_env(:goth, :json) + previous_env = System.get_env("GCP_CREDENTIALS") + + on_exit(fn -> + if previous, do: Application.put_env(:goth, :json, previous), else: Application.delete_env(:goth, :json) + if previous_env, do: System.put_env("GCP_CREDENTIALS", previous_env), else: System.delete_env("GCP_CREDENTIALS") + end) + + Application.delete_env(:goth, :json) + System.delete_env("GCP_CREDENTIALS") + + :ok + end + + test "returns an error when credentials are missing" do + assert Config.token() == {:error, :missing_credentials} + end + + test "returns an error for invalid credential JSON" do + Application.put_env(:goth, :json, "not json") + + assert {:error, {:invalid_credentials_json, %Jason.DecodeError{}}} = Config.token() + end + end +end diff --git a/test/rocket/default_handler_test.exs b/test/rocket/default_handler_test.exs new file mode 100644 index 0000000..9f04c7e --- /dev/null +++ b/test/rocket/default_handler_test.exs @@ -0,0 +1,19 @@ +defmodule Rocket.Response.DefaultHandlerTest do + use ExUnit.Case, async: true + + import ExUnit.CaptureLog + + alias Rocket.Response.DefaultHandler + + test "logs success for 2xx statuses" do + log = capture_log(fn -> DefaultHandler.call(204, %{}, %{}) end) + + assert log =~ "[Rocket] success" + end + + test "logs errors with status and response body" do + log = capture_log(fn -> DefaultHandler.call(429, %{}, %{"error" => "quota"}) end) + + assert log =~ ~s([Rocket] error 429: %{"error" => "quota"}) + end +end diff --git a/test/rocket/http_client/finch_test.exs b/test/rocket/http_client/finch_test.exs new file mode 100644 index 0000000..6962f5a --- /dev/null +++ b/test/rocket/http_client/finch_test.exs @@ -0,0 +1,46 @@ +defmodule Rocket.HTTPClient.FinchTest do + use ExUnit.Case, async: false + + alias Rocket.HTTPClient.Finch, as: FinchClient + + setup do + Application.put_env(:rocket, :finch, Rocket.FinchTestClient) + Application.put_env(:rocket, :finch_pool, Rocket.Finch) + Application.put_env(:rocket, :finch_test_pid, self()) + + on_exit(fn -> + Application.delete_env(:rocket, :finch) + Application.delete_env(:rocket, :finch_pool) + Application.delete_env(:rocket, :finch_response) + Application.delete_env(:rocket, :finch_test_pid) + end) + + :ok + end + + test "builds and sends Finch requests" do + Application.put_env(:rocket, :finch_response, fn -> + {:ok, %Finch.Response{status: 200, body: ~s({"name":"messages/1"})}} + end) + + assert FinchClient.post("https://example.test/send", [{"Authorization", "Bearer token"}], "{}", + receive_timeout: 20_000 + ) == + {:ok, %{status: 200, body: ~s({"name":"messages/1"})}} + + assert_receive {:finch_build, :post, "https://example.test/send", [{"Authorization", "Bearer token"}], "{}"} + assert_receive {:finch_request, %Finch.Request{}, Rocket.Finch, [receive_timeout: 20_000]} + end + + test "returns Finch transport errors" do + Application.put_env(:rocket, :finch_response, fn -> {:error, %{reason: :timeout}} end) + + assert FinchClient.post("https://example.test/send", [], "{}", []) == {:error, %{reason: :timeout}} + end + + test "returns a clear error when Finch is unavailable" do + Application.put_env(:rocket, :finch, MissingFinch) + + assert FinchClient.post("https://example.test/send", [], "{}", []) == {:error, :finch_not_available} + end +end diff --git a/test/rocket/pipeline_test.exs b/test/rocket/pipeline_test.exs new file mode 100644 index 0000000..03a16c8 --- /dev/null +++ b/test/rocket/pipeline_test.exs @@ -0,0 +1,162 @@ +defmodule Rocket.PipelineTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureLog + + defmodule TestConsumer do + use GenStage + + def start_link(test_pid) do + GenStage.start_link(__MODULE__, test_pid) + end + + @impl GenStage + def init(test_pid) do + {:consumer, test_pid, subscribe_to: [Rocket.PushCollector]} + end + + @impl GenStage + def handle_events(events, _from, test_pid) do + send(test_pid, {:events, events}) + {:noreply, [], test_pid} + end + end + + defmodule TestRequest do + def perform(payload) do + Application.fetch_env!(:rocket, :request_test_pid) + |> send({:performed, payload}) + + case payload do + :error -> {:error, :failed} + :raise -> raise "request failed" + _payload -> :ok + end + end + end + + setup do + Application.put_env(:rocket, :request_module, TestRequest) + Application.put_env(:rocket, :request_test_pid, self()) + + on_exit(fn -> + Application.delete_env(:rocket, :request_test_pid) + Application.put_env(:rocket, :request_module, Rocket.Request) + end) + + :ok + end + + test "push collector dispatches list and single push requests" do + start_supervised!(Rocket.PushCollector) + start_supervised!({TestConsumer, self()}) + + assert :ok = Rocket.PushCollector.push([:first, :second]) + assert_receive {:events, [:first, :second]} + + assert :ok = Rocket.PushCollector.push(:third) + assert_receive {:events, [:third]} + end + + test "pusher consumes events and performs requests" do + start_supervised!(Rocket.PushCollector) + start_supervised!(Rocket.Pusher) + + assert :ok = Rocket.PushCollector.push(%{message: %{token: "device-token"}}) + assert_receive {:performed, %{message: %{token: "device-token"}}} + end + + test "application starts the configured collector and pusher workers" do + previous_workers = Application.get_env(:rocket, :workers) + Application.put_env(:rocket, :workers, 3) + + {:ok, supervisor} = Rocket.Application.start(:normal, []) + Process.unlink(supervisor) + + on_exit(fn -> + if Process.alive?(supervisor), do: Supervisor.stop(supervisor) + + if previous_workers do + Application.put_env(:rocket, :workers, previous_workers) + else + Application.delete_env(:rocket, :workers) + end + end) + + assert %{active: 4, specs: 4, workers: 4} = Supervisor.count_children(supervisor) + end + + test "application starts Finch when the default Finch client is configured" do + previous_client = Application.get_env(:rocket, :http_client) + previous_workers = Application.get_env(:rocket, :workers) + + Application.put_env(:rocket, :http_client, Rocket.HTTPClient.Finch) + Application.put_env(:rocket, :workers, 1) + + {:ok, supervisor} = Rocket.Application.start(:normal, []) + Process.unlink(supervisor) + + on_exit(fn -> + if Process.alive?(supervisor), do: Supervisor.stop(supervisor) + + if previous_client do + Application.put_env(:rocket, :http_client, previous_client) + else + Application.delete_env(:rocket, :http_client) + end + + if previous_workers do + Application.put_env(:rocket, :workers, previous_workers) + else + Application.delete_env(:rocket, :workers) + end + end) + + assert %{active: 3, specs: 3, workers: 3} = Supervisor.count_children(supervisor) + end + + test "application supports zero configured pusher workers" do + previous_workers = Application.get_env(:rocket, :workers) + Application.put_env(:rocket, :workers, 0) + + {:ok, supervisor} = Rocket.Application.start(:normal, []) + Process.unlink(supervisor) + + on_exit(fn -> + if Process.alive?(supervisor), do: Supervisor.stop(supervisor) + + if previous_workers do + Application.put_env(:rocket, :workers, previous_workers) + else + Application.delete_env(:rocket, :workers) + end + end) + + assert %{active: 1, specs: 1, workers: 1} = Supervisor.count_children(supervisor) + end + + test "pusher callback keeps consumer state" do + Application.put_env(:rocket, :request_test_pid, self()) + + assert {:noreply, [], :state} = Rocket.Pusher.handle_events([:one, :two], self(), :state) + assert_receive {:performed, :one} + assert_receive {:performed, :two} + end + + test "pusher isolates failed events and continues processing" do + Application.put_env(:rocket, :request_test_pid, self()) + + capture_log(fn -> + assert {:noreply, [], :state} = Rocket.Pusher.handle_events([:error, :raise, :ok], self(), :state) + end) + + assert_receive {:performed, :error} + assert_receive {:performed, :raise} + assert_receive {:performed, :ok} + end + + test "push collector callbacks keep producer state when there is no demand" do + assert {:producer, :ok} = Rocket.PushCollector.init([]) + assert {:noreply, [], :state} = Rocket.PushCollector.handle_demand(10, :state) + end +end diff --git a/test/rocket/request_test.exs b/test/rocket/request_test.exs new file mode 100644 index 0000000..d940c35 --- /dev/null +++ b/test/rocket/request_test.exs @@ -0,0 +1,102 @@ +defmodule Rocket.RequestTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureLog + import Mox + + alias Rocket.Request + + setup :verify_on_exit! + + setup do + Application.put_env(:rocket, :config_provider, Rocket.ConfigProviderMock) + Application.put_env(:rocket, :http_client, Rocket.HTTPClientMock) + Application.put_env(:rocket, :response_handler, Rocket.ResponseHandlerMock) + + :ok + end + + test "posts encoded payloads and returns parsed success bodies" do + payload = %{"message" => %{"token" => "device-token"}} + + expect(Rocket.ConfigProviderMock, :generate, fn -> + {:ok, %{headers: [{"Authorization", "Bearer token"}], url: "https://example.test/send"}} + end) + + expect(Rocket.HTTPClientMock, :post, fn url, headers, body, opts -> + assert url == "https://example.test/send" + assert headers == [{"Authorization", "Bearer token"}] + assert Jason.decode!(body) == payload + assert opts == [receive_timeout: 20_000] + + {:ok, %{status: 200, body: ~s({"name":"messages/1"})}} + end) + + expect(Rocket.ResponseHandlerMock, :call, fn 200, ^payload, %{"name" => "messages/1"} -> :ok end) + + assert Request.perform(payload) == {:ok, %{"name" => "messages/1"}} + end + + test "returns parsed client errors and calls the handler" do + payload = %{"message" => %{"token" => "bad-token"}} + + expect(Rocket.ConfigProviderMock, :generate, fn -> + {:ok, %{headers: [], url: "https://example.test/send"}} + end) + + expect(Rocket.HTTPClientMock, :post, fn _url, _headers, _body, _opts -> + {:ok, %{status: 400, body: ~s({"error":"invalid"})}} + end) + + expect(Rocket.ResponseHandlerMock, :call, fn 400, ^payload, %{"error" => "invalid"} -> :ok end) + + assert Request.perform(payload) == {:error, %{"error" => "invalid"}} + end + + test "returns invalid JSON errors from HTTP responses" do + payload = %{"message" => %{"token" => "device-token"}} + + expect(Rocket.ConfigProviderMock, :generate, fn -> + {:ok, %{headers: [], url: "https://example.test/send"}} + end) + + expect(Rocket.HTTPClientMock, :post, fn _url, _headers, _body, _opts -> + {:ok, %{status: 200, body: "not json"}} + end) + + expect(Rocket.ResponseHandlerMock, :call, fn 200, ^payload, {:invalid_json, %Jason.DecodeError{}, "not json"} -> + :ok + end) + + assert {:error, {:invalid_json, %Jason.DecodeError{}, "not json"}} = Request.perform(payload) + end + + test "returns configuration errors without posting" do + expect(Rocket.ConfigProviderMock, :generate, fn -> {:error, :missing_credentials} end) + + assert Request.perform(%{"message" => %{}}) == {:error, :missing_credentials} + end + + test "returns encode errors without configuration lookup" do + assert {:error, {:encode_error, %Protocol.UndefinedError{}}} = Request.perform(self()) + end + + test "returns Finch transport errors and logs the connection issue" do + payload = %{"message" => %{}} + + expect(Rocket.ConfigProviderMock, :generate, fn -> + {:ok, %{headers: [], url: "https://example.test/send"}} + end) + + expect(Rocket.HTTPClientMock, :post, fn _url, _headers, _body, _opts -> + {:error, %{reason: :timeout}} + end) + + log = + capture_log(fn -> + assert Request.perform(payload) == {:error, :timeout} + end) + + assert log =~ "[Rocket] connection error :timeout" + end +end diff --git a/test/rocket/response_test.exs b/test/rocket/response_test.exs new file mode 100644 index 0000000..19f08fa --- /dev/null +++ b/test/rocket/response_test.exs @@ -0,0 +1,53 @@ +defmodule Rocket.ResponseTest do + use ExUnit.Case, async: true + + alias Rocket.Response, as: Parser + + test "parses successful JSON responses" do + response = %{status: 200, body: ~s({"name":"messages/1"})} + + assert Parser.parse({:ok, response}) == {:ok, %{"name" => "messages/1"}} + end + + test "returns invalid JSON errors for malformed success bodies" do + response = %{status: 201, body: "not json"} + + assert {:error, {:invalid_json, %Jason.DecodeError{}, "not json"}} = Parser.parse({:ok, response}) + end + + test "parses client error JSON responses" do + response = %{status: 400, body: ~s({"error":"bad request"})} + + assert Parser.parse({:ok, response}) == {:error, %{"error" => "bad request"}} + end + + test "returns raw client error bodies when JSON decoding fails" do + response = %{status: 404, body: "not found"} + + assert Parser.parse({:ok, response}) == {:error, "not found"} + end + + test "returns server error bodies as structured errors" do + response = %{status: 503, body: "unavailable"} + + assert Parser.parse({:ok, response}) == {:error, "unavailable"} + end + + test "returns unsupported HTTP responses as errors" do + response = %{status: 302, body: "redirect"} + + assert Parser.parse({:ok, response}) == {:error, response} + end + + test "normalizes timeout errors" do + assert Parser.parse({:error, %{reason: :timeout}}) == {:error, :timeout} + end + + test "returns Finch transport error reasons" do + assert Parser.parse({:error, %{reason: :nxdomain}}) == {:error, :nxdomain} + end + + test "returns raw Finch error terms" do + assert Parser.parse({:error, :closed}) == {:error, :closed} + end +end diff --git a/test/rocket_test.exs b/test/rocket_test.exs new file mode 100644 index 0000000..9a169fe --- /dev/null +++ b/test/rocket_test.exs @@ -0,0 +1,31 @@ +defmodule RocketTest do + use ExUnit.Case, async: false + + import Mox + + setup :verify_on_exit! + + setup do + Application.put_env(:rocket, :config_provider, Rocket.ConfigProviderMock) + Application.put_env(:rocket, :http_client, Rocket.HTTPClientMock) + Application.put_env(:rocket, :response_handler, Rocket.ResponseHandlerMock) + + :ok + end + + test "push/1 performs a synchronous request" do + payload = %{"message" => %{"token" => "device-token"}} + + expect(Rocket.ConfigProviderMock, :generate, fn -> + {:ok, %{headers: [], url: "https://example.test/send"}} + end) + + expect(Rocket.HTTPClientMock, :post, fn _url, _headers, _body, _opts -> + {:ok, %{status: 200, body: ~s({"name":"messages/1"})}} + end) + + expect(Rocket.ResponseHandlerMock, :call, fn 200, ^payload, %{"name" => "messages/1"} -> :ok end) + + assert Rocket.push(payload) == {:ok, %{"name" => "messages/1"}} + end +end diff --git a/test/support/finch_test_client.ex b/test/support/finch_test_client.ex new file mode 100644 index 0000000..d312553 --- /dev/null +++ b/test/support/finch_test_client.ex @@ -0,0 +1,17 @@ +defmodule Rocket.FinchTestClient do + @moduledoc false + + def build(method, url, headers, body) do + send(test_pid(), {:finch_build, method, url, headers, body}) + Finch.build(method, url, headers, body) + end + + def request(request, name, opts) do + send(test_pid(), {:finch_request, request, name, opts}) + Application.fetch_env!(:rocket, :finch_response).() + end + + defp test_pid do + Application.fetch_env!(:rocket, :finch_test_pid) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..71b558d --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,13 @@ +ExUnit.start() + +Code.require_file("support/finch_test_client.ex", __DIR__) + +Mox.defmock(Rocket.ConfigProviderMock, for: Rocket.ConfigProvider) +Mox.defmock(Rocket.HTTPClientMock, for: Rocket.HTTPClient) +Mox.defmock(Rocket.ResponseHandlerMock, for: Rocket.Response.ResponseHandler) + +Application.put_env(:rocket, :config_provider, Rocket.ConfigProviderMock) +Application.put_env(:rocket, :http_client, Rocket.HTTPClientMock) +Application.put_env(:rocket, :response_handler, Rocket.ResponseHandlerMock) + +Application.stop(:rocket)