diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..c83945f --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,8 @@ +[ + import_deps: [:phoenix], + inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"], + locals_without_parens: [ + field: :*, + belongs_to: :* + ] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8847ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +stripe_mock-*.tar + +# Since we are building assets from assets/, +# we ignore priv/static. You may want to comment +# this depending on your deployment strategy. +/priv/static/ + +# Files matching config/*.secret.exs pattern contain sensitive +# data and you should not commit them into version control. +# +# Alternatively, you may comment the line below and commit the +# secrets files as long as you replace their contents by environment +# variables. +/config/*.secret.exs diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f217df0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Nick Kezhaya + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..683d42e --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# StripeMock + +Starts a server that pretends to be Stripe. Docs here: https://hexdocs.pm/stripe_mock/StripeMock.html diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..0a8e523 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,28 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Mix.Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +use Mix.Config + +# Configures the endpoint +config :stripe_mock, StripeMockWeb.Endpoint, + url: [host: "localhost"], + http: [:inet6, port: System.get_env("PORT") || 12111], + secret_key_base: "ug9ATr9o7f/N2inxnW+SlrNVP7Ok+f9gAP43yHfqqm/bgFZSLeY6vQOY+wp562Iz", + render_errors: [view: StripeMockWeb.ErrorView, accepts: ~w(json)], + pubsub: [name: StripeMock.PubSub, adapter: Phoenix.PubSub.PG2] + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{Mix.env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..55f2e1c --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,47 @@ +use Mix.Config + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we use it +# with webpack to recompile .js and .css sources. +config :stripe_mock, StripeMockWeb.Endpoint, + debug_errors: true, + code_reloader: true, + check_origin: false, + watchers: [] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Note that this task requires Erlang/OTP 20 or later. +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..d03ae72 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,7 @@ +use Mix.Config + +config :stripe_mock, StripeMockWeb.Endpoint, + server: false + +# Print only warnings and errors during test +config :logger, level: :warn diff --git a/lib/stripe_mock.ex b/lib/stripe_mock.ex new file mode 100644 index 0000000..0ab2671 --- /dev/null +++ b/lib/stripe_mock.ex @@ -0,0 +1,27 @@ +defmodule StripeMock do + @moduledoc """ + StripeMock is a service that duplicates some of the core functionality of the + Stripe API, without doing enough to receive a cease-and-desist letter from + Stripe's legal team. + + This has been created for testing purposes only. MY GOD will it speed up your + test suite. + + First, add the dependency to your `mix.exs`: + + {:stripe_mock, "~> 0.1.0"} + + (or whatever the latest version is; I probably won't be updating this moduledoc.) + + In your app's `config/test.exs` file: + + config :stripity_stripe, :api_base_url, "http://localhost:12111/v1/" + config :stripe_mock, StripeMockWeb.Endpoint, http: [port: 12111], server: true + + That should get the StripeMock server to start in your test environment. + + No database connection is needed. `StripeMock.Repo` is just a GenServer that + stores everything in its state. It'd be nice if `ecto_mnesia` was updated for + Ecto 3, but as of right now this is the next best option. + """ +end diff --git a/lib/stripe_mock/api.ex b/lib/stripe_mock/api.ex new file mode 100644 index 0000000..27f4af2 --- /dev/null +++ b/lib/stripe_mock/api.ex @@ -0,0 +1,39 @@ +defmodule StripeMock.API do + alias StripeMock.API.Operations, as: Ops + + # Customers + defdelegate list_customers(), to: Ops.Customer + defdelegate get_customer(id), to: Ops.Customer + defdelegate get_customer!(id), to: Ops.Customer + defdelegate create_customer(attrs \\ %{}), to: Ops.Customer + defdelegate update_customer(customer, attrs \\ %{}), to: Ops.Customer + defdelegate delete_customer(customer), to: Ops.Customer + + # Tokens + defdelegate get_token(id), to: Ops.Token + defdelegate create_token(attrs \\ %{}), to: Ops.Token + + # Cards + defdelegate list_cards(customer), to: Ops.Card + defdelegate get_card!(id), to: Ops.Card + defdelegate create_card(customer, attrs \\ %{}), to: Ops.Card + defdelegate create_customer_card_from_source(customer, source, metadata \\ %{}), to: Ops.Card + defdelegate update_card(card, attrs \\ %{}), to: Ops.Card + defdelegate delete_card(card), to: Ops.Card + + # Sources - TODO + # defdelegate attach_source(source, customer), to: Ops.Source + # defdelegate detach_source(source, customer), to: Ops.Source + + # Charges + defdelegate list_charges(), to: Ops.Charge + defdelegate get_charge(id), to: Ops.Charge + defdelegate create_charge(attrs \\ %{}), to: Ops.Charge + defdelegate update_charge(charge, attrs \\ %{}), to: Ops.Charge + + # Refunds + defdelegate list_refunds(), to: Ops.Refund + defdelegate get_refund(id), to: Ops.Refund + defdelegate create_refund(attrs \\ %{}), to: Ops.Refund + defdelegate update_refund(refund, attrs \\ %{}), to: Ops.Refund +end diff --git a/lib/stripe_mock/api/card.ex b/lib/stripe_mock/api/card.ex new file mode 100644 index 0000000..2be5fca --- /dev/null +++ b/lib/stripe_mock/api/card.ex @@ -0,0 +1,79 @@ +defmodule StripeMock.API.Card do + use Ecto.Schema + import Ecto.Changeset + alias StripeMock.API + + schema "cards" do + field :brand, :string + field :created, :integer + field :deleted, :boolean + field :metadata, StripeMock.Metadata, default: %{} + field :last4, :string + field :source, :string + + field :number, :string + field :exp_month, :integer + field :exp_year, :integer + field :cvc, :string + + belongs_to :customer, API.Customer + end + + @doc false + def create_changeset(card, attrs) do + card + |> cast(attrs, [:source, :metadata]) + |> validate_required([:source, :metadata]) + |> set_brand() + |> set_last4() + end + + @doc false + def update_changeset(card, attrs) do + card + |> cast(attrs, []) + |> validate_required([]) + end + + @doc false + def token_changeset(card, attrs) do + card + |> cast(attrs, [:number, :exp_month, :exp_year, :cvc]) + |> validate_required([:number, :exp_month, :exp_year, :cvc]) + |> set_brand() + |> set_last4() + end + + defp set_brand(changeset) do + brand = + case get_field(changeset, :number) do + "4242424242424242" -> "Visa" + "4000056655665556" -> "Visa" + "5555555555554444" -> "Mastercard" + "2223003122003222" -> "Mastercard" + "5200828282828210" -> "Mastercard" + "5105105105105100" -> "Mastercard" + "378282246310005" -> "American Express" + "371449635398431" -> "American Express" + "6011111111111117" -> "Discover" + "6011000990139424" -> "Discover" + "30569309025904" -> "Diners Club" + "38520000023237" -> "Diners Club" + "3566002020360505" -> "JCB" + "6200000000000005" -> "UnionPay" + end + + put_change(changeset, :brand, brand) + end + + defp set_last4(changeset) do + case get_field(changeset, :number) do + number when is_bitstring(number) -> + last4 = String.split_at(number, -6) |> elem(1) + put_change(changeset, :last4, last4) + + _ -> + changeset + end + end +end diff --git a/lib/stripe_mock/api/charge.ex b/lib/stripe_mock/api/charge.ex new file mode 100644 index 0000000..dbe2c42 --- /dev/null +++ b/lib/stripe_mock/api/charge.ex @@ -0,0 +1,47 @@ +defmodule StripeMock.API.Charge do + use Ecto.Schema + import Ecto.Changeset + + alias StripeMock.API + + @foreign_key_type :binary_id + schema "charges" do + field :amount, :integer + field :capture, :boolean, default: false + field :currency, :string + field :description, :string + field :metadata, StripeMock.Metadata, default: %{} + field :statement_descriptor, :string + field :transfer_group, :string + + belongs_to :customer, API.Customer + end + + @doc false + def create_changeset(charge, attrs) do + charge + |> cast(attrs, [ + :amount, + :currency, + :capture, + :customer_id, + :description, + :metadata, + :statement_descriptor, + :transfer_group + ]) + |> validate_required([:amount, :currency]) + end + + @doc false + def update_changeset(charge, attrs) do + charge + |> cast(attrs, [ + :customer_id, + :description, + :metadata, + :transfer_group + ]) + |> validate_required([:amount, :currency]) + end +end diff --git a/lib/stripe_mock/api/customer.ex b/lib/stripe_mock/api/customer.ex new file mode 100644 index 0000000..a8b3eb3 --- /dev/null +++ b/lib/stripe_mock/api/customer.ex @@ -0,0 +1,30 @@ +defmodule StripeMock.API.Customer do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: false} + schema "customers" do + field :created, :integer + field :currency, :string, default: "usd" + field :deleted, :boolean, default: false + field :description, :string + field :email, :string + field :metadata, StripeMock.Metadata, default: %{} + field :name, :string + field :phone, :string + end + + @doc false + def changeset(customer, attrs) do + customer + |> cast(attrs, [:created, :currency, :description, :email, :name, :phone]) + |> validate_email() + end + + defp validate_email(changeset) do + case get_field(changeset, :email) do + nil -> changeset + _ -> validate_format(changeset, :email, ~r/.+@.+\..+/i) + end + end +end diff --git a/lib/stripe_mock/api/operations/card.ex b/lib/stripe_mock/api/operations/card.ex new file mode 100644 index 0000000..ff47a2b --- /dev/null +++ b/lib/stripe_mock/api/operations/card.ex @@ -0,0 +1,41 @@ +defmodule StripeMock.API.Operations.Card do + alias StripeMock.Repo + alias StripeMock.API.Card + + def list_cards(customer) do + Card + |> Repo.all() + |> Enum.filter(&(&1.customer_id == customer.id)) + end + + def get_card!(id), do: Repo.get!(Card, id) + + def create_card(customer, attrs) do + %Card{customer_id: customer.id} + |> Card.create_changeset(attrs) + |> Repo.insert() + end + + def create_customer_card_from_source(customer, source, metadata) do + result = + source.card + |> Ecto.Changeset.change(%{customer_id: customer.id, metadata: metadata}) + |> Repo.update() + + source + |> Ecto.Changeset.change(%{used: true}) + |> Repo.update() + + result + end + + def update_card(%Card{} = card, attrs) do + card + |> Card.update_changeset(attrs) + |> Repo.update() + end + + def delete_card(%Card{} = card) do + Repo.delete(card) + end +end diff --git a/lib/stripe_mock/api/operations/charge.ex b/lib/stripe_mock/api/operations/charge.ex new file mode 100644 index 0000000..bb8a44b --- /dev/null +++ b/lib/stripe_mock/api/operations/charge.ex @@ -0,0 +1,24 @@ +defmodule StripeMock.API.Operations.Charge do + alias StripeMock.Repo + alias StripeMock.API.Charge + + def list_charges do + Repo.all(Charge) + end + + def get_charge(id) do + Repo.fetch(Charge, id) + end + + def create_charge(attrs \\ %{}) do + %Charge{} + |> Charge.create_changeset(attrs) + |> Repo.insert() + end + + def update_charge(%Charge{} = charge, attrs) do + charge + |> Charge.create_changeset(attrs) + |> Repo.update() + end +end diff --git a/lib/stripe_mock/api/operations/customer.ex b/lib/stripe_mock/api/operations/customer.ex new file mode 100644 index 0000000..3368c42 --- /dev/null +++ b/lib/stripe_mock/api/operations/customer.ex @@ -0,0 +1,33 @@ +defmodule StripeMock.API.Operations.Customer do + alias StripeMock.Repo + alias StripeMock.API.Customer + + def list_customers() do + Repo.all(Customer) + end + + def get_customer(id) do + case Repo.get(Customer, id) do + nil -> {:error, :not_found} + customer -> {:ok, customer} + end + end + + def get_customer!(id), do: Repo.get!(Customer, id) + + def create_customer(attrs \\ %{}) do + %Customer{} + |> Customer.changeset(attrs) + |> Repo.insert() + end + + def update_customer(%Customer{} = customer, attrs) do + customer + |> Customer.changeset(attrs) + |> Repo.update() + end + + def delete_customer(%Customer{} = customer) do + Repo.delete(customer) + end +end diff --git a/lib/stripe_mock/api/operations/refund.ex b/lib/stripe_mock/api/operations/refund.ex new file mode 100644 index 0000000..84e8491 --- /dev/null +++ b/lib/stripe_mock/api/operations/refund.ex @@ -0,0 +1,24 @@ +defmodule StripeMock.API.Operations.Refund do + alias StripeMock.Repo + alias StripeMock.API.Refund + + def list_refunds do + Repo.all(Refund) + end + + def get_refund(id) do + Repo.fetch(Refund, id) + end + + def create_refund(attrs \\ %{}) do + %Refund{} + |> Refund.create_changeset(attrs) + |> Repo.insert() + end + + def update_refund(%Refund{} = refund, attrs) do + refund + |> Refund.update_changeset(attrs) + |> Repo.update() + end +end diff --git a/lib/stripe_mock/api/operations/token.ex b/lib/stripe_mock/api/operations/token.ex new file mode 100644 index 0000000..52ea324 --- /dev/null +++ b/lib/stripe_mock/api/operations/token.ex @@ -0,0 +1,14 @@ +defmodule StripeMock.API.Operations.Token do + alias StripeMock.Repo + alias StripeMock.API.Token + + def get_token(id) do + Repo.fetch(Token, id) + end + + def create_token(attrs) do + %Token{} + |> Token.changeset(attrs) + |> Repo.insert() + end +end diff --git a/lib/stripe_mock/api/refund.ex b/lib/stripe_mock/api/refund.ex new file mode 100644 index 0000000..9dd8801 --- /dev/null +++ b/lib/stripe_mock/api/refund.ex @@ -0,0 +1,78 @@ +defmodule StripeMock.API.Refund do + use Ecto.Schema + import Ecto.Changeset + + alias StripeMock.{API, Repo} + + @foreign_key_type :binary_id + schema "refunds" do + field :amount, :integer + field :metadata, StripeMock.Metadata, default: %{} + field :reason, :string + + belongs_to :charge, API.Charge + end + + @doc false + def create_changeset(refund, attrs) do + refund + |> cast(attrs, [:charge_id, :amount, :metadata, :reason]) + |> validate_required([:charge_id]) + |> set_default_amount() + |> validate_required(:amount) + |> validate_amount() + |> validate_reason() + end + + @doc false + def update_changeset(refund, attrs) do + refund + |> cast(attrs, [:metadata]) + end + + defp set_default_amount(changeset) do + with nil <- get_field(changeset, :amount), + {:ok, charge} <- fetch_charge(changeset) do + put_change(changeset, :amount, charge.amount) + else + _ -> changeset + end + end + + defp validate_amount(changeset) do + with {:ok, charge} <- fetch_charge(changeset), + amount when is_integer(amount) <- get_field(changeset, :amount) do + total_refunded = + __MODULE__ + |> Repo.all() + |> Enum.filter(&(&1.charge_id == charge.id)) + |> Enum.map(& &1.amount) + |> Enum.sum() + + if total_refunded + amount > charge.amount do + add_error(changeset, :amount, "is too high") + else + changeset + end + else + _ -> changeset + end + end + + defp validate_reason(changeset) do + case get_field(changeset, :reason) do + nil -> changeset + reason when reason in ~w(duplicate fraudulent requested_by_customer) -> changeset + _ -> add_error(changeset, :reason, "is invalid") + end + end + + defp fetch_charge(changeset) do + with charge_id when is_bitstring(charge_id) <- get_field(changeset, :charge_id), + {:ok, _} = result <- Repo.fetch(API.Charge, charge_id) do + result + else + _ -> nil + end + end +end diff --git a/lib/stripe_mock/api/token.ex b/lib/stripe_mock/api/token.ex new file mode 100644 index 0000000..3cae8e1 --- /dev/null +++ b/lib/stripe_mock/api/token.ex @@ -0,0 +1,31 @@ +defmodule StripeMock.API.Token do + use Ecto.Schema + import Ecto.Changeset + alias StripeMock.API + + @foreign_key_type :binary_id + schema "tokens" do + field :client_ip, :string + field :created, :integer + field :type, :string + field :used, :boolean, default: false + + belongs_to :card, API.Card + end + + @doc false + def changeset(token, attrs) do + token + |> cast(attrs, [:client_ip]) + |> cast_assoc(:card, with: &API.Card.token_changeset/2) + |> set_type() + |> validate_required([:client_ip, :type]) + end + + defp set_type(changeset) do + case get_field(changeset, :card) do + nil -> changeset + _ -> put_change(changeset, :type, "card") + end + end +end diff --git a/lib/stripe_mock/application.ex b/lib/stripe_mock/application.ex new file mode 100644 index 0000000..b6a4e3f --- /dev/null +++ b/lib/stripe_mock/application.ex @@ -0,0 +1,20 @@ +defmodule StripeMock.Application do + @moduledoc false + + use Application + + def start(_type, _args) do + children = [ + StripeMockWeb.Endpoint, + StripeMock.Repo + ] + + opts = [strategy: :one_for_one, name: StripeMock.Supervisor] + Supervisor.start_link(children, opts) + end + + def config_change(changed, _new, removed) do + StripeMockWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/lib/stripe_mock/base52.ex b/lib/stripe_mock/base52.ex new file mode 100644 index 0000000..665f565 --- /dev/null +++ b/lib/stripe_mock/base52.ex @@ -0,0 +1,25 @@ +defmodule StripeMock.Base52 do + @chars '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' + @base52_alphabet @chars -- 'AEIOUaeiou' + + @doc """ + Encodes an integer into a base52 string. + + iex> encode(0) + "0" + iex> encode(1000000000) + "2clyTD" + """ + def encode(0), do: "0" + + def encode(integer) when is_integer(integer) do + do_encode(integer, '') |> to_string() + end + + defp do_encode(integer, encoded) when integer > 0 do + encoded = [Enum.at(@base52_alphabet, rem(integer, 52)) | encoded] + do_encode(Integer.floor_div(integer, 52), encoded) + end + + defp do_encode(0, encoded), do: encoded +end diff --git a/lib/stripe_mock/error.ex b/lib/stripe_mock/error.ex new file mode 100644 index 0000000..f55c75a --- /dev/null +++ b/lib/stripe_mock/error.ex @@ -0,0 +1,25 @@ +defmodule StripeMock.Error do + defstruct [:code, :message, :param, :type] + + @type t() :: %__MODULE__{ + code: atom(), + message: String.t(), + param: nil | map(), + type: atom() + } + + def unauthorized() do + %__MODULE__{ + code: :unauthorized, + message: "Unauthorized", + param: nil, + type: :unauthorized + } + end + + def http_status(%__MODULE__{code: :resource_missing, param: "id"}), + do: 404 + + def http_status(%__MODULE__{code: :resource_missing}), + do: 400 +end diff --git a/lib/stripe_mock/id.ex b/lib/stripe_mock/id.ex new file mode 100644 index 0000000..84f6e4e --- /dev/null +++ b/lib/stripe_mock/id.ex @@ -0,0 +1,12 @@ +defmodule StripeMock.ID do + @moduledoc """ + Generates a somewhat Stripe-esque random ID string. Pretty much a base52-encoded UUID. + """ + + def generate() do + :crypto.strong_rand_bytes(16) + |> :erlang.binary_to_list() + |> Enum.reduce(1, &(&1 * &2)) + |> StripeMock.Base52.encode() + end +end diff --git a/lib/stripe_mock/metadata.ex b/lib/stripe_mock/metadata.ex new file mode 100644 index 0000000..681ba9d --- /dev/null +++ b/lib/stripe_mock/metadata.ex @@ -0,0 +1,27 @@ +defmodule StripeMock.Metadata do + @behaviour Ecto.Type + + def type(), do: :map + + def cast(nil), do: {:ok, %{}} + def cast(""), do: {:ok, %{}} + + def cast(metadata) when is_map(metadata) do + value = + for {k, v} <- metadata, + is_bitstring(k) and String.length(k) <= 40, + is_bitstring(v) and String.length(v) <= 500, + into: %{}, + do: {k, v} + + {:ok, value} + end + + def cast(_), do: :error + + def dump(value) when is_map(value), do: {:ok, value} + def dump(_), do: :error + + def load(value) when is_map(value), do: {:ok, value} + def load(_), do: :error +end diff --git a/lib/stripe_mock/pagination.ex b/lib/stripe_mock/pagination.ex new file mode 100644 index 0000000..5dd98d1 --- /dev/null +++ b/lib/stripe_mock/pagination.ex @@ -0,0 +1,43 @@ +defmodule StripeMock.Pagination do + @moduledoc """ + Handles the Stripe-esque pagination. + """ + + alias __MODULE__.Page + + defmacro __using__(_opts) do + quote do + def render_page(conn, page, view, template) do + %{ + object: "list", + url: "/v1/customers", + has_more: page.has_more, + data: render_many(page.data, view, template) + } + end + end + end + + @spec paginate(list(), map()) :: Page.t() + def paginate(objects, params) do + limit = get_limit(params) + + objects = + case params["starting_after"] do + nil -> objects + starting_after -> Enum.drop_while(objects, &(&1.id != starting_after)) + end + + %Page{data: Enum.take(objects, limit), has_more: length(objects) > limit} + end + + @default_limit 10 + defp get_limit(%{"limit" => limit}) do + case Integer.parse(limit) do + {limit, ""} -> max(limit, 1) + _ -> @default_limit + end + end + + defp get_limit(_params), do: @default_limit +end diff --git a/lib/stripe_mock/pagination/page.ex b/lib/stripe_mock/pagination/page.ex new file mode 100644 index 0000000..d16d4bc --- /dev/null +++ b/lib/stripe_mock/pagination/page.ex @@ -0,0 +1,13 @@ +defmodule StripeMock.Pagination.Page do + @moduledoc """ + Defines a `Page` type for passing data to the views. + """ + + @enforce_keys [:has_more, :data] + defstruct [:has_more, :data] + + @type t() :: %__MODULE__{ + has_more: boolean(), + data: list() + } +end diff --git a/lib/stripe_mock/repo.ex b/lib/stripe_mock/repo.ex new file mode 100644 index 0000000..29b537f --- /dev/null +++ b/lib/stripe_mock/repo.ex @@ -0,0 +1,175 @@ +defmodule StripeMock.Repo do + @moduledoc """ + This is where we store everything. Obviously, don't call any of these + functions yourself. + + State structure is: + + %{ + customers: %{ + "cus_123123" => %Customer{} + } + } + """ + use GenServer + import Ecto.Changeset + alias Ecto.Changeset + alias StripeMock.API + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, %{}, [name: __MODULE__] ++ opts) + end + + def all(schema), do: GenServer.call(pid(), {:all, schema}) + def insert(changeset), do: GenServer.call(pid(), {:save, changeset}) + def update(changeset), do: GenServer.call(pid(), {:save, changeset}) + def get(schema, id), do: GenServer.call(pid(), {:get, schema, id}) + + def get!(schema, id) do + case GenServer.call(pid(), {:get, schema, id}) do + nil -> raise "Not found." + record -> record + end + end + + def fetch(schema, id) do + case get(schema, id) do + nil -> {:error, :not_found} + object -> {:ok, object} + end + end + + def delete(%Changeset{} = changeset) do + changeset = put_change(changeset, :deleted, true) + GenServer.call(pid(), {:save, changeset}) + end + + def delete(object), do: GenServer.call(pid(), {:save, %{object | deleted: true}}) + + defp pid(), do: GenServer.whereis(__MODULE__) + + @impl true + def init(state) do + {:ok, state} + end + + @impl true + def handle_call({:all, schema}, _from, state) do + case Map.get(state, schema) do + schemas when is_map(schemas) -> {:reply, Map.values(schemas), state} + _ -> {:reply, [], state} + end + end + + @impl true + def handle_call({:save, changeset}, _from, state) do + case save(changeset, state) do + {:ok, term, new_state} -> {:reply, {:ok, term}, new_state} + {:error, _} = error -> {:reply, error, state} + end + end + + @impl true + def handle_call({:get, schema, id}, _from, state) do + object = + with schemas when is_map(schemas) <- Map.get(state, schema), + object when is_map(object) <- Map.get(schemas, id) do + object + else + _ -> nil + end + + {:reply, object, state} + end + + @spec save(Changeset.t(), map()) :: {:ok, any(), map()} | {:error, Changeset.t()} + defp save(changeset, state) do + changeset = change(changeset) + + changeset = + case get_field(changeset, :id) do + nil -> put_change(changeset, :id, generate_id(changeset.data)) + id when is_bitstring(id) -> changeset + end + + changeset = + cond do + Map.has_key?(changeset.data, :created) -> + case get_field(changeset, :created) do + nil -> put_change(changeset, :created, :os.system_time(:seconds)) + _ -> changeset + end + + true -> + changeset + end + + changeset = + if Map.has_key?(changeset.data, :object) do + put_change(changeset, :object, type(changeset.data)) + else + changeset + end + + # Run through changed assocs and save + {changeset, state} = + Enum.reduce_while(changeset.changes, {changeset, state}, fn + {key, %Changeset{} = assoc_change}, {changeset, state} -> + case save(assoc_change, state) do + {:ok, saved_assoc, new_state} -> + changeset = + changeset + |> put_change(key, saved_assoc) + |> put_change(String.to_atom("#{key}_id"), saved_assoc.id) + + {:cont, {changeset, new_state}} + + {:error, failed_assoc} -> + changeset = put_change(changeset, key, failed_assoc) + throw({:invalid_changeset, changeset}) + end + + _, acc -> + {:cont, acc} + end) + + if !changeset.valid? do + throw({:invalid_changeset, changeset}) + end + + object = apply_changes(changeset) + + # Put the object into the new state + type = object.__struct__ + + type_map = + case Map.get(state, type) do + %{} = s -> s + _ -> %{} + end + |> Map.put(object.id, object) + + new_state = Map.put(state, type, type_map) + + {:ok, object, new_state} + catch + {:invalid_changeset, changeset} -> + {:error, changeset} + end + + defp generate_id(schema) do + prefix(schema) <> "_" <> StripeMock.ID.generate() + end + + def type(%API.Card{}), do: :card + def type(%API.Charge{}), do: :charge + def type(%API.Customer{}), do: :customer + def type(%API.Refund{}), do: :refund + def type(%API.Token{}), do: :token + + def prefix(%API.Card{}), do: "card" + def prefix(%API.Charge{}), do: "ch" + def prefix(%API.Customer{}), do: "cus" + def prefix(%API.Refund{}), do: "re" + def prefix(%API.Token{}), do: "tok" +end diff --git a/lib/stripe_mock_web.ex b/lib/stripe_mock_web.ex new file mode 100644 index 0000000..c1ffa35 --- /dev/null +++ b/lib/stripe_mock_web.ex @@ -0,0 +1,71 @@ +defmodule StripeMockWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, views, channels and so on. + + This can be used in your application as: + + use StripeMockWeb, :controller + use StripeMockWeb, :view + + The definitions below will be executed for every view, + controller, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define any helper function in modules + and import those modules here. + """ + + def controller do + quote do + use Phoenix.Controller, namespace: StripeMockWeb + + import Plug.Conn + import StripeMockWeb.Gettext + import StripeMock.Pagination, only: [paginate: 2] + alias StripeMockWeb.Router.Helpers, as: Routes + alias StripeMockWeb.Plug, as: SMPlug + end + end + + def view do + quote do + use Phoenix.View, + root: "lib/stripe_mock_web/templates", + namespace: StripeMockWeb + + use StripeMock.Pagination + + # Import convenience functions from controllers + import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1] + + import StripeMockWeb.ErrorHelpers + import StripeMockWeb.ViewHelpers + import StripeMockWeb.Gettext + alias StripeMockWeb.Router.Helpers, as: Routes + end + end + + def router do + quote do + use Phoenix.Router + import Plug.Conn + import Phoenix.Controller + end + end + + def channel do + quote do + use Phoenix.Channel + import StripeMockWeb.Gettext + end + end + + @doc """ + When used, dispatch to the appropriate controller/view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/lib/stripe_mock_web/controllers/charge_controller.ex b/lib/stripe_mock_web/controllers/charge_controller.ex new file mode 100644 index 0000000..1fda950 --- /dev/null +++ b/lib/stripe_mock_web/controllers/charge_controller.ex @@ -0,0 +1,36 @@ +defmodule StripeMockWeb.ChargeController do + use StripeMockWeb, :controller + + alias StripeMock.API + alias StripeMock.API.Charge + + plug SMPlug.ConvertParams, %{"customer" => "customer_id"} when action in [:create, :update] + action_fallback StripeMockWeb.FallbackController + + def index(conn, params) do + page = API.list_charges() |> paginate(params) + render(conn, "index.json", page: page) + end + + def create(conn, charge_params) do + with {:ok, %Charge{} = charge} <- API.create_charge(charge_params) do + conn + |> put_status(:created) + |> put_resp_header("location", Routes.charge_path(conn, :show, charge)) + |> render("show.json", charge: charge) + end + end + + def show(conn, %{"id" => id}) do + with {:ok, charge} <- API.get_charge(id) do + render(conn, "show.json", charge: charge) + end + end + + def update(conn, %{"id" => id} = charge_params) do + with {:ok, charge} <- API.get_charge(id), + {:ok, %Charge{} = charge} <- API.update_charge(charge, charge_params) do + render(conn, "show.json", charge: charge) + end + end +end diff --git a/lib/stripe_mock_web/controllers/customer_controller.ex b/lib/stripe_mock_web/controllers/customer_controller.ex new file mode 100644 index 0000000..a52e073 --- /dev/null +++ b/lib/stripe_mock_web/controllers/customer_controller.ex @@ -0,0 +1,43 @@ +defmodule StripeMockWeb.CustomerController do + use StripeMockWeb, :controller + + alias StripeMock.API + alias StripeMock.API.Customer + + action_fallback StripeMockWeb.FallbackController + + def index(conn, params) do + page = API.list_customers() |> paginate(params) + render(conn, "index.json", page: page) + end + + def create(conn, customer_params) do + with {:ok, %Customer{} = customer} <- API.create_customer(customer_params) do + conn + |> put_status(:created) + |> put_resp_header("location", Routes.customer_path(conn, :show, customer)) + |> render("show.json", customer: customer) + end + end + + def show(conn, %{"id" => id}) do + customer = API.get_customer!(id) + render(conn, "show.json", customer: customer) + end + + def update(conn, %{"id" => id} = customer_params) do + customer = API.get_customer!(id) + + with {:ok, %Customer{} = customer} <- API.update_customer(customer, customer_params) do + render(conn, "show.json", customer: customer) + end + end + + def delete(conn, %{"id" => id}) do + customer = API.get_customer!(id) + + with {:ok, %Customer{}} <- API.delete_customer(customer) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/stripe_mock_web/controllers/fallback_controller.ex b/lib/stripe_mock_web/controllers/fallback_controller.ex new file mode 100644 index 0000000..6baf637 --- /dev/null +++ b/lib/stripe_mock_web/controllers/fallback_controller.ex @@ -0,0 +1,30 @@ +defmodule StripeMockWeb.FallbackController do + @moduledoc """ + Translates controller action results into valid `Plug.Conn` responses. + + See `Phoenix.Controller.action_fallback/1` for more details. + """ + use StripeMockWeb, :controller + alias StripeMock.Error + + def call(conn, {:error, %Ecto.Changeset{} = changeset}) do + conn + |> put_status(:unprocessable_entity) + |> put_view(StripeMockWeb.ChangesetView) + |> render("error.json", changeset: changeset) + end + + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> put_view(StripeMockWeb.ErrorView) + |> render(:"404") + end + + def call(conn, {:error, %Error{} = error}) do + conn + |> put_status(Error.http_status(error)) + |> put_view(StripeMockWeb.ErrorView) + |> render(:error, error: error) + end +end diff --git a/lib/stripe_mock_web/controllers/refund_controller.ex b/lib/stripe_mock_web/controllers/refund_controller.ex new file mode 100644 index 0000000..944e1fd --- /dev/null +++ b/lib/stripe_mock_web/controllers/refund_controller.ex @@ -0,0 +1,36 @@ +defmodule StripeMockWeb.RefundController do + use StripeMockWeb, :controller + + alias StripeMock.API + alias StripeMock.API.Refund + + plug SMPlug.ConvertParams, %{"charge" => "charge_id"} when action in [:create, :update] + action_fallback StripeMockWeb.FallbackController + + def index(conn, params) do + page = API.list_refunds() |> paginate(params) + render(conn, "index.json", page: page) + end + + def create(conn, refund_params) do + with {:ok, %Refund{} = refund} <- API.create_refund(refund_params) do + conn + |> put_status(:created) + |> put_resp_header("location", Routes.refund_path(conn, :show, refund)) + |> render("show.json", refund: refund) + end + end + + def show(conn, %{"id" => id}) do + with {:ok, refund} <- API.get_refund(id) do + render(conn, "show.json", refund: refund) + end + end + + def update(conn, %{"id" => id} = refund_params) do + with {:ok, refund} <- API.get_refund(id), + {:ok, %Refund{} = refund} <- API.update_refund(refund, refund_params) do + render(conn, "show.json", refund: refund) + end + end +end diff --git a/lib/stripe_mock_web/controllers/source_controller.ex b/lib/stripe_mock_web/controllers/source_controller.ex new file mode 100644 index 0000000..2422860 --- /dev/null +++ b/lib/stripe_mock_web/controllers/source_controller.ex @@ -0,0 +1,74 @@ +defmodule StripeMockWeb.SourceController do + use StripeMockWeb, :controller + + alias StripeMock.API + + plug :set_customer when action != :show + action_fallback StripeMockWeb.FallbackController + + # GET /v1/customers/cus_F7ux2fMKFvhMMP/sources?object=card + def index(conn, %{"object" => "card"} = params) do + page = API.list_cards(conn.assigns.customer) |> paginate(params) + + conn + |> put_view(StripeMockWeb.CardView) + |> render("index.json", page: page) + end + + # POST /v1/customers/cus_F9PeLuQPxok2xa/sources + def create(conn, %{"source" => token} = params) do + %{customer: customer} = conn.assigns + + case API.get_token(token) do + {:ok, source} -> + with {:ok, source} <- + API.create_customer_card_from_source(customer, source, params["metadata"]) do + conn + |> put_status(:created) + |> render("show.json", source: source) + end + + {:error, :not_found} -> + {:error, + %StripeMock.Error{code: :resource_missing, type: :invalid_request_error, param: "source"}} + end + end + + def show(conn, %{"id" => id}) do + card = API.get_card!(id) + + conn + |> put_view(StripeMockWeb.CardView) + |> render("show.json", card: card) + end + + def update(conn, %{"id" => id, "card" => card_params}) do + card = API.get_card!(id) + + with {:ok, source} <- API.update_card(card, card_params) do + render(conn, "show.json", source: source) + end + end + + def delete(conn, %{"id" => id}) do + card = API.get_card!(id) + + with {:ok, source} <- API.delete_card(card) do + render(conn, "show.json", source: source) + end + end + + defp set_customer(%{params: %{"customer_id" => customer_id}} = conn, _arg) do + case API.get_customer(customer_id) do + {:ok, customer} -> + assign(conn, :customer, customer) + + _ -> + conn + |> put_status(404) + |> put_view(StripeMockWeb.ErrorView) + |> render(:"404", []) + |> halt() + end + end +end diff --git a/lib/stripe_mock_web/controllers/token_controller.ex b/lib/stripe_mock_web/controllers/token_controller.ex new file mode 100644 index 0000000..bb79297 --- /dev/null +++ b/lib/stripe_mock_web/controllers/token_controller.ex @@ -0,0 +1,22 @@ +defmodule StripeMockWeb.TokenController do + use StripeMockWeb, :controller + + alias StripeMock.API + + action_fallback StripeMockWeb.FallbackController + plug SMPlug.SetClientIP + + def create(conn, params) do + with {:ok, token} <- API.create_token(params) do + conn + |> put_status(:created) + |> render("show.json", token: token) + end + end + + def show(conn, %{"id" => id}) do + with {:ok, token} <- API.get_token(id) do + render(conn, "show.json", token: token) + end + end +end diff --git a/lib/stripe_mock_web/endpoint.ex b/lib/stripe_mock_web/endpoint.ex new file mode 100644 index 0000000..9e7ef25 --- /dev/null +++ b/lib/stripe_mock_web/endpoint.ex @@ -0,0 +1,16 @@ +defmodule StripeMockWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :stripe_mock + + plug Plug.RequestId + plug Plug.Logger + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + + plug StripeMockWeb.Router +end diff --git a/lib/stripe_mock_web/gettext.ex b/lib/stripe_mock_web/gettext.ex new file mode 100644 index 0000000..254ba79 --- /dev/null +++ b/lib/stripe_mock_web/gettext.ex @@ -0,0 +1,24 @@ +defmodule StripeMockWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), + your module gains a set of macros for translations, for example: + + import StripeMockWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext, otp_app: :stripe_mock +end diff --git a/lib/stripe_mock_web/plug/auth.ex b/lib/stripe_mock_web/plug/auth.ex new file mode 100644 index 0000000..916abc4 --- /dev/null +++ b/lib/stripe_mock_web/plug/auth.ex @@ -0,0 +1,54 @@ +defmodule StripeMockWeb.Plug.Auth do + @moduledoc """ + Pretends to do auth stuff. + """ + + use Phoenix.Controller + + def init(options), do: options + + def call(conn, _arg) do + if authed?(conn) do + conn + else + conn + |> put_status(:unauthorized) + |> put_view(StripeMockWeb.ErrorView) + |> render(:error, error: StripeMock.Error.unauthorized()) + |> halt() + end + end + + defp authed?(conn) do + case get_key(conn) do + "sk_test_" <> _ -> true + _ -> false + end + end + + defp get_key(conn) do + conn.req_headers + |> Enum.find_value(fn + {"authorization", auth} when is_bitstring(auth) -> + case String.downcase(auth) do + "basic " <> _ -> + {_, base64_key} = String.split_at(auth, 6) + + case Base.decode64(base64_key) do + {:ok, key} -> key + _ -> false + end + + "bearer " <> _ -> + {_, key} = String.split_at(auth, 7) + key + + _ -> + false + end + + _ -> + false + end) + end +end diff --git a/lib/stripe_mock_web/plug/convert_params.ex b/lib/stripe_mock_web/plug/convert_params.ex new file mode 100644 index 0000000..3680701 --- /dev/null +++ b/lib/stripe_mock_web/plug/convert_params.ex @@ -0,0 +1,24 @@ +defmodule StripeMockWeb.Plug.ConvertParams do + @moduledoc """ + Moves params from one key to the other before they hit the changeset + functions. For example, `%{"customer_id" => customer_id}` should be changed + to `%{"customer" => customer_id}` for setting the customer in a way Stripe + would expect. + """ + + def init(options) when is_map(options) do + options + end + + def call(conn, conversions) do + params = + for {k, v} <- conn.params, into: %{} do + case Map.get(conversions, k) do + nil -> {k, v} + new_key -> {new_key, v} + end + end + + %{conn | params: params} + end +end diff --git a/lib/stripe_mock_web/plug/set_client_ip.ex b/lib/stripe_mock_web/plug/set_client_ip.ex new file mode 100644 index 0000000..e0936d1 --- /dev/null +++ b/lib/stripe_mock_web/plug/set_client_ip.ex @@ -0,0 +1,13 @@ +defmodule StripeMockWeb.Plug.SetClientIP do + @moduledoc """ + Sets the client IP address in the params via an extremely frowned upon method. + """ + + def init(options), do: options + + def call(%{params: params} = conn, _arg) do + [a, b, c, d | _] = Tuple.to_list(conn.remote_ip) + params = Map.put(params, "client_ip", "#{a}.#{b}.#{c}.#{d}") + %{conn | params: params} + end +end diff --git a/lib/stripe_mock_web/router.ex b/lib/stripe_mock_web/router.ex new file mode 100644 index 0000000..b5ec4ce --- /dev/null +++ b/lib/stripe_mock_web/router.ex @@ -0,0 +1,21 @@ +defmodule StripeMockWeb.Router do + use StripeMockWeb, :router + + pipeline :api do + plug :accepts, ["json"] + plug StripeMockWeb.Plug.Auth + end + + scope "/v1", StripeMockWeb do + pipe_through :api + + resources "/customers", CustomerController do + resources "/sources", SourceController + end + + resources "/charges", ChargeController, except: [:delete] + resources "/refunds", RefundController, except: [:delete] + resources "/sources", SourceController, only: [:show] + resources "/tokens", TokenController, only: [:create, :show] + end +end diff --git a/lib/stripe_mock_web/view_helpers.ex b/lib/stripe_mock_web/view_helpers.ex new file mode 100644 index 0000000..5ed8ff7 --- /dev/null +++ b/lib/stripe_mock_web/view_helpers.ex @@ -0,0 +1,7 @@ +defmodule StripeMockWeb.ViewHelpers do + def as_map(struct) do + struct + |> Map.delete(:__struct__) + |> Map.delete(:__meta__) + end +end diff --git a/lib/stripe_mock_web/views/card_view.ex b/lib/stripe_mock_web/views/card_view.ex new file mode 100644 index 0000000..da8578c --- /dev/null +++ b/lib/stripe_mock_web/views/card_view.ex @@ -0,0 +1,23 @@ +defmodule StripeMockWeb.CardView do + use StripeMockWeb, :view + alias __MODULE__ + + def render("index.json", %{conn: conn, page: page}) do + render_page(conn, page, CardView, "card.json") + end + + def render("show.json", %{card: card}) do + render_one(card, CardView, "card.json") + end + + def render("card.json", %{card: card}) do + card + |> Map.take(~w(id created deleted exp_month exp_year metadata last4 brand)a) + |> Map.put("object", "card") + |> Map.put("customer", card.customer_id) + end + + def render("delete.json", %{card: card}) do + %{id: card.id, object: card.object, deleted: card.deleted} + end +end diff --git a/lib/stripe_mock_web/views/changeset_view.ex b/lib/stripe_mock_web/views/changeset_view.ex new file mode 100644 index 0000000..2260fce --- /dev/null +++ b/lib/stripe_mock_web/views/changeset_view.ex @@ -0,0 +1,19 @@ +defmodule StripeMockWeb.ChangesetView do + use StripeMockWeb, :view + + @doc """ + Traverses and translates changeset errors. + + See `Ecto.Changeset.traverse_errors/2` and + `StripeMockWeb.ErrorHelpers.translate_error/1` for more details. + """ + def translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, &translate_error/1) + end + + def render("error.json", %{changeset: changeset}) do + # When encoded, the changeset returns its errors + # as a JSON object. So we just pass it forward. + %{errors: translate_errors(changeset)} + end +end diff --git a/lib/stripe_mock_web/views/charge_view.ex b/lib/stripe_mock_web/views/charge_view.ex new file mode 100644 index 0000000..4679e97 --- /dev/null +++ b/lib/stripe_mock_web/views/charge_view.ex @@ -0,0 +1,27 @@ +defmodule StripeMockWeb.ChargeView do + use StripeMockWeb, :view + alias StripeMockWeb.ChargeView + + def render("index.json", %{page: page}) do + %{data: render_many(page.data, ChargeView, "charge.json")} + end + + def render("show.json", %{charge: charge}) do + render_one(charge, ChargeView, "charge.json") + end + + def render("charge.json", %{charge: charge}) do + %{ + id: charge.id, + amount: charge.amount, + currency: charge.currency, + capture: charge.capture, + customer: charge.customer_id, + description: charge.description, + metadata: charge.metadata, + object: "charge", + statement_descriptor: charge.statement_descriptor, + transfer_group: charge.transfer_group + } + end +end diff --git a/lib/stripe_mock_web/views/customer_view.ex b/lib/stripe_mock_web/views/customer_view.ex new file mode 100644 index 0000000..1e5b841 --- /dev/null +++ b/lib/stripe_mock_web/views/customer_view.ex @@ -0,0 +1,16 @@ +defmodule StripeMockWeb.CustomerView do + use StripeMockWeb, :view + alias StripeMockWeb.CustomerView + + def render("index.json", %{conn: conn, page: page}) do + render_page(conn, page, CustomerView, "customer.json") + end + + def render("show.json", %{customer: customer}) do + render_one(customer, CustomerView, "customer.json") + end + + def render("customer.json", %{customer: customer}) do + customer |> as_map() + end +end diff --git a/lib/stripe_mock_web/views/error_helpers.ex b/lib/stripe_mock_web/views/error_helpers.ex new file mode 100644 index 0000000..831fc59 --- /dev/null +++ b/lib/stripe_mock_web/views/error_helpers.ex @@ -0,0 +1,33 @@ +defmodule StripeMockWeb.ErrorHelpers do + @moduledoc """ + Conveniences for translating and building error messages. + """ + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # When using gettext, we typically pass the strings we want + # to translate as a static argument: + # + # # Translate "is invalid" in the "errors" domain + # dgettext("errors", "is invalid") + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # Because the error messages we show in our forms and APIs + # are defined inside Ecto, we need to translate them dynamically. + # This requires us to call the Gettext module passing our gettext + # backend as first argument. + # + # Note we use the "errors" domain, which means translations + # should be written to the errors.po file. The :count option is + # set by Ecto and indicates we should also apply plural rules. + if count = opts[:count] do + Gettext.dngettext(StripeMockWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(StripeMockWeb.Gettext, "errors", msg, opts) + end + end +end diff --git a/lib/stripe_mock_web/views/error_view.ex b/lib/stripe_mock_web/views/error_view.ex new file mode 100644 index 0000000..4f59f1b --- /dev/null +++ b/lib/stripe_mock_web/views/error_view.ex @@ -0,0 +1,20 @@ +defmodule StripeMockWeb.ErrorView do + use StripeMockWeb, :view + + # If you want to customize a particular status code + # for a certain format, you may uncomment below. + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def template_not_found(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end + + def render("error.json", %{error: error}) do + %{error: Map.take(error, [:code, :message, :param, :type])} + end +end diff --git a/lib/stripe_mock_web/views/refund_view.ex b/lib/stripe_mock_web/views/refund_view.ex new file mode 100644 index 0000000..9391827 --- /dev/null +++ b/lib/stripe_mock_web/views/refund_view.ex @@ -0,0 +1,22 @@ +defmodule StripeMockWeb.RefundView do + use StripeMockWeb, :view + alias StripeMockWeb.RefundView + + def render("index.json", %{conn: conn, page: page}) do + render_page(conn, page, RefundView, "refund.json") + end + + def render("show.json", %{refund: refund}) do + render_one(refund, RefundView, "refund.json") + end + + def render("refund.json", %{refund: refund}) do + %{ + id: refund.id, + charge: refund.charge_id, + amount: refund.amount, + metadata: refund.metadata, + reason: refund.reason + } + end +end diff --git a/lib/stripe_mock_web/views/source_view.ex b/lib/stripe_mock_web/views/source_view.ex new file mode 100644 index 0000000..e0a607b --- /dev/null +++ b/lib/stripe_mock_web/views/source_view.ex @@ -0,0 +1,10 @@ +defmodule StripeMockWeb.SourceView do + use StripeMockWeb, :view + + def render("show.json", %{source: source}) do + case source.id do + "card_" <> _ -> render_one(source, StripeMockWeb.CardView, "card.json") + _ -> raise "Weird ID" + end + end +end diff --git a/lib/stripe_mock_web/views/token_view.ex b/lib/stripe_mock_web/views/token_view.ex new file mode 100644 index 0000000..8df98ce --- /dev/null +++ b/lib/stripe_mock_web/views/token_view.ex @@ -0,0 +1,30 @@ +defmodule StripeMockWeb.TokenView do + use StripeMockWeb, :view + alias StripeMockWeb.{CardView, TokenView} + + def render("index.json", %{conn: conn, page: page}) do + render_page(conn, page, TokenView, "token.json") + end + + def render("show.json", %{token: token}) do + render_one(token, TokenView, "token.json") + end + + def render("token.json", %{token: token}) do + object = %{ + id: token.id, + object: "token", + client_ip: token.client_ip, + created: token.created, + type: token.type, + used: token.used + } + + if token.card do + card = render(CardView, "card.json", card: token.card) + Map.put(object, :card, card) + else + object + end + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..1e5da9a --- /dev/null +++ b/mix.exs @@ -0,0 +1,61 @@ +defmodule StripeMock.MixProject do + use Mix.Project + + def project do + [ + app: :stripe_mock, + version: "0.1.0", + elixir: "~> 1.5", + elixirc_paths: elixirc_paths(Mix.env()), + compilers: [:phoenix, :gettext] ++ Mix.compilers(), + start_permanent: false, + description: description(), + package: package(), + deps: deps() + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {StripeMock.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + defp description do + """ + StripeMock is a Stripe mock server written with Elixir and Phoenix. + """ + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support", "test/fixture"] + defp elixirc_paths(_), do: ["lib"] + + defp package() do + [ + name: :stripe_mock, + files: ["lib", "priv", "mix.exs", "README.md", "LICENSE"], + maintainers: ["Nick Kezhaya"], + licenses: ["MIT"], + links: %{"GitHub" => "https://github.com/whitepaperclip/stripe_mock"} + ] + end + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, ">= 0.0.0"}, + {:gettext, "~> 0.11"}, + {:jason, "~> 1.0"}, + {:plug_cowboy, "~> 2.0"}, + {:ecto, "~> 3.1"}, + {:ex_doc, ">= 0.0.0", only: :dev} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..10fc0e2 --- /dev/null +++ b/mix.lock @@ -0,0 +1,20 @@ +%{ + "cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, + "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm"}, + "decimal": {:hex, :decimal, "1.7.0", "30d6b52c88541f9a66637359ddf85016df9eb266170d53105f02e4a67e00c5aa", [:mix], [], "hexpm"}, + "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm"}, + "ecto": {:hex, :ecto, "3.1.4", "69d852da7a9f04ede725855a35ede48d158ca11a404fe94f8b2fb3b2162cd3c9", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, + "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, + "gettext": {:hex, :gettext, "0.16.1", "e2130b25eebcbe02bb343b119a07ae2c7e28bd4b146c4a154da2ffb2b3507af2", [:mix], [], "hexpm"}, + "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "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"}, + "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, + "phoenix": {:hex, :phoenix, "1.4.6", "8535f4a01291f0fbc2c30c78c4ca6a2eacc148db5178ad76e8b2fc976c590115", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"}, + "plug": {:hex, :plug, "1.8.0", "9d2685cb007fe5e28ed9ac27af2815bc262b7817a00929ac10f56f169f43b977", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.0.2", "6055f16868cc4882b24b6e1d63d2bada94fb4978413377a3b32ac16c18dffba2", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, + "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, +} diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..cdec3a1 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,11 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot new file mode 100644 index 0000000..d6f47fa --- /dev/null +++ b/priv/gettext/errors.pot @@ -0,0 +1,10 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. + diff --git a/test/fixture/card.ex b/test/fixture/card.ex new file mode 100644 index 0000000..7e1aecc --- /dev/null +++ b/test/fixture/card.ex @@ -0,0 +1,10 @@ +defmodule StripeMock.CardFixture do + def valid_card() do + %{ + number: "4242424242424242", + exp_month: "12", + exp_year: "2020", + cvc: "123" + } + end +end diff --git a/test/stripe_mock_web/controllers/charge_controller_test.exs b/test/stripe_mock_web/controllers/charge_controller_test.exs new file mode 100644 index 0000000..3dd9ff4 --- /dev/null +++ b/test/stripe_mock_web/controllers/charge_controller_test.exs @@ -0,0 +1,95 @@ +defmodule StripeMockWeb.ChargeControllerTest do + use StripeMockWeb.ConnCase + @moduletag :charge + + alias StripeMock.API.Charge + + setup :create_customer + + describe "index" do + test "lists all charges", %{conn: conn} do + conn = get(conn, Routes.charge_path(conn, :index)) + assert is_list(json_response(conn, 200)["data"]) + end + end + + describe "create charge" do + test "renders charge when data is valid", %{conn: conn, customer: customer} do + conn = post(conn, Routes.charge_path(conn, :create), create_attrs(customer.id)) + assert %{"id" => id} = json_response(conn, 201) + + conn = get(conn, Routes.charge_path(conn, :show, id)) + + assert %{ + "id" => id, + "amount" => 5000, + "capture" => true, + "currency" => "some currency", + "customer" => "cus_" <> _, + "description" => "some description", + "metadata" => %{}, + "statement_descriptor" => "some statement_descriptor", + "transfer_group" => "some transfer_group" + } = json_response(conn, 200) + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, Routes.charge_path(conn, :create), invalid_attrs()) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update charge" do + setup [:create_charge] + + test "renders charge when data is valid", %{conn: conn, charge: %Charge{id: id} = charge} do + conn = put(conn, Routes.charge_path(conn, :update, charge), update_attrs()) + assert %{"id" => ^id} = json_response(conn, 200) + + conn = get(conn, Routes.charge_path(conn, :show, id)) + + assert %{ + "description" => "some updated description", + "metadata" => %{"key" => "val"} + } = json_response(conn, 200) + end + + test "renders errors when data is invalid", %{conn: conn, charge: charge} do + conn = put(conn, Routes.charge_path(conn, :update, charge), invalid_attrs()) + assert json_response(conn, 422)["errors"] != %{} + end + end + + def create_attrs(customer_id) do + %{ + amount: 5000, + capture: true, + currency: "some currency", + customer: customer_id, + description: "some description", + metadata: %{}, + statement_descriptor: "some statement_descriptor", + transfer_group: "some transfer_group" + } + end + + def update_attrs() do + %{ + description: "some updated description", + metadata: %{"key" => "val"} + } + end + + def invalid_attrs() do + %{ + amount: nil, + capture: nil, + currency: nil, + customer: nil, + description: nil, + metadata: nil, + statement_descriptor: nil, + transfer_group: nil + } + end +end diff --git a/test/stripe_mock_web/controllers/customer_controller_test.exs b/test/stripe_mock_web/controllers/customer_controller_test.exs new file mode 100644 index 0000000..f0c4f83 --- /dev/null +++ b/test/stripe_mock_web/controllers/customer_controller_test.exs @@ -0,0 +1,80 @@ +defmodule StripeMockWeb.CustomerControllerTest do + use StripeMockWeb.ConnCase + @moduletag :customer + + alias StripeMock.API.Customer + + @create_attrs %{ + email: "foo@wat.com" + } + @update_attrs %{ + name: "Bar" + } + @invalid_attrs %{ + email: "not an email" + } + + describe "index" do + setup :create_customer + setup :create_customer + + test "lists all customers", %{conn: conn} do + conn = get(conn, Routes.customer_path(conn, :index)) + refute json_response(conn, 200)["data"] == [] + end + end + + describe "create customer" do + test "renders customer when data is valid", %{conn: conn} do + conn = post(conn, Routes.customer_path(conn, :create), @create_attrs) + assert %{"id" => id} = json_response(conn, 201) + + conn = get(conn, Routes.customer_path(conn, :show, id)) + + assert %{"id" => id} = json_response(conn, 200) + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, Routes.customer_path(conn, :create), @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update customer" do + setup [:create_customer] + + test "renders customer when data is valid", %{ + conn: conn, + customer: %Customer{id: id} = customer + } do + conn = put(conn, Routes.customer_path(conn, :update, customer), @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200) + + conn = get(conn, Routes.customer_path(conn, :show, id)) + + assert %{"id" => id} = json_response(conn, 200) + end + + test "renders errors when data is invalid", %{conn: conn, customer: customer} do + conn = put(conn, Routes.customer_path(conn, :update, customer), @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete customer" do + setup [:create_customer] + + test "deletes chosen customer", %{ + conn: conn, + customer: %Customer{id: id} = customer + } do + conn = delete(conn, Routes.customer_path(conn, :delete, customer)) + assert response(conn, 204) + + assert %{"deleted" => true} = + conn + |> get(Routes.customer_path(conn, :show, id)) + |> json_response(200) + end + end +end diff --git a/test/stripe_mock_web/controllers/refund_controller_test.exs b/test/stripe_mock_web/controllers/refund_controller_test.exs new file mode 100644 index 0000000..52c975b --- /dev/null +++ b/test/stripe_mock_web/controllers/refund_controller_test.exs @@ -0,0 +1,100 @@ +defmodule StripeMockWeb.RefundControllerTest do + use StripeMockWeb.ConnCase + @moduletag :refund + + alias StripeMock.API.Refund + + setup :create_customer + setup :create_charge + + describe "index" do + test "lists all refunds", %{conn: conn} do + conn = get(conn, Routes.refund_path(conn, :index)) + assert is_list(json_response(conn, 200)["data"]) + end + end + + describe "create refund" do + test "renders refund when data is valid", %{conn: conn, charge: charge} do + conn = post(conn, Routes.refund_path(conn, :create), create_attrs(charge.id)) + assert %{"id" => id} = json_response(conn, 201) + + conn = get(conn, Routes.refund_path(conn, :show, id)) + + assert %{ + "id" => "re_" <> _, + "amount" => 5000, + "charge" => "ch_" <> _, + "metadata" => %{} + } = json_response(conn, 200) + end + + test "amount defaults to full amount of the charge", %{conn: conn, charge: charge} do + params = %{create_attrs(charge.id) | amount: nil} + conn = post(conn, Routes.refund_path(conn, :create), params) + amount = charge.amount + assert %{"id" => id, "amount" => ^amount} = json_response(conn, 201) + refute is_nil(charge.amount) + + conn = get(conn, Routes.refund_path(conn, :show, id)) + + assert %{ + "id" => "re_" <> _, + "amount" => ^amount, + "charge" => "ch_" <> _, + "metadata" => %{} + } = json_response(conn, 200) + end + + test "refunds limited to charge amount", %{conn: conn, charge: charge} do + params = %{create_attrs(charge.id) | amount: nil} + + conn + |> post(Routes.refund_path(conn, :create), params) + |> json_response(:created) + + params = %{create_attrs(charge.id) | amount: 1} + conn = post(conn, Routes.refund_path(conn, :create), params) + amount_errors = json_response(conn, 422)["errors"]["amount"] + assert is_list(amount_errors) and amount_errors != [] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, Routes.refund_path(conn, :create), invalid_attrs()) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update refund" do + setup [:create_refund] + + test "renders refund when data is valid", %{conn: conn, refund: %Refund{id: id} = refund} do + assert refund.metadata == %{} + + conn = put(conn, Routes.refund_path(conn, :update, refund), %{metadata: %{"key" => "val"}}) + assert %{"id" => ^id} = json_response(conn, 200) + + conn = get(conn, Routes.refund_path(conn, :show, id)) + + assert %{"metadata" => %{"key" => "val"}} = json_response(conn, 200) + end + end + + def create_attrs(charge_id) do + %{ + amount: 5000, + charge: charge_id, + description: "some description", + metadata: %{} + } + end + + def invalid_attrs() do + %{ + amount: nil, + charge: nil, + description: nil, + metadata: nil + } + end +end diff --git a/test/stripe_mock_web/controllers/source_controller_test.exs b/test/stripe_mock_web/controllers/source_controller_test.exs new file mode 100644 index 0000000..11c7496 --- /dev/null +++ b/test/stripe_mock_web/controllers/source_controller_test.exs @@ -0,0 +1,103 @@ +defmodule StripeMockWeb.SourceControllerTest do + use StripeMockWeb.ConnCase + @moduletag :source + + alias StripeMock.API + alias StripeMock.API.Card + alias StripeMock.CardFixture + + @card_attrs CardFixture.valid_card() + @update_attrs %{ + name: "New Name", + address: "New Address" + } + + setup :create_customer + + describe "index" do + setup [:create_card] + + test "lists all cards", %{conn: conn, customer: customer} do + conn = get(conn, Routes.customer_source_path(conn, :index, customer.id, object: "card")) + data = json_response(conn, 200)["data"] + assert is_list(data) + + for source <- data do + assert source["customer"] == customer.id + end + end + end + + describe "create card" do + test "renders card when data is valid", %{conn: conn, customer: customer} do + # Get a source token + %{"id" => token} = + conn + |> post(Routes.token_path(conn, :create), card: @card_attrs) + |> json_response(201) + + %{"id" => id, "object" => "card"} = + conn + |> post(Routes.customer_source_path(conn, :create, customer.id), source: token) + |> json_response(201) + + assert id =~ ~r/^card_/ + end + + test "renders errors when data is invalid", %{conn: conn, customer: customer} do + response = + conn + |> post(Routes.customer_source_path(conn, :create, customer.id), source: "non-token") + |> json_response(400) + + assert is_map(response["error"]) + end + end + + describe "update card" do + setup [:create_card] + + test "renders card when data is valid", %{conn: conn, card: %Card{id: id} = card} do + conn = + put(conn, Routes.customer_source_path(conn, :update, card.customer_id, card), + card: @update_attrs + ) + + assert %{"id" => ^id, "object" => "card"} = json_response(conn, 200) + + conn = get(conn, Routes.customer_source_path(conn, :show, card.customer_id, card)) + + assert %{"id" => ^id, "object" => "card"} = json_response(conn, 200) + end + end + + describe "delete card" do + setup [:create_card] + + test "deletes chosen card", %{conn: conn, card: card} do + conn = delete(conn, Routes.customer_source_path(conn, :delete, card.customer_id, card)) + assert response(conn, 200) + + response = + conn + |> get(Routes.customer_source_path(conn, :show, card.customer_id, card)) + |> json_response(200) + + assert response["deleted"] + end + end + + defp create_card(%{conn: conn, customer: customer}) do + %{"id" => token} = + conn + |> post(Routes.token_path(conn, :create), card: @card_attrs) + |> json_response(201) + + %{"id" => id, "object" => "card"} = + conn + |> post(Routes.customer_source_path(conn, :create, customer.id), source: token) + |> json_response(201) + + [card: API.get_card!(id)] + end +end diff --git a/test/stripe_mock_web/controllers/token_controller_test.exs b/test/stripe_mock_web/controllers/token_controller_test.exs new file mode 100644 index 0000000..a38802a --- /dev/null +++ b/test/stripe_mock_web/controllers/token_controller_test.exs @@ -0,0 +1,32 @@ +defmodule StripeMockWeb.TokenControllerTest do + use StripeMockWeb.ConnCase + @moduletag :token + + @card_attrs StripeMock.CardFixture.valid_card() + + describe "create token" do + test "accepts card data and sets type to card", %{conn: conn} do + conn = post(conn, Routes.token_path(conn, :create), card: @card_attrs) + assert %{"id" => id, "type" => "card"} = json_response(conn, 201) + + conn = get(conn, Routes.token_path(conn, :show, id)) + + assert %{ + "id" => "tok_" <> _, + "client_ip" => "127.0.0.1", + "created" => created, + "object" => "token", + "type" => "card", + "used" => false, + "card" => %{"id" => "card_" <> _} + } = json_response(conn, 200) + + assert is_integer(created) + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, Routes.token_path(conn, :create), %{}) + assert json_response(conn, 422)["errors"] != %{} + end + end +end diff --git a/test/stripe_mock_web/plug/auth_test.exs b/test/stripe_mock_web/plug/auth_test.exs new file mode 100644 index 0000000..6d8c2d6 --- /dev/null +++ b/test/stripe_mock_web/plug/auth_test.exs @@ -0,0 +1,51 @@ +defmodule StripeMockWeb.AuthTest do + use StripeMockWeb.ConnCase + @moduletag :auth + + setup do + [conn: build_conn()] + end + + test "bearer auth", %{conn: conn} do + conn + |> put_req_header("authorization", "bearer sk_test_123") + |> get(Routes.customer_path(conn, :index)) + |> json_response(200) + |> assert + end + + test "bearer fail", %{conn: conn} do + conn + |> put_req_header("authorization", "bearer foo") + |> get(Routes.customer_path(conn, :index)) + |> json_response(401) + |> assert + end + + test "basic auth", %{conn: conn} do + # sk_test_123: + conn + |> put_req_header("authorization", "basic c2tfdGVzdF8xMjM6") + |> get(Routes.customer_path(conn, :index)) + |> json_response(200) + |> assert + end + + test "basic auth pw", %{conn: conn} do + # sk_test_123:pw doesn't matter + conn + |> put_req_header("authorization", "basic c2tfdGVzdF8xMjM6cHcgZG9lc24ndCBtYXR0ZXI=") + |> get(Routes.customer_path(conn, :index)) + |> json_response(200) + |> assert + end + + test "basic fail", %{conn: conn} do + # foo + conn + |> put_req_header("authorization", "basic Zm9v") + |> get(Routes.customer_path(conn, :index)) + |> json_response(401) + |> assert + end +end diff --git a/test/stripe_mock_web/views/error_view_test.exs b/test/stripe_mock_web/views/error_view_test.exs new file mode 100644 index 0000000..c4a5558 --- /dev/null +++ b/test/stripe_mock_web/views/error_view_test.exs @@ -0,0 +1,15 @@ +defmodule StripeMockWeb.ErrorViewTest do + use StripeMockWeb.ConnCase, async: true + + # Bring render/3 and render_to_string/3 for testing custom views + import Phoenix.View + + test "renders 404.json" do + assert render(StripeMockWeb.ErrorView, "404.json", []) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500.json" do + assert render(StripeMockWeb.ErrorView, "500.json", []) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000..6906c9a --- /dev/null +++ b/test/support/conn_case.ex @@ -0,0 +1,41 @@ +defmodule StripeMockWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + it cannot be async. For this reason, every test runs + inside a transaction which is reset at the beginning + of the test unless the test case is marked as async. + """ + + use ExUnit.CaseTemplate, async: false + + using do + quote do + # Import conveniences for testing with connections + use Phoenix.ConnTest + import StripeMock.TestHelper + alias StripeMockWeb.Router.Helpers, as: Routes + + # The default endpoint for testing + @endpoint StripeMockWeb.Endpoint + end + end + + setup _tags do + {:ok, conn: Phoenix.ConnTest.build_conn()} + end + + setup %{conn: conn} do + {:ok, conn: Plug.Conn.put_req_header(conn, "accept", "application/json")} + end + + setup %{conn: conn} do + {:ok, conn: Plug.Conn.put_req_header(conn, "authorization", "bearer sk_test_123")} + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..08b5316 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,50 @@ +ExUnit.start() + +defmodule StripeMock.TestHelper do + alias StripeMock.API + + @create_attrs %{ + name: "Foo" + } + + def create_customer() do + {:ok, customer} = API.create_customer(@create_attrs) + customer + end + + def create_customer(_) do + [customer: create_customer()] + end + + def create_charge(%{customer: customer}) do + [charge: create_charge(customer)] + end + + def create_charge(%API.Customer{} = customer) do + {:ok, charge} = + API.create_charge(%{ + amount: 5000, + capture: true, + currency: "some currency", + customer: customer.id, + description: "some description", + metadata: %{}, + statement_descriptor: "some statement_descriptor", + transfer_group: "some transfer_group" + }) + + charge + end + + def create_refund(%{charge: charge}) do + params = %{ + amount: 5000, + charge_id: charge.id, + description: "some description", + metadata: %{} + } + + {:ok, refund} = API.create_refund(params) + {:ok, refund: refund} + end +end