├── test
├── test_helper.exs
└── bypass_test.exs
├── .gitignore
├── .formatter.exs
├── lib
├── bypass
│ ├── application.ex
│ ├── utils.ex
│ ├── plug.ex
│ └── instance.ex
└── bypass.ex
├── config
└── config.exs
├── CHANGELOG.md
├── LICENSE
├── mix.exs
├── .github
└── workflows
│ └── elixir.yml
├── mix.lock
└── README.md
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /_build
2 | /cover
3 | /deps
4 | erl_crash.dump
5 | *.ez
6 | doc/
7 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
3 | ]
4 |
--------------------------------------------------------------------------------
/lib/bypass/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Bypass.Application do
2 | @moduledoc false
3 |
4 | use Application
5 |
6 | def start(_type, _args) do
7 | opts = [strategy: :one_for_one, name: Bypass.Supervisor]
8 | DynamicSupervisor.start_link(opts)
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Mix.Config module.
3 | import Config
4 |
5 | config :bypass, test_framework: :ex_unit
6 |
7 | config :ex_unit, capture_log: true
8 |
9 | config :logger, :console,
10 | level: :debug,
11 | format: "$message $metadata\n",
12 | metadata: [:pid]
13 |
--------------------------------------------------------------------------------
/lib/bypass/utils.ex:
--------------------------------------------------------------------------------
1 | defmodule Bypass.Utils do
2 | @moduledoc false
3 |
4 | Application.load(:bypass)
5 |
6 | defmacro debug_log(msg) do
7 | quote bind_quoted: [msg: msg] do
8 | if Application.get_env(:bypass, :enable_debug_log, false) do
9 | require Logger
10 | Logger.debug(["[bypass] ", msg])
11 | else
12 | :ok
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## v2.1.0 - 13 Nov 2020
4 |
5 | * Support latest Cowboy.
6 | * Require at least Elixir 1.7.
7 | * Ditch Cowboy 1.0
8 |
9 | ## v2.0.0 - 19 Aug 2020
10 |
11 | * Allow the redefinition of routes.
12 | * Make listen interface configurable.
13 | * Add SO_REUSEPORT.
14 | * Add support for parametric routes.
15 | * Switch from :simple_one_for_one to DynamicSupervisor.
16 | * Require at least Elixir 1.6.
17 | * Replace gun with mint.
18 |
19 | ## v1.0.0 - 26 Nov 2018
20 |
21 | * Support for Plug 1.7 with `plug_cowboy` 1 and 2.
22 |
23 | ## v0.9.0 - 30 Sept 2018
24 |
25 | * Add support for Cowboy 2 thanks to @hassox
26 |
27 | ## v0.6.0 - 2 Feb 2017
28 |
29 | * Add support for Elixir v1.0
30 | * Allow choosing the port number for a Bypass instance
31 | * Bypass instances now only listen on the loopback interface
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015–2020 PSPDFKit GmbH (pspdfkit.com)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/lib/bypass/plug.ex:
--------------------------------------------------------------------------------
1 | defmodule Bypass.Plug do
2 | @moduledoc false
3 |
4 | @behaviour Plug
5 |
6 | @impl true
7 | def init(bypass_instance: pid), do: pid
8 |
9 | @impl true
10 | def call(%{method: method, request_path: request_path} = conn, pid) do
11 | {method, path, path_params} = Bypass.Instance.call(pid, {:get_route, method, request_path})
12 | route = {method, path}
13 | conn = Plug.Conn.fetch_query_params(%{conn | params: path_params})
14 |
15 | case Bypass.Instance.call(pid, {:get_expect_fun, route}) do
16 | {:ok, ref, fun} ->
17 | try do
18 | fun.(conn)
19 | else
20 | conn ->
21 | put_result(pid, route, ref, :ok_call)
22 | conn
23 | catch
24 | class, reason ->
25 | stacktrace = __STACKTRACE__
26 | put_result(pid, route, ref, {:exit, {class, reason, stacktrace}})
27 | :erlang.raise(class, reason, stacktrace)
28 | end
29 |
30 | {:error, error, route} ->
31 | put_result(pid, route, make_ref(), {:error, error, route})
32 | raise "route error"
33 | end
34 | end
35 |
36 | defp put_result(pid, route, ref, result) do
37 | Bypass.Instance.cast(pid, {:put_expect_result, route, ref, result})
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Bypass.Mixfile do
2 | use Mix.Project
3 |
4 | @version "2.1.0"
5 | @source_url "https://github.com/PSPDFKit-labs/bypass"
6 |
7 | def project do
8 | [
9 | app: :bypass,
10 | version: @version,
11 | elixir: "~> 1.7",
12 | description: description(),
13 | package: package(),
14 | deps: deps(),
15 | docs: docs(),
16 | dialyzer: [
17 | plt_add_apps: [:ex_unit]
18 | ]
19 | ]
20 | end
21 |
22 | def application do
23 | [
24 | extra_applications: [:logger],
25 | mod: {Bypass.Application, []},
26 | env: env()
27 | ]
28 | end
29 |
30 | defp deps do
31 | [
32 | {:plug_cowboy, "~> 2.0"},
33 | {:plug, "~> 1.7"},
34 | {:ranch, "~> 1.7"},
35 | {:ex_doc, "> 0.0.0", only: :dev},
36 | {:espec, "~> 1.6", only: [:dev, :test]},
37 | {:mint, "~> 1.1", only: :test},
38 | {:dialyxir, "~> 1.3", only: [:dev], runtime: false}
39 | ]
40 | end
41 |
42 | defp env do
43 | [enable_debug_log: false]
44 | end
45 |
46 | defp docs do
47 | [
48 | main: "Bypass",
49 | api_reference: false,
50 | source_url: @source_url,
51 | source_ref: "v#{@version}",
52 | extras: ["CHANGELOG.md"]
53 | ]
54 | end
55 |
56 | defp description do
57 | """
58 | Bypass provides a quick way to create a custom plug that can be put in place instead of an
59 | actual HTTP server to return prebaked responses to client requests. This is helpful when you
60 | want to create a mock HTTP server and test how your HTTP client handles different types of
61 | server responses.
62 | """
63 | end
64 |
65 | defp package do
66 | [
67 | files: ["lib", "mix.exs", "README.md", "CHANGELOG.md", "LICENSE"],
68 | maintainers: ["PSPDFKit"],
69 | licenses: ["MIT"],
70 | links: %{
71 | "Changelog" => "#{@source_url}/blob/master/CHANGELOG.md",
72 | "GitHub" => @source_url,
73 | "PSPDFKit" => "https://pspdfkit.com"
74 | }
75 | ]
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/.github/workflows/elixir.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ "master" ]
6 | pull_request:
7 | branches: [ "master" ]
8 |
9 | permissions:
10 | contents: read
11 |
12 |
13 |
14 |
15 | jobs:
16 | build:
17 | name: Build and test - Erlang ${{matrix.otp}} / Elixir ${{matrix.elixir}}
18 | runs-on: ${{matrix.os}}
19 | strategy:
20 | matrix:
21 | # https://hexdocs.pm/elixir/compatibility-and-deprecations.html#compatibility-between-elixir-and-erlang-otp
22 | include:
23 | # Elixir 1.12: 22-24
24 | - elixir: "1.12"
25 | otp: "22.3"
26 | os: "ubuntu-20.04"
27 |
28 | - elixir: "1.12"
29 | otp: "23.3"
30 | os: "ubuntu-20.04"
31 |
32 | - elixir: "1.12"
33 | otp: "24.3"
34 | os: "ubuntu-22.04"
35 |
36 | # Elixir 1.13: 22-24
37 | - elixir: "1.13.4"
38 | otp: "22.3"
39 | os: "ubuntu-20.04"
40 |
41 | - elixir: "1.13.4"
42 | otp: "23.3"
43 | os: "ubuntu-20.04"
44 |
45 | - elixir: "1.13.4"
46 | otp: "24.3"
47 | os: "ubuntu-22.04"
48 |
49 | - elixir: "1.13.4"
50 | otp: "25.3"
51 | os: "ubuntu-22.04"
52 |
53 | # Elixir 1.14: 23-25 (and 26 from v1.14.5)
54 | - elixir: "1.14"
55 | otp: "23.3"
56 | os: "ubuntu-20.04"
57 |
58 | - elixir: "1.14"
59 | otp: "24.3"
60 | os: "ubuntu-22.04"
61 |
62 | - elixir: "1.14"
63 | otp: "25.3"
64 | os: "ubuntu-22.04"
65 |
66 | - elixir: "1.14"
67 | otp: "26.2"
68 | os: "ubuntu-22.04"
69 |
70 | # Elixir 1.15: 24-26
71 | - elixir: "1.15"
72 | otp: "24.3"
73 | os: "ubuntu-22.04"
74 |
75 | - elixir: "1.15"
76 | otp: "25.3"
77 | os: "ubuntu-22.04"
78 |
79 | - elixir: "1.15"
80 | otp: "26.2"
81 | os: "ubuntu-22.04"
82 |
83 | # Elixir 1.16: 24-25
84 | - elixir: "1.16"
85 | otp: "24.3"
86 | os: "ubuntu-22.04"
87 |
88 | - elixir: "1.16"
89 | otp: "25.3"
90 | os: "ubuntu-22.04"
91 |
92 | - elixir: "1.16"
93 | otp: "26.2"
94 | os: "ubuntu-22.04"
95 |
96 | steps:
97 | - uses: actions/checkout@v3
98 | - name: Set up Elixir
99 | uses: erlef/setup-beam@ae6e9db1bf49000a27750a9e283cf4069da9d171
100 | with:
101 | otp-version: ${{matrix.otp}}
102 | elixir-version: ${{matrix.elixir}}
103 | - name: Restore dependencies cache
104 | uses: actions/cache@v3
105 | with:
106 | path: deps
107 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
108 | restore-keys: ${{ runner.os }}-mix-
109 | - name: Install dependencies
110 | run: mix deps.get
111 | - name: Run tests
112 | run: mix test
113 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "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", "e5580029080f3f1ad17436fb97b0d5ed2ed4e4815a96bac36b5a992e20f58db6"},
3 | "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm", "1e1a3d176d52daebbecbbcdfd27c27726076567905c2a9d7398c54da9d225761"},
4 | "dialyxir": {:hex, :dialyxir, "1.3.0", "fd1672f0922b7648ff9ce7b1b26fcf0ef56dda964a459892ad15f6b4410b5284", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "00b2a4bcd6aa8db9dcb0b38c1225b7277dca9bc370b6438715667071a304696f"},
5 | "earmark": {:hex, :earmark, "1.3.0", "17f0c38eaafb4800f746b457313af4b2442a8c2405b49c645768680f900be603", [:mix], [], "hexpm", "f8b8820099caf0d5e72ae6482d2b0da96f213cbbe2b5b2191a37966e119eaa27"},
6 | "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"},
7 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
8 | "espec": {:hex, :espec, "1.6.3", "d9355788e508b82743a1b1b9aa5ac64ba37b0547c6210328d909e8a6eb56d42e", [:mix], [{:meck, "0.8.12", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "235ef9931fc6ae8066272b77dc11c462e72af0aa50c6023643acd22b09326d21"},
9 | "ex_doc": {:hex, :ex_doc, "0.22.2", "03a2a58bdd2ba0d83d004507c4ee113b9c521956938298eba16e55cc4aba4a6c", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "cf60e1b3e2efe317095b6bb79651f83a2c1b3edcb4d319c421d7fcda8b3aff26"},
10 | "gun": {:git, "https://github.com/PSPDFKit-labs/gun.git", "0462585ec7b0bcb2ca4b8b91e6d2624a45324b6e", []},
11 | "makeup": {:hex, :makeup, "1.0.3", "e339e2f766d12e7260e6672dd4047405963c5ec99661abdc432e6ec67d29ef95", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2e9b4996d11832947731f7608fed7ad2f9443011b3b479ae288011265cdd3dad"},
12 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"},
13 | "meck": {:hex, :meck, "0.8.12", "1f7b1a9f5d12c511848fec26bbefd09a21e1432eadb8982d9a8aceb9891a3cf2", [:rebar3], [], "hexpm", "7a6ab35a42e6c846636e8ecd6fdf2cc2e3f09dbee1abb15c1a7c705c10775787"},
14 | "mime": {:hex, :mime, "1.4.0", "5066f14944b470286146047d2f73518cf5cca82f8e4815cf35d196b58cf07c47", [:mix], [], "hexpm", "75fa42c4228ea9a23f70f123c74ba7cece6a03b1fd474fe13f6a7a85c6ea4ff6"},
15 | "mint": {:hex, :mint, "1.1.0", "1fd0189edd9e3ffdbd7fcd8bc3835902b987a63ec6c4fd1aa8c2a56e2165f252", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bfd316c3789340b682d5679a8116bcf2112e332447bdc20c1d62909ee45f48d"},
16 | "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"},
17 | "plug": {:hex, :plug, "1.10.4", "41eba7d1a2d671faaf531fa867645bd5a3dce0957d8e2a3f398ccff7d2ef017f", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad1e233fe73d2eec56616568d260777b67f53148a999dc2d048f4eb9778fe4a0"},
18 | "plug_cowboy": {:hex, :plug_cowboy, "2.1.3", "38999a3e85e39f0e6bdfdf820761abac61edde1632cfebbacc445cdcb6ae1333", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "056f41f814dbb38ea44613e0f613b3b2b2f2c6afce64126e252837669eba84db"},
19 | "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"},
20 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"},
21 | "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"},
22 | }
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Bypass
2 |
3 |
4 |
5 | [](https://github.com/PSPDFKit-labs/bypass/actions)
6 | [](https://hex.pm/packages/bypass)
7 | [](https://hexdocs.pm/bypass/)
8 | [](https://hex.pm/packages/bypass)
9 | [](https://github.com/PSPDFKit-labs/bypass/blob/master/LICENSE)
10 | [](https://github.com/PSPDFKit-labs/bypass/commits/master)
11 |
12 |
13 | `Bypass` provides a quick way to create a custom plug that can be put in place
14 | instead of an actual HTTP server to return prebaked responses to client
15 | requests. This is most useful in tests, when you want to create a mock HTTP
16 | server and test how your HTTP client handles different types of responses from
17 | the server.
18 |
19 | Bypass supports Elixir 1.10 and OTP 21 and up. It works with Cowboy 2.
20 |
21 | ## Usage
22 |
23 | To use Bypass in a test case, open a connection and use its port to connect your
24 | client to it.
25 |
26 | If you want to test what happens when the HTTP server goes down, use
27 | `Bypass.down/1` to close the TCP socket and `Bypass.up/1` to start listening on
28 | the same port again. Both functions block until the socket updates its state.
29 |
30 | ### Expect Functions
31 |
32 | You can take any of the following approaches:
33 |
34 | * `expect/2` or `expect_once/2` to install a generic function that all calls to
35 | bypass will use
36 | * `expect/4` and/or `expect_once/4` to install specific routes (method and path)
37 | * `stub/4` to install specific routes without expectations
38 | * a combination of the above, where the routes will be used first, and then the
39 | generic version will be used as default
40 |
41 | ### Example
42 |
43 | In the following example `TwitterClient.start_link()` takes the endpoint URL as
44 | its argument allowing us to make sure it will connect to the running instance of
45 | Bypass.
46 |
47 | ```elixir
48 | defmodule TwitterClientTest do
49 | use ExUnit.Case, async: true
50 |
51 | setup do
52 | bypass = Bypass.open()
53 | {:ok, bypass: bypass}
54 | end
55 |
56 | test "client can handle an error response", %{bypass: bypass} do
57 | Bypass.expect_once(bypass, "POST", "/1.1/statuses/update.json", fn conn ->
58 | Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
59 | end)
60 |
61 | {:ok, client} = TwitterClient.start_link(url: endpoint_url(bypass.port))
62 | assert {:error, :rate_limited} == TwitterClient.post_tweet(client, "Elixir is awesome!")
63 | end
64 |
65 | test "client can recover from server downtime", %{bypass: bypass} do
66 | Bypass.expect(bypass, fn conn ->
67 | # We don't care about `request_path` or `method` for this test.
68 | Plug.Conn.resp(conn, 200, "")
69 | end)
70 |
71 | {:ok, client} = TwitterClient.start_link(url: endpoint_url(bypass.port))
72 |
73 | assert :ok == TwitterClient.post_tweet(client, "Elixir is awesome!")
74 |
75 | # Blocks until the TCP socket is closed.
76 | Bypass.down(bypass)
77 |
78 | assert {:error, :noconnect} == TwitterClient.post_tweet(client, "Elixir is awesome!")
79 |
80 | Bypass.up(bypass)
81 |
82 | # When testing a real client that is using e.g. https://github.com/fishcakez/connection
83 | # with https://github.com/ferd/backoff to handle reconnecting, we'd have to loop for
84 | # a while until the client has reconnected.
85 |
86 | assert :ok == TwitterClient.post_tweet(client, "Elixir is awesome!")
87 | end
88 |
89 | defp endpoint_url(port), do: "http://localhost:#{port}/"
90 | end
91 | ```
92 |
93 | That's all you need to do. Bypass automatically sets up an `on_exit` hook to
94 | close its socket when the test finishes running.
95 |
96 | Multiple concurrent Bypass instances are supported, all will have a different
97 | unique port. Concurrent requests are also supported on the same instance.
98 |
99 | > Note: `Bypass.open/0` **must not** be called in a `setup_all` blocks due to
100 | > the way Bypass verifies the expectations at the end of each test.
101 |
102 | ## How to use with ESpec
103 |
104 | While Bypass primarily targets ExUnit, the official Elixir builtin test
105 | framework, it can also be used with [ESpec](https://hex.pm/packages/espec). The
106 | test configuration is basically the same, there are only two differences:
107 |
108 | 1. In your Mix config file, you must declare which test framework Bypass is
109 | being used with (defaults to `:ex_unit`). This simply disables the automatic
110 | integration with some hooks provided by `ExUnit`.
111 |
112 | ```elixir
113 | config :bypass, test_framework: :espec
114 | ```
115 |
116 | 2. In your specs, you must explicitly verify the declared expectations. You can
117 | do it in the `finally` block.
118 |
119 | ```elixir
120 | defmodule TwitterClientSpec do
121 | use ESpec, async: true
122 |
123 | before do
124 | bypass = Bypass.open()
125 | {:shared, bypass: bypass}
126 | end
127 |
128 | finally do
129 | Bypass.verify_expectations!(shared.bypass)
130 | end
131 |
132 | specify "the client can handle an error response" do
133 | Bypass.expect_once(shared.bypass, "POST", "/1.1/statuses/update.json", fn conn ->
134 | Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
135 | end)
136 |
137 | {:ok, client} = TwitterClient.start_link(url: endpoint_url(shared.bypass.port))
138 | assert {:error, :rate_limited} == TwitterClient.post_tweet(client, "Elixir is awesome!")
139 | end
140 |
141 | defp endpoint_url(port), do: "http://localhost:#{port}/"
142 | end
143 | ```
144 |
145 | ## Configuration options
146 |
147 | Set `:enable_debug_log` to `true` in the application environment to make Bypass
148 | log what it's doing:
149 |
150 | ```elixir
151 | config :bypass, enable_debug_log: true
152 | ```
153 |
154 |
155 |
156 | ## Installation
157 |
158 | Add `:bypass` to your list of dependencies in mix.exs:
159 |
160 | ```elixir
161 | def deps do
162 | [
163 | {:bypass, "~> 2.1", only: :test}
164 | ]
165 | end
166 | ```
167 |
168 | We do not recommended adding `:bypass` to the list of applications in your
169 | `mix.exs`.
170 |
171 | ## License
172 |
173 | This software is licensed under [the MIT license](LICENSE).
174 |
175 | ## About
176 |
177 |
178 |
179 |
180 |
181 | This project is maintained and funded by [Nutrient](https://nutrient.io/).
182 |
183 | Please ensure [you signed our
184 | CLA](https://www.nutrient.io/guides/web/miscellaneous/contributing/) so we
185 | can accept your contributions.
186 |
187 | See [our other open source projects](https://github.com/PSPDFKit-labs), read
188 | [our blog](https://nutrient.io/blog/) or say hello on X
189 | ([@nutrientdocs](https://x.com/nutrientdocs)).
190 |
--------------------------------------------------------------------------------
/lib/bypass.ex:
--------------------------------------------------------------------------------
1 | defmodule Bypass do
2 | @external_resource "README.md"
3 | @moduledoc "README.md"
4 | |> File.read!()
5 | |> String.split("")
6 | |> Enum.fetch!(1)
7 |
8 | defstruct pid: nil, port: nil
9 |
10 | @typedoc """
11 | Represents a Bypass server process.
12 | """
13 | @type t :: %__MODULE__{pid: pid, port: non_neg_integer}
14 |
15 | import Bypass.Utils
16 | require Logger
17 |
18 | @doc """
19 | Starts an Elixir process running a minimal Plug app. The process is a HTTP
20 | handler and listens to requests on a TCP port on localhost.
21 |
22 | Use the other functions in this module to declare which requests are handled
23 | and set expectations on the calls.
24 |
25 | ## Options
26 |
27 | - `port` - Optional TCP port to listen to requests.
28 |
29 | ## Examples
30 |
31 | ```elixir
32 | bypass = Bypass.open()
33 | ```
34 |
35 | Assign a specific port to a Bypass instance to listen on:
36 |
37 | ```elixir
38 | bypass = Bypass.open(port: 1234)
39 | ```
40 |
41 | """
42 | @spec open(Keyword.t()) :: Bypass.t()
43 | def open(opts \\ []) do
44 | pid = start_instance(opts)
45 | port = Bypass.Instance.call(pid, :port)
46 | debug_log("Did open connection #{inspect(pid)} on port #{inspect(port)}")
47 | bypass = %Bypass{pid: pid, port: port}
48 | setup_framework_integration(test_framework(), bypass)
49 | bypass
50 | end
51 |
52 | defp start_instance(opts) do
53 | case DynamicSupervisor.start_child(Bypass.Supervisor, Bypass.Instance.child_spec(opts)) do
54 | {:ok, pid} ->
55 | pid
56 |
57 | {:ok, pid, _info} ->
58 | pid
59 |
60 | {:error, reason} ->
61 | raise "Failed to start bypass instance.\n" <>
62 | "Reason: #{start_supervised_error(reason)}"
63 | end
64 | end
65 |
66 | defp start_supervised_error({{:EXIT, reason}, info}) when is_tuple(info),
67 | do: Exception.format_exit(reason)
68 |
69 | defp start_supervised_error({reason, info}) when is_tuple(info),
70 | do: Exception.format_exit(reason)
71 |
72 | defp start_supervised_error(reason), do: Exception.format_exit({:start_spec, reason})
73 |
74 | defp setup_framework_integration(:ex_unit, bypass = %{pid: pid}) do
75 | ExUnit.Callbacks.on_exit({Bypass, pid}, fn ->
76 | do_verify_expectations(bypass.pid, ExUnit.AssertionError)
77 | end)
78 | end
79 |
80 | defp setup_framework_integration(:espec, _bypass) do
81 | end
82 |
83 | @doc """
84 | Can be called to immediately verify if the declared request expectations have
85 | been met.
86 |
87 | Returns `:ok` on success and raises an error on failure.
88 | """
89 | @spec verify_expectations!(Bypass.t()) :: :ok | no_return()
90 | def verify_expectations!(bypass) do
91 | verify_expectations!(test_framework(), bypass)
92 | end
93 |
94 | defp verify_expectations!(:ex_unit, _bypass) do
95 | raise "Not available in ExUnit, as it's configured automatically."
96 | end
97 |
98 | if Code.ensure_loaded?(ESpec) do
99 | defp verify_expectations!(:espec, bypass) do
100 | do_verify_expectations(bypass.pid, ESpec.AssertionError)
101 | end
102 | end
103 |
104 | defp do_verify_expectations(bypass_pid, error_module) do
105 | case Bypass.Instance.call(bypass_pid, :on_exit) do
106 | :ok ->
107 | :ok
108 |
109 | :ok_call ->
110 | :ok
111 |
112 | {:error, :too_many_requests, {:any, :any}} ->
113 | raise error_module, "Expected only one HTTP request for Bypass"
114 |
115 | {:error, :too_many_requests, {method, path}} ->
116 | raise error_module, "Expected only one HTTP request for Bypass at #{method} #{path}"
117 |
118 | {:error, {:unexpected_request_number, expected, actual}, {:any, :any}} ->
119 | raise error_module, "Expected #{expected} HTTP request for Bypass, got #{actual}"
120 |
121 | {:error, {:unexpected_request_number, expected, actual}, {method, path}} ->
122 | raise error_module,
123 | "Expected #{expected} HTTP request for Bypass at #{method} #{path}, got #{actual}"
124 |
125 | {:error, :unexpected_request, {:any, :any}} ->
126 | raise error_module, "Bypass got an HTTP request but wasn't expecting one"
127 |
128 | {:error, :unexpected_request, {method, path}} ->
129 | raise error_module,
130 | "Bypass got an HTTP request but wasn't expecting one at #{method} #{path}"
131 |
132 | {:error, :not_called, {:any, :any}} ->
133 | raise error_module, "No HTTP request arrived at Bypass"
134 |
135 | {:error, :not_called, {method, path}} ->
136 | raise error_module,
137 | "No HTTP request arrived at Bypass at #{method} #{path}"
138 |
139 | {:exit, {class, reason, stacktrace}} ->
140 | :erlang.raise(class, reason, stacktrace)
141 | end
142 | end
143 |
144 | @doc """
145 | Re-opens the TCP socket on the same port. Blocks until the operation is
146 | complete.
147 |
148 | ```elixir
149 | Bypass.up(bypass)
150 | ```
151 | """
152 | @spec up(Bypass.t()) :: :ok | {:error, :already_up}
153 | def up(%Bypass{pid: pid}),
154 | do: Bypass.Instance.call(pid, :up)
155 |
156 | @doc """
157 | Closes the TCP socket. Blocks until the operation is complete.
158 |
159 | ```elixir
160 | Bypass.down(bypass)
161 | ```
162 | """
163 | @spec down(Bypass.t()) :: :ok | {:error, :already_down}
164 | def down(%Bypass{pid: pid}),
165 | do: Bypass.Instance.call(pid, :down)
166 |
167 | @doc """
168 | Expects the passed function to be called at least once regardless of the route.
169 |
170 | ```elixir
171 | Bypass.expect(bypass, fn conn ->
172 | assert "/1.1/statuses/update.json" == conn.request_path
173 | assert "POST" == conn.method
174 | Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
175 | end)
176 | ```
177 | """
178 | @spec expect(Bypass.t(), (Plug.Conn.t() -> Plug.Conn.t())) :: :ok
179 | def expect(%Bypass{pid: pid}, fun),
180 | do: Bypass.Instance.call(pid, {:expect, fun})
181 |
182 | @doc """
183 | Expects the passed function to be called exactly `n` times for any route.
184 |
185 | ```elixir
186 | Bypass.expect(bypass, 3, fn conn ->
187 | assert "/1.1/statuses/update.json" == conn.request_path
188 | assert "POST" == conn.method
189 | Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
190 | end)
191 | ```
192 | """
193 | @spec expect(Bypass.t(), pos_integer(), (Plug.Conn.t() -> Plug.Conn.t())) :: :ok
194 | def expect(%Bypass{pid: pid}, n, fun),
195 | do: Bypass.Instance.call(pid, {:expect, n, fun})
196 |
197 | @doc """
198 | Expects the passed function to be called at least once for the specified route (method and path).
199 |
200 | - `method` is one of `["GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS", "CONNECT"]`
201 |
202 | - `path` is the endpoint.
203 |
204 | ```elixir
205 | Bypass.expect(bypass, "POST", "/1.1/statuses/update.json", fn conn ->
206 | Agent.update(AgentModule, fn step_no -> step_no + 1 end)
207 | Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
208 | end)
209 | ```
210 | """
211 | @spec expect(Bypass.t(), String.t(), String.t(), (Plug.Conn.t() -> Plug.Conn.t())) :: :ok
212 | def expect(%Bypass{pid: pid}, method, path, fun),
213 | do: Bypass.Instance.call(pid, {:expect, method, path, fun})
214 |
215 | @doc """
216 | Expects the passed function to be called exactly `n` times for the specified route (method and path).
217 |
218 | - `method` is one of `["GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS", "CONNECT"]`
219 |
220 | - `path` is the endpoint.
221 |
222 | - `n` is the number of times the route is expected to be called.
223 |
224 | ```elixir
225 | Bypass.expect(bypass, "POST", "/1.1/statuses/update.json", 3, fn conn ->
226 | Agent.update(AgentModule, fn step_no -> step_no + 1 end)
227 | Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
228 | end)
229 | ```
230 | """
231 | @spec expect(Bypass.t(), String.t(), String.t(), pos_integer(), (Plug.Conn.t() -> Plug.Conn.t())) :: :ok
232 | def expect(%Bypass{pid: pid}, method, path, n, fun),
233 | do: Bypass.Instance.call(pid, {{:exactly, n}, method, path, fun})
234 |
235 | @doc """
236 | Expects the passed function to be called exactly once regardless of the route.
237 |
238 | ```elixir
239 | Bypass.expect_once(bypass, fn conn ->
240 | assert "/1.1/statuses/update.json" == conn.request_path
241 | assert "POST" == conn.method
242 | Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
243 | end)
244 | ```
245 | """
246 | @spec expect_once(Bypass.t(), (Plug.Conn.t() -> Plug.Conn.t())) :: :ok
247 | def expect_once(%Bypass{pid: pid}, fun),
248 | do: Bypass.Instance.call(pid, {:expect_once, fun})
249 |
250 | @doc """
251 | Expects the passed function to be called exactly once for the specified route (method and path).
252 |
253 | - `method` is one of `["GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS", "CONNECT"]`
254 |
255 | - `path` is the endpoint.
256 |
257 | ```elixir
258 | Bypass.expect_once(bypass, "POST", "/1.1/statuses/update.json", fn conn ->
259 | Agent.update(AgentModule, fn step_no -> step_no + 1 end)
260 | Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
261 | end)
262 | ```
263 | """
264 | @spec expect_once(Bypass.t(), String.t(), String.t(), (Plug.Conn.t() -> Plug.Conn.t())) :: :ok
265 | def expect_once(%Bypass{pid: pid}, method, path, fun),
266 | do: Bypass.Instance.call(pid, {:expect_once, method, path, fun})
267 |
268 | @doc """
269 | Allows the function to be invoked zero or many times for the specified route (method and path).
270 |
271 | - `method` is one of `["GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS", "CONNECT"]`
272 |
273 | - `path` is the endpoint.
274 |
275 | ```elixir
276 | Bypass.stub(bypass, "POST", "/1.1/statuses/update.json", fn conn ->
277 | Agent.update(AgentModule, fn step_no -> step_no + 1 end)
278 | Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
279 | end)
280 | ```
281 | """
282 | @spec stub(Bypass.t(), String.t(), String.t(), (Plug.Conn.t() -> Plug.Conn.t())) :: :ok
283 | def stub(%Bypass{pid: pid}, method, path, fun),
284 | do: Bypass.Instance.call(pid, {:stub, method, path, fun})
285 |
286 | @doc """
287 | Makes an expectation to pass.
288 |
289 | ```
290 | Bypass.expect(bypass, fn _conn ->
291 | Bypass.pass(bypass)
292 |
293 | assert false
294 | end)
295 | """
296 | @spec pass(Bypass.t()) :: :ok
297 | def pass(%Bypass{pid: pid}),
298 | do: Bypass.Instance.call(pid, :pass)
299 |
300 | defp test_framework do
301 | Application.get_env(:bypass, :test_framework, :ex_unit)
302 | end
303 | end
304 |
--------------------------------------------------------------------------------
/lib/bypass/instance.ex:
--------------------------------------------------------------------------------
1 | defmodule Bypass.Instance do
2 | @moduledoc false
3 |
4 | use GenServer, restart: :transient
5 |
6 | import Bypass.Utils
7 | import Plug.Router.Utils, only: [build_path_match: 1]
8 |
9 | def start_link(opts \\ []) do
10 | GenServer.start_link(__MODULE__, [opts])
11 | end
12 |
13 | def call(pid, request) do
14 | debug_log("call(#{inspect(pid)}, #{inspect(request)})")
15 | result = GenServer.call(pid, request, :infinity)
16 | debug_log("#{inspect(pid)} -> #{inspect(result)}")
17 | result
18 | end
19 |
20 | def cast(pid, request) do
21 | GenServer.cast(pid, request)
22 | end
23 |
24 | # GenServer callbacks
25 |
26 | def init([opts]) do
27 | # Get a free port from the OS
28 | case :ranch_tcp.listen(so_reuseport() ++ [ip: listen_ip(), port: Keyword.get(opts, :port, 0)]) do
29 | {:ok, socket} ->
30 | {:ok, port} = :inet.port(socket)
31 | :erlang.port_close(socket)
32 |
33 | ref = make_ref()
34 | socket = do_up(port, ref)
35 |
36 | state = %{
37 | expectations: %{},
38 | port: port,
39 | ref: ref,
40 | socket: socket,
41 | callers_awaiting_down: [],
42 | callers_awaiting_exit: [],
43 | pass: false,
44 | unknown_route_error: nil,
45 | monitors: %{}
46 | }
47 |
48 | {:ok, state}
49 |
50 | {:error, reason} ->
51 | {:stop, reason}
52 | end
53 | end
54 |
55 | def handle_info({:DOWN, ref, _, _, reason}, state) do
56 | case pop_in(state.monitors[ref]) do
57 | {nil, state} ->
58 | {:noreply, state}
59 |
60 | {route, state} ->
61 | result = {:exit, {:exit, reason, []}}
62 | {:noreply, route |> put_result(ref, result, state) |> dispatch_awaiting_callers()}
63 | end
64 | end
65 |
66 | def handle_cast({:put_expect_result, route, ref, result}, state) do
67 | {:noreply, route |> put_result(ref, result, state) |> dispatch_awaiting_callers()}
68 | end
69 |
70 | def handle_call(request, from, state) do
71 | debug_log([inspect(self()), " called ", inspect(request), " with state ", inspect(state)])
72 | do_handle_call(request, from, state)
73 | end
74 |
75 | defp do_handle_call(:port, _, %{port: port} = state) do
76 | {:reply, port, state}
77 | end
78 |
79 | defp do_handle_call(:up, _from, %{port: port, ref: ref, socket: nil} = state) do
80 | socket = do_up(port, ref)
81 | {:reply, :ok, %{state | socket: socket}}
82 | end
83 |
84 | defp do_handle_call(:up, _from, state) do
85 | {:reply, {:error, :already_up}, state}
86 | end
87 |
88 | defp do_handle_call(:down, _from, %{socket: nil} = state) do
89 | {:reply, {:error, :already_down}, state}
90 | end
91 |
92 | defp do_handle_call(
93 | :down,
94 | from,
95 | %{socket: socket, ref: ref, callers_awaiting_down: callers_awaiting_down} = state
96 | )
97 | when not is_nil(socket) do
98 | if retained_plugs_count(state) > 0 do
99 | # wait for plugs to finish
100 | {:noreply, %{state | callers_awaiting_down: [from | callers_awaiting_down]}}
101 | else
102 | do_down(ref, socket)
103 | {:reply, :ok, %{state | socket: nil}}
104 | end
105 | end
106 |
107 | defp do_handle_call({expect, fun}, from, state) when expect in [:expect, :expect_once] do
108 | do_handle_call({expect, :any, :any, fun}, from, state)
109 | end
110 |
111 | defp do_handle_call({:expect, n, fun}, from, state) do
112 | do_handle_call({{:exactly, n}, :any, :any, fun}, from, state)
113 | end
114 |
115 | defp do_handle_call(
116 | {expect, method, path, fun},
117 | _from,
118 | %{expectations: expectations} = state
119 | )
120 | when (expect in [:stub, :expect, :expect_once] or
121 | (is_tuple(expect) and elem(expect, 0) == :exactly)) and
122 | method in [
123 | "GET",
124 | "POST",
125 | "HEAD",
126 | "PUT",
127 | "PATCH",
128 | "DELETE",
129 | "OPTIONS",
130 | "CONNECT",
131 | :any
132 | ] and
133 | (is_binary(path) or path == :any) and
134 | is_function(fun, 1) do
135 | route = {method, path}
136 |
137 | updated_expectations =
138 | Map.put(
139 | expectations,
140 | route,
141 | new_route(
142 | fun,
143 | path,
144 | case expect do
145 | :expect -> :once_or_more
146 | :expect_once -> :once
147 | :stub -> :none_or_more
148 | {:exactly, n} -> {:exactly, n}
149 | end
150 | )
151 | )
152 |
153 | {:reply, :ok, %{state | expectations: updated_expectations}}
154 | end
155 |
156 | defp do_handle_call({expect, _, _, _}, _from, _state)
157 | when expect in [:expect, :expect_once] do
158 | raise "Route for #{expect} does not conform to specification"
159 | end
160 |
161 | defp do_handle_call({:get_route, method, path}, _from, state) do
162 | {route, _} = route_info(method, path, state)
163 | {:reply, route, state}
164 | end
165 |
166 | defp do_handle_call(:pass, _from, state) do
167 | updated_state =
168 | Enum.reduce(state.expectations, state, fn {route, route_expectations}, state_acc ->
169 | Enum.reduce(route_expectations.retained_plugs, state_acc, fn {ref, _}, plugs_acc ->
170 | put_result(route, ref, :ok, plugs_acc)
171 | end)
172 | end)
173 |
174 | {:reply, :ok, %{updated_state | pass: true}}
175 | end
176 |
177 | defp do_handle_call(
178 | {:get_expect_fun, route},
179 | from,
180 | %{expectations: expectations} = state
181 | ) do
182 | case Map.get(expectations, route) do
183 | %{expected: :once, request_count: count} when count > 0 ->
184 | {:reply, {:error, :too_many_requests, route}, increase_route_count(state, route)}
185 |
186 | %{expected: {:exactly, n}, request_count: count} when count >= n ->
187 | {:reply, {:error, {:unexpected_request_number, n, count + 1}, route},
188 | increase_route_count(state, route)}
189 |
190 | nil ->
191 | {:reply, {:error, :unexpected_request, route}, state}
192 |
193 | route_expectations ->
194 | state = increase_route_count(state, route)
195 | {ref, state} = retain_plug_process(route, from, state)
196 | {:reply, {:ok, ref, route_expectations.fun}, state}
197 | end
198 | end
199 |
200 | defp do_handle_call(:on_exit, from, %{callers_awaiting_exit: callers} = state) do
201 | if retained_plugs_count(state) > 0 do
202 | {:noreply, %{state | callers_awaiting_exit: [from | callers]}}
203 | else
204 | {result, updated_state} = do_exit(state)
205 | {:stop, :normal, result, updated_state}
206 | end
207 | end
208 |
209 | defp do_exit(state) do
210 | updated_state =
211 | case state do
212 | %{socket: nil} ->
213 | state
214 |
215 | %{socket: socket, ref: ref} ->
216 | do_down(ref, socket)
217 | %{state | socket: nil}
218 | end
219 |
220 | result =
221 | cond do
222 | state.pass ->
223 | :ok
224 |
225 | state.unknown_route_error ->
226 | state.unknown_route_error
227 |
228 | true ->
229 | case expectation_problem_message(state.expectations) do
230 | nil -> :ok
231 | error -> error
232 | end
233 | end
234 |
235 | {result, updated_state}
236 | end
237 |
238 | defp put_result(route, ref, result, state) do
239 | if state.expectations[route] do
240 | {_, state} = pop_in(state.monitors[ref])
241 |
242 | update_in(state.expectations[route], fn route_expectations ->
243 | plugs = route_expectations.retained_plugs
244 |
245 | Map.merge(route_expectations, %{
246 | retained_plugs: Map.delete(plugs, ref),
247 | results: [result | Map.fetch!(route_expectations, :results)]
248 | })
249 | end)
250 | else
251 | Map.put(state, :unknown_route_error, result)
252 | end
253 | end
254 |
255 | defp increase_route_count(state, route) do
256 | update_in(
257 | state.expectations[route],
258 | fn route_expectations -> Map.update(route_expectations, :request_count, 1, &(&1 + 1)) end
259 | )
260 | end
261 |
262 | defp expectation_problem_message(expectations) do
263 | problem_route =
264 | expectations
265 | |> Enum.reject(fn {_route, expectations} -> expectations[:expected] == :none_or_more end)
266 | |> Enum.find(fn {_route, expectations} -> problem_route?(expectations) end)
267 |
268 | case problem_route do
269 | {route, %{expected: {:exactly, expected}, request_count: actual}} ->
270 | {:error, {:unexpected_request_number, expected, actual}, route}
271 |
272 | {route, _} ->
273 | {:error, :not_called, route}
274 |
275 | nil ->
276 | Enum.reduce_while(expectations, nil, fn {_route, route_expectations}, _ ->
277 | first_error =
278 | Enum.find(route_expectations.results, fn
279 | result when is_tuple(result) -> result
280 | _result -> nil
281 | end)
282 |
283 | case first_error do
284 | nil -> {:cont, nil}
285 | error -> {:halt, error}
286 | end
287 | end)
288 | end
289 | end
290 |
291 | defp problem_route?(%{expected: {:exactly, n}} = expectations) do
292 | length(expectations.results) < n
293 | end
294 |
295 | defp problem_route?(expectations) do
296 | Enum.empty?(expectations.results)
297 | end
298 |
299 | defp route_info(method, path, %{expectations: expectations} = _state) do
300 | segments = build_path_match(path) |> elem(1)
301 |
302 | route =
303 | expectations
304 | |> Enum.reduce_while(
305 | {:any, :any, %{}},
306 | fn
307 | {{^method, path_pattern}, %{path_parts: path_parts}}, acc ->
308 | case match_route(segments, path_parts) do
309 | {true, params} -> {:halt, {method, path_pattern, params}}
310 | {false, _} -> {:cont, acc}
311 | end
312 |
313 | _, acc ->
314 | {:cont, acc}
315 | end
316 | )
317 |
318 | {route, Map.get(expectations, route)}
319 | end
320 |
321 | defp match_route(path, route) when length(path) == length(route) do
322 | path
323 | |> Enum.zip(route)
324 | |> Enum.reduce_while(
325 | {true, %{}},
326 | fn
327 | {value, {param, _, _}}, {_, params} ->
328 | {:cont, {true, Map.put(params, Atom.to_string(param), value)}}
329 |
330 | {segment, segment}, acc ->
331 | {:cont, acc}
332 |
333 | _, _ ->
334 | {:halt, {false, nil}}
335 | end
336 | )
337 | end
338 |
339 | defp match_route(_, _), do: {false, nil}
340 |
341 | defp do_up(port, ref) do
342 | plug_opts = [bypass_instance: self()]
343 | {:ok, socket} = :ranch_tcp.listen(so_reuseport() ++ [ip: listen_ip(), port: port])
344 | cowboy_opts = cowboy_opts(port, ref, socket)
345 | {:ok, _pid} = Plug.Cowboy.http(Bypass.Plug, plug_opts, cowboy_opts)
346 | socket
347 | end
348 |
349 | defp do_down(ref, socket) do
350 | :ok = Plug.Cowboy.shutdown(ref)
351 |
352 | # `port_close` is synchronous, so after it has returned we _know_ that the socket has been
353 | # closed. If we'd rely on ranch's supervisor shutting down the acceptor processes and thereby
354 | # killing the socket we would run into race conditions where the socket port hasn't yet gotten
355 | # the EXIT signal and would still be open, thereby breaking tests that rely on a closed socket.
356 | case :erlang.port_info(socket, :name) do
357 | :undefined -> :ok
358 | _ -> :erlang.port_close(socket)
359 | end
360 | end
361 |
362 | defp retain_plug_process({method, path} = route, {caller_pid, _}, state) do
363 | debug_log([
364 | inspect(self()),
365 | " retain_plug_process ",
366 | inspect(caller_pid),
367 | ", retained_plugs: ",
368 | inspect(
369 | Map.get(state.expectations, route)
370 | |> Map.get(:retained_plugs)
371 | |> Map.values()
372 | )
373 | ])
374 |
375 | ref = Process.monitor(caller_pid)
376 |
377 | state =
378 | update_in(state.expectations[route][:retained_plugs], fn plugs ->
379 | Map.update(plugs, ref, caller_pid, fn _ ->
380 | raise "plug already installed for #{method} #{path}"
381 | end)
382 | end)
383 |
384 | {ref, put_in(state.monitors[ref], route)}
385 | end
386 |
387 | defp dispatch_awaiting_callers(
388 | %{
389 | callers_awaiting_down: down_callers,
390 | callers_awaiting_exit: exit_callers,
391 | socket: socket,
392 | ref: ref
393 | } = state
394 | ) do
395 | if retained_plugs_count(state) == 0 do
396 | down_reset =
397 | if length(down_callers) > 0 do
398 | do_down(ref, socket)
399 | Enum.each(down_callers, &GenServer.reply(&1, :ok))
400 | %{state | socket: nil, callers_awaiting_down: []}
401 | end
402 |
403 | if length(exit_callers) > 0 do
404 | {result, _updated_state} = do_exit(state)
405 | Enum.each(exit_callers, &GenServer.reply(&1, result))
406 | GenServer.stop(:normal)
407 | end
408 |
409 | down_reset || state
410 | else
411 | state
412 | end
413 | end
414 |
415 | defp retained_plugs_count(state) do
416 | state.expectations
417 | |> Map.values()
418 | |> Enum.flat_map(&Map.get(&1, :retained_plugs))
419 | |> length
420 | end
421 |
422 | defp new_route(fun, path_parts, expected) when is_list(path_parts) do
423 | %{
424 | fun: fun,
425 | expected: expected,
426 | path_parts: path_parts,
427 | retained_plugs: %{},
428 | results: [],
429 | request_count: 0
430 | }
431 | end
432 |
433 | defp new_route(fun, :any, expected) do
434 | new_route(fun, [], expected)
435 | end
436 |
437 | defp new_route(fun, path, expected) do
438 | new_route(fun, build_path_match(path) |> elem(1), expected)
439 | end
440 |
441 | defp cowboy_opts(port, ref, socket) do
442 | [ref: ref, port: port, transport_options: [num_acceptors: 5, socket: socket]]
443 | end
444 |
445 | # Use raw socket options to set SO_REUSEPORT so we fix {:error, :eaddrinuse} - where the OS errors
446 | # when we attempt to listen on the same port as before, since it's still considered in use.
447 | #
448 | # See https://lwn.net/Articles/542629/ for details on SO_REUSEPORT.
449 | #
450 | # See https://github.com/aetrion/erl-dns/blob/0c8d768/src/erldns_server_sup.erl#L81 for an
451 | # Erlang library using this approach.
452 | #
453 | # We want to do this:
454 | #
455 | # int optval = 1;
456 | # setsockopt(sfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));
457 | #
458 | # Use the following C program to find the values on each OS:
459 | #
460 | # #include
461 | # #include
462 | #
463 | # int main() {
464 | # printf("SOL_SOCKET: %d\n", SOL_SOCKET);
465 | # printf("SO_REUSEPORT: %d\n", SO_REUSEPORT);
466 | # return 0;
467 | # }
468 | defp so_reuseport() do
469 | case :os.type() do
470 | {:unix, :linux} -> [{:raw, 1, 15, <<1::32-native>>}]
471 | {:unix, :darwin} -> [{:raw, 65_535, 512, <<1::32-native>>}]
472 | _ -> []
473 | end
474 | end
475 |
476 | # This is used to override the default behaviour of ranch_tcp
477 | # and limit the range of interfaces it will listen on to just
478 | # the configured interface. Loopback is a default interface.
479 | defp listen_ip do
480 | case Application.get_env(:bypass, :listen_ip, {127, 0, 0, 1}) do
481 | listen_ip when is_tuple(listen_ip) ->
482 | listen_ip
483 |
484 | listen_ip ->
485 | listen_ip
486 | |> to_charlist()
487 | |> :inet.parse_address()
488 | |> case do
489 | {:ok, listen_ip} ->
490 | listen_ip
491 |
492 | {:error, :einval} ->
493 | raise ArgumentError, "invalid listen_ip: #{inspect(listen_ip)}"
494 | end
495 | end
496 | end
497 | end
498 |
--------------------------------------------------------------------------------
/test/bypass_test.exs:
--------------------------------------------------------------------------------
1 | defmodule BypassTest do
2 | use ExUnit.Case
3 | doctest Bypass
4 |
5 | defdelegate capture_log(fun), to: ExUnit.CaptureLog
6 |
7 | test "show ISSUE #51" do
8 | Enum.each(
9 | 1..1000,
10 | fn _ ->
11 | bypass = %Bypass{} = Bypass.open(port: 8000)
12 |
13 | Bypass.down(bypass)
14 | end
15 | )
16 | end
17 |
18 | test "Bypass.open can specify a port to operate on with expect" do
19 | 1234 |> specify_port(:expect)
20 | end
21 |
22 | test "Bypass.open can specify a port to operate on with expect_once" do
23 | 1235 |> specify_port(:expect_once)
24 | end
25 |
26 | defp specify_port(port, expect_fun) do
27 | bypass = Bypass.open(port: port)
28 |
29 | apply(Bypass, expect_fun, [
30 | bypass,
31 | fn conn ->
32 | assert port == conn.port
33 | Plug.Conn.send_resp(conn, 200, "")
34 | end
35 | ])
36 |
37 | assert {:ok, 200, ""} = request(port)
38 | bypass2 = Bypass.open(port: port)
39 | assert(is_map(bypass2) and bypass2.__struct__ == Bypass)
40 | end
41 |
42 | test "Bypass.down takes down the socket with expect" do
43 | :expect |> down_socket
44 | end
45 |
46 | test "Bypass.down takes down the socket with expect_once" do
47 | :expect_once |> down_socket
48 | end
49 |
50 | defp down_socket(expect_fun) do
51 | bypass = Bypass.open()
52 |
53 | apply(Bypass, expect_fun, [
54 | bypass,
55 | fn conn -> Plug.Conn.send_resp(conn, 200, "") end
56 | ])
57 |
58 | assert {:ok, 200, ""} = request(bypass.port)
59 |
60 | Bypass.down(bypass)
61 | assert {:error, %Mint.TransportError{reason: :econnrefused}} = request(bypass.port)
62 | end
63 |
64 | test "Bypass.up opens the socket again" do
65 | bypass = Bypass.open()
66 |
67 | Bypass.expect(bypass, fn conn ->
68 | Plug.Conn.send_resp(conn, 200, "")
69 | end)
70 |
71 | assert {:ok, 200, ""} = request(bypass.port)
72 |
73 | Bypass.down(bypass)
74 | assert {:error, %Mint.TransportError{reason: :econnrefused}} = request(bypass.port)
75 |
76 | Bypass.up(bypass)
77 | assert {:ok, 200, ""} = request(bypass.port)
78 | end
79 |
80 | test "Bypass.expect raises if no request is made" do
81 | :expect |> not_called
82 | end
83 |
84 | test "Bypass.expect_once raises if no request is made" do
85 | :expect_once |> not_called
86 | end
87 |
88 | defp not_called(expect_fun) do
89 | bypass = Bypass.open()
90 |
91 | apply(Bypass, expect_fun, [
92 | bypass,
93 | fn _conn -> assert false end
94 | ])
95 |
96 | # Override Bypass' on_exit handler.
97 | ExUnit.Callbacks.on_exit({Bypass, bypass.pid}, fn ->
98 | exit_result = Bypass.Instance.call(bypass.pid, :on_exit)
99 | assert {:error, :not_called, {:any, :any}} = exit_result
100 | end)
101 | end
102 |
103 | test "Bypass.expect can be made to pass by calling Bypass.pass" do
104 | :expect |> pass
105 | end
106 |
107 | test "Bypass.expect_once can be made to pass by calling Bypass.pass" do
108 | :expect_once |> pass
109 | end
110 |
111 | defp pass(expect_fun) do
112 | bypass = Bypass.open()
113 |
114 | apply(Bypass, expect_fun, [
115 | bypass,
116 | fn _conn ->
117 | Bypass.pass(bypass)
118 | Process.exit(self(), :shutdown)
119 | end
120 | ])
121 |
122 | capture_log(fn ->
123 | assert {:error, _conn, %Mint.TransportError{reason: :timeout}, _responses} =
124 | request(bypass.port)
125 | end)
126 | end
127 |
128 | test "closing a bypass while the request is in-flight with expect" do
129 | :expect |> closing_in_flight
130 | end
131 |
132 | test "closing a bypass while the request is in-flight with expect_once" do
133 | :expect_once |> closing_in_flight
134 | end
135 |
136 | defp closing_in_flight(expect_fun) do
137 | bypass = Bypass.open()
138 |
139 | apply(Bypass, expect_fun, [
140 | bypass,
141 | fn _conn ->
142 | # Mark the request as arrived, since we're shutting it down now.
143 | Bypass.pass(bypass)
144 | Bypass.down(bypass)
145 | end
146 | ])
147 |
148 | assert {:error, _conn, %Mint.TransportError{reason: :closed}, _responses} =
149 | request(bypass.port)
150 | end
151 |
152 | test "Bypass.down waits for plug process to terminate before shutting it down with expect" do
153 | :expect |> down_wait_to_terminate
154 | end
155 |
156 | test "Bypass.down waits for plug process to terminate before shutting it down with expect_once" do
157 | :expect_once |> down_wait_to_terminate
158 | end
159 |
160 | defp down_wait_to_terminate(expect_fun) do
161 | test_process = self()
162 | ref = make_ref()
163 | bypass = Bypass.open()
164 |
165 | apply(Bypass, expect_fun, [
166 | bypass,
167 | fn conn ->
168 | Process.flag(:trap_exit, true)
169 | result = Plug.Conn.send_resp(conn, 200, "")
170 | Process.sleep(200)
171 | send(test_process, ref)
172 | result
173 | end
174 | ])
175 |
176 | assert {:ok, 200, ""} = request(bypass.port)
177 |
178 | # Here we make sure that Bypass.down waits until the plug process finishes
179 | # its work before shutting down.
180 | refute_received ^ref
181 | Bypass.down(bypass)
182 | assert_received ^ref
183 | end
184 |
185 | test "Concurrent calls to down" do
186 | test_process = self()
187 | ref = make_ref()
188 | bypass = Bypass.open()
189 |
190 | Bypass.expect(
191 | bypass,
192 | "POST",
193 | "/this",
194 | fn conn ->
195 | Process.sleep(100)
196 | Plug.Conn.send_resp(conn, 200, "")
197 | end
198 | )
199 |
200 | Bypass.expect(
201 | bypass,
202 | "POST",
203 | "/that",
204 | fn conn ->
205 | Process.sleep(100)
206 | result = Plug.Conn.send_resp(conn, 200, "")
207 | send(test_process, ref)
208 | result
209 | end
210 | )
211 |
212 | assert {:ok, 200, ""} = request(bypass.port, "/this")
213 |
214 | tasks =
215 | Enum.map(1..5, fn _ ->
216 | Task.async(fn ->
217 | assert {:ok, 200, ""} = request(bypass.port, "/that")
218 | Bypass.down(bypass)
219 | end)
220 | end)
221 |
222 | # Here we make sure that Bypass.down waits until the plug process finishes
223 | # its work before shutting down.
224 | refute_received ^ref
225 | Process.sleep(200)
226 | Bypass.down(bypass)
227 |
228 | Enum.map(tasks, fn task ->
229 | Task.await(task)
230 | assert_received ^ref
231 | end)
232 | end
233 |
234 | @tag :wip
235 | test "Calling a bypass route without expecting a call fails the test" do
236 | bypass = Bypass.open()
237 |
238 | capture_log(fn ->
239 | assert {:ok, 500, ""} = request(bypass.port)
240 | end)
241 |
242 | # Override Bypass' on_exit handler.
243 | ExUnit.Callbacks.on_exit({Bypass, bypass.pid}, fn ->
244 | exit_result = Bypass.Instance.call(bypass.pid, :on_exit)
245 | assert {:error, :unexpected_request, {:any, :any}} = exit_result
246 | end)
247 | end
248 |
249 | test "Bypass can handle concurrent requests with expect" do
250 | bypass = Bypass.open()
251 | parent = self()
252 |
253 | Bypass.expect(bypass, fn conn ->
254 | send(parent, :request_received)
255 | Plug.Conn.send_resp(conn, 200, "")
256 | end)
257 |
258 | tasks =
259 | Enum.map(1..5, fn _ ->
260 | Task.async(fn -> {:ok, 200, ""} = request(bypass.port) end)
261 | end)
262 |
263 | Enum.map(tasks, fn task ->
264 | Task.await(task)
265 | assert_receive :request_received
266 | end)
267 |
268 | # Override Bypass' on_exit handler.
269 | ExUnit.Callbacks.on_exit({Bypass, bypass.pid}, fn ->
270 | :ok == Bypass.Instance.call(bypass.pid, :on_exit)
271 | end)
272 | end
273 |
274 | test "Bypass can handle concurrent requests with expect_once" do
275 | bypass = Bypass.open()
276 | parent = self()
277 |
278 | Bypass.expect_once(bypass, fn conn ->
279 | send(parent, :request_received)
280 | Plug.Conn.send_resp(conn, 200, "")
281 | end)
282 |
283 | Enum.map(1..5, fn _ -> Task.async(fn -> request(bypass.port) end) end)
284 | |> Enum.map(fn task -> Task.await(task) end)
285 |
286 | assert_receive :request_received
287 | refute_receive :request_received
288 |
289 | # Override Bypass' on_exit handler.
290 | ExUnit.Callbacks.on_exit({Bypass, bypass.pid}, fn ->
291 | exit_result = Bypass.Instance.call(bypass.pid, :on_exit)
292 | assert {:error, :too_many_requests, {:any, :any}} = exit_result
293 | end)
294 | end
295 |
296 | for {expected, actual, alt} <- [{3, 5, "too many"}, {5, 3, "not enough"}] do
297 | @tag expected: expected, actual: actual
298 | test "Bypass.expect/3 fails when #{alt} requests arrived", %{
299 | expected: expected,
300 | actual: actual
301 | } do
302 | bypass = Bypass.open()
303 | parent = self()
304 |
305 | Bypass.expect(bypass, expected, fn conn ->
306 | send(parent, :request_received)
307 | Plug.Conn.send_resp(conn, 200, "")
308 | end)
309 |
310 | Enum.map(1..actual, fn _ -> Task.async(fn -> request(bypass.port) end) end)
311 | |> Task.await_many()
312 |
313 | Enum.each(1..min(actual, expected), fn _ -> assert_receive :request_received end)
314 | refute_receive :request_received
315 |
316 | # Override Bypass' on_exit handler.
317 | ExUnit.Callbacks.on_exit({Bypass, bypass.pid}, fn ->
318 | exit_result = Bypass.Instance.call(bypass.pid, :on_exit)
319 | assert {:error, {:unexpected_request_number, expected, actual}, _} = exit_result
320 | assert expected != actual
321 | end)
322 | end
323 |
324 | @tag expected: expected, actual: actual
325 | test "Bypass.expect/5 fails when #{alt} requests arrived", %{
326 | expected: expected,
327 | actual: actual
328 | } do
329 | bypass = Bypass.open()
330 | parent = self()
331 |
332 | Bypass.expect(bypass, "GET", "/foo", expected, fn conn ->
333 | send(parent, :request_received)
334 | Plug.Conn.send_resp(conn, 200, "")
335 | end)
336 |
337 | Enum.map(1..actual, fn _ -> Task.async(fn -> request(bypass.port, "/foo", "GET") end) end)
338 | |> Task.await_many()
339 |
340 | Enum.each(1..min(actual, expected), fn _ -> assert_receive :request_received end)
341 | refute_receive :request_received
342 |
343 | # Override Bypass' on_exit handler.
344 | ExUnit.Callbacks.on_exit({Bypass, bypass.pid}, fn ->
345 | exit_result = Bypass.Instance.call(bypass.pid, :on_exit)
346 | assert {:error, {:unexpected_request_number, expected, actual}, _} = exit_result
347 | assert expected != actual
348 | end)
349 | end
350 | end
351 |
352 | test "Bypass.stub/4 does not raise if request is made" do
353 | :stub |> specific_route
354 | end
355 |
356 | test "Bypass.stub/4 does not raise if request is not made" do
357 | :stub |> set_expectation("/stub_path")
358 | end
359 |
360 | test "Bypass.expect/4 can be used to define a specific route" do
361 | :expect |> specific_route
362 | end
363 |
364 | test "Bypass.expect_once/4 can be used to define a specific route" do
365 | :expect_once |> specific_route
366 | end
367 |
368 | defp set_expectation(action, path) do
369 | bypass = Bypass.open()
370 | method = "POST"
371 |
372 | apply(Bypass, action, [
373 | bypass,
374 | method,
375 | path,
376 | fn conn ->
377 | assert conn.method == method
378 | assert conn.request_path == path
379 | Plug.Conn.send_resp(conn, 200, "")
380 | end
381 | ])
382 | end
383 |
384 | defp specific_route(expect_fun) do
385 | bypass = Bypass.open()
386 | method = "POST"
387 | path = "/this"
388 |
389 | apply(Bypass, expect_fun, [
390 | bypass,
391 | method,
392 | path,
393 | fn conn ->
394 | assert conn.method == method
395 | assert conn.request_path == path
396 | Plug.Conn.send_resp(conn, 200, "")
397 | end
398 | ])
399 |
400 | capture_log(fn ->
401 | assert {:ok, 200, ""} = request(bypass.port, path)
402 | end)
403 | end
404 |
405 | test "Bypass.stub/4 does not raise if request with parameters is made" do
406 | :stub |> specific_route_with_params
407 | end
408 |
409 | test "Bypass.expect/4 can be used to define a specific route with parameters" do
410 | :expect |> specific_route_with_params
411 | end
412 |
413 | test "Bypass.expect_once/4 can be used to define a specific route with parameters" do
414 | :expect_once |> specific_route_with_params
415 | end
416 |
417 | defp specific_route_with_params(expect_fun) do
418 | bypass = Bypass.open()
419 | method = "POST"
420 | pattern = "/this/:resource/get/:id"
421 | path = "/this/my_resource/get/1234"
422 |
423 | apply(Bypass, expect_fun, [
424 | bypass,
425 | method,
426 | pattern,
427 | fn conn ->
428 | assert conn.method == method
429 | assert conn.request_path == path
430 |
431 | assert conn.params == %{
432 | "resource" => "my_resource",
433 | "id" => "1234",
434 | "q_param_1" => "a",
435 | "q_param_2" => "b"
436 | }
437 |
438 | Plug.Conn.send_resp(conn, 200, "")
439 | end
440 | ])
441 |
442 | capture_log(fn ->
443 | assert {:ok, 200, ""} = request(bypass.port, path <> "?q_param_1=a&q_param_2=b")
444 | end)
445 | end
446 |
447 | test "All routes to a Bypass.expect/4 call must be called" do
448 | :expect |> all_routes_must_be_called
449 | end
450 |
451 | test "All routes to a Bypass.expect_once/4 call must be called" do
452 | :expect_once |> all_routes_must_be_called
453 | end
454 |
455 | defp all_routes_must_be_called(expect_fun) do
456 | bypass = Bypass.open()
457 | method = "POST"
458 | paths = ["/this", "/that"]
459 |
460 | Enum.each(paths, fn path ->
461 | apply(Bypass, expect_fun, [
462 | bypass,
463 | method,
464 | path,
465 | fn conn ->
466 | assert conn.method == method
467 | assert Enum.any?(paths, fn path -> conn.request_path == path end)
468 | Plug.Conn.send_resp(conn, 200, "")
469 | end
470 | ])
471 | end)
472 |
473 | capture_log(fn ->
474 | assert {:ok, 200, ""} = request(bypass.port, "/this")
475 | end)
476 |
477 | # Override Bypass' on_exit handler
478 | ExUnit.Callbacks.on_exit({Bypass, bypass.pid}, fn ->
479 | exit_result = Bypass.Instance.call(bypass.pid, :on_exit)
480 | assert {:error, :not_called, {"POST", "/that"}} = exit_result
481 | end)
482 | end
483 |
484 | @doc ~S"""
485 | Open a new HTTP connection and perform the request. We don't want to use httpc, hackney or another
486 | "high-level" HTTP client, since they do connection pooling and we will sometimes get a connection
487 | closed error and not a failed to connect error, when we test Bypass.down.
488 | """
489 | def request(port, path \\ "/example_path", method \\ "POST") do
490 | with {:ok, conn} <- Mint.HTTP.connect(:http, "127.0.0.1", port, mode: :passive),
491 | {:ok, conn, ref} <- Mint.HTTP.request(conn, method, path, [], "") do
492 | receive_responses(conn, ref, 100, [])
493 | end
494 | end
495 |
496 | defp receive_responses(conn, ref, status, body) do
497 | with {:ok, conn, responses} <- Mint.HTTP.recv(conn, 0, 200) do
498 | receive_responses(responses, conn, ref, status, body)
499 | end
500 | end
501 |
502 | defp receive_responses([], conn, ref, status, body) do
503 | receive_responses(conn, ref, status, body)
504 | end
505 |
506 | defp receive_responses([response | responses], conn, ref, status, body) do
507 | case response do
508 | {:status, ^ref, status} ->
509 | receive_responses(responses, conn, ref, status, body)
510 |
511 | {:headers, ^ref, _headers} ->
512 | receive_responses(responses, conn, ref, status, body)
513 |
514 | {:data, ^ref, data} ->
515 | receive_responses(responses, conn, ref, status, [data | body])
516 |
517 | {:done, ^ref} ->
518 | _ = Mint.HTTP.close(conn)
519 | {:ok, status, body |> Enum.reverse() |> IO.iodata_to_binary()}
520 |
521 | {:error, ^ref, _reason} = error ->
522 | error
523 | end
524 | end
525 |
526 | test "Bypass.expect/4 can be used to define a specific route and then redefine it later" do
527 | :expect |> specific_route_redefined
528 | end
529 |
530 | test "Bypass.expect_once/4 can be used to define a specific route and then redefine it later" do
531 | :expect_once |> specific_route_redefined
532 | end
533 |
534 | defp specific_route_redefined(expect_fun) do
535 | bypass = Bypass.open()
536 | method = "POST"
537 | path = "/this"
538 |
539 | apply(Bypass, expect_fun, [
540 | bypass,
541 | method,
542 | path,
543 | fn conn ->
544 | assert conn.method == method
545 | assert conn.request_path == path
546 | Plug.Conn.send_resp(conn, 200, "")
547 | end
548 | ])
549 |
550 | capture_log(fn ->
551 | assert {:ok, 200, ""} = request(bypass.port, path)
552 | end)
553 |
554 | # Redefine the expect
555 | apply(Bypass, expect_fun, [
556 | bypass,
557 | method,
558 | path,
559 | fn conn ->
560 | assert conn.method == method
561 | assert conn.request_path == path
562 | Plug.Conn.send_resp(conn, 200, "other response")
563 | end
564 | ])
565 |
566 | capture_log(fn ->
567 | assert {:ok, 200, "other response"} = request(bypass.port, path)
568 | end)
569 | end
570 |
571 | defp prepare_stubs do
572 | bypass = Bypass.open()
573 |
574 | Bypass.expect_once(bypass, fn conn ->
575 | Plug.Conn.send_resp(conn, 200, "")
576 | end)
577 |
578 | Bypass.expect_once(bypass, "GET", "/foo", fn conn ->
579 | Plug.Conn.send_resp(conn, 200, "")
580 | end)
581 |
582 | bypass
583 | end
584 |
585 | test "Bypass.verify_expectations! - with ExUnit it will raise an exception" do
586 | bypass = Bypass.open()
587 |
588 | Bypass.expect_once(bypass, fn conn ->
589 | Plug.Conn.send_resp(conn, 200, "")
590 | end)
591 |
592 | assert {:ok, 200, ""} = request(bypass.port)
593 |
594 | assert_raise RuntimeError, "Not available in ExUnit, as it's configured automatically.", fn ->
595 | Bypass.verify_expectations!(bypass)
596 | end
597 | end
598 |
599 | test "Bypass.verify_expectations! - with ESpec it will check if the expectations are being met" do
600 | Application.put_all_env(bypass: [test_framework: :espec])
601 |
602 | # Fail: no requests
603 | bypass = prepare_stubs()
604 |
605 | assert_raise ESpec.AssertionError, "No HTTP request arrived at Bypass", fn ->
606 | Bypass.verify_expectations!(bypass)
607 | end
608 |
609 | # Success
610 | bypass = prepare_stubs()
611 | assert {:ok, 200, ""} = request(bypass.port)
612 | assert {:ok, 200, ""} = request(bypass.port, "/foo", "GET")
613 | assert :ok = Bypass.verify_expectations!(bypass)
614 |
615 | # Fail: no requests on a single stub
616 | bypass = prepare_stubs()
617 | assert {:ok, 200, ""} = request(bypass.port)
618 |
619 | assert_raise ESpec.AssertionError, "No HTTP request arrived at Bypass at GET /foo", fn ->
620 | Bypass.verify_expectations!(bypass)
621 | end
622 |
623 | # Fail: too many requests
624 | bypass = prepare_stubs()
625 | assert {:ok, 200, ""} = request(bypass.port)
626 |
627 | Task.start(fn ->
628 | assert {:ok, 200, ""} = request(bypass.port)
629 | end)
630 |
631 | assert {:ok, 200, ""} = request(bypass.port, "/foo", "GET")
632 | :timer.sleep(10)
633 |
634 | assert_raise ESpec.AssertionError, "Expected only one HTTP request for Bypass", fn ->
635 | Bypass.verify_expectations!(bypass)
636 | end
637 |
638 | Application.put_all_env(bypass: [test_framework: :ex_unit])
639 | end
640 |
641 | test "Bypass.open/1 raises when cannot start child" do
642 | assert_raise RuntimeError, ~r/Failed to start bypass instance/, fn ->
643 | Bypass.open(:error)
644 | end
645 | end
646 | end
647 |
--------------------------------------------------------------------------------