├── 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 | [![Build Status](https://github.com/PSPDFKit-labs/bypass/actions/workflows/elixir.yml/badge.svg?branch=master)](https://github.com/PSPDFKit-labs/bypass/actions) 6 | [![Module Version](https://img.shields.io/hexpm/v/bypass.svg)](https://hex.pm/packages/bypass) 7 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/bypass/) 8 | [![Total Download](https://img.shields.io/hexpm/dt/bypass.svg)](https://hex.pm/packages/bypass) 9 | [![License](https://img.shields.io/hexpm/l/bypass.svg)](https://github.com/PSPDFKit-labs/bypass/blob/master/LICENSE) 10 | [![Last Updated](https://img.shields.io/github/last-commit/PSPDFKit-labs/bypass.svg)](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 | --------------------------------------------------------------------------------