1defmodule Phoenix.Ecto.SQL.Sandbox do 2 @moduledoc """ 3 A plug to allow concurrent, transactional acceptance tests with Ecto.Adapters.SQL.Sandbox. 4 5 ## Example 6 7 This plug should only be used during tests. First, set a flag to 8 enable it in `config/test.exs`: 9 10 config :your_app, sql_sandbox: true 11 12 And use the flag to conditionally add the plug to `lib/your_app/endpoint.ex`: 13 14 if Application.get_env(:your_app, :sql_sandbox) do 15 plug Phoenix.Ecto.SQL.Sandbox 16 end 17 18 It's important that this is at the top of `endpoint.ex`, before any other plugs. 19 20 Then, within an acceptance test, checkout a sandboxed connection as before. 21 Use `metadata_for/2` helper to get the session metadata to that will allow access 22 to the test's connection. 23 Here's an example using [Hound](https://hex.pm/packages/hound): 24 25 use Hound.Helpers 26 27 setup do 28 :ok = Ecto.Adapters.SQL.Sandbox.checkout(YourApp.Repo) 29 metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(YourApp.Repo, self()) 30 Hound.start_session(metadata: metadata) 31 end 32 33 ## Concurrent end-to-end tests with external clients 34 35 Concurrent and transactional tests for external HTTP clients is supported, 36 allowing for complete end-to-end tests. This is useful for cases such as 37 JavaScript test suites for single page applications that exercise the 38 Phoenix endpoint for end-to-end test setup and teardown. To enable this, 39 you can expose a sandbox route on the `Phoenix.Ecto.SQL.Sandbox` plug by 40 providing the `:at`, and `:repo` options. For example: 41 42 plug Phoenix.Ecto.SQL.Sandbox, 43 at: "/sandbox", 44 repo: MyApp.Repo 45 46 This would expose a route at `"/sandbox"` for the given repo where 47 external clients send POST requests to spawn a new sandbox session, 48 and DELETE requests to stop an active sandbox session. By default, 49 the external client is expected to pass up the `"user-agent" header 50 containing serialized sandbox metadata returned from the POST request, 51 but this value may customized with the `:header` option. 52 """ 53 54 import Plug.Conn 55 alias Plug.Conn 56 alias Phoenix.Ecto.SQL.SandboxSupervisor 57 58 @doc """ 59 Spawns a sandbox session to checkout a connection for a remote client. 60 61 ## Examples 62 63 iex> {:ok, _owner_pid, metdata} = start_child(MyApp.Repo) 64 """ 65 def start_child(repo, opts \\ []) do 66 case Supervisor.start_child(SandboxSupervisor, [repo, self(), opts]) do 67 {:ok, owner} -> 68 metadata = metadata_for(repo, owner) 69 {:ok, owner, metadata} 70 71 {:error, reason} -> 72 {:error, reason} 73 end 74 end 75 76 @doc """ 77 Stops a sandbox session holding a connection for a remote client. 78 79 ## Examples 80 81 iex> {:ok, owner_pid, metadata} = start_child(MyApp.Repo) 82 iex> :ok = stop(owner_pid) 83 """ 84 def stop(owner) when is_pid(owner) do 85 GenServer.call(owner, :checkin) 86 end 87 88 def init(opts \\ []) do 89 %{ 90 sandbox: Keyword.get(opts, :sandbox, Ecto.Adapters.SQL.Sandbox), 91 header: Keyword.get(opts, :header, "user-agent"), 92 path: get_path_info(opts[:at]), 93 repo: opts[:repo] 94 } 95 end 96 defp get_path_info(nil), do: nil 97 defp get_path_info(path), do: Plug.Router.Utils.split(path) 98 99 def call(%Conn{method: "POST", path_info: path} = conn, %{path: path} = opts) do 100 {:ok, _owner, metadata} = start_child(opts.repo, sandbox: opts.sandbox) 101 102 conn 103 |> put_resp_content_type("text/plain") 104 |> send_resp(200, encode_metadata(metadata)) 105 |> halt() 106 end 107 def call(%Conn{method: "DELETE", path_info: path} = conn, %{path: path} = opts) do 108 case extract_metadata(conn, opts.header) do 109 %{owner: owner} -> 110 :ok = stop(owner) 111 112 conn 113 |> put_resp_content_type("text/plain") 114 |> send_resp(200, "") 115 |> halt() 116 117 %{} -> 118 conn 119 |> send_resp(410, "") 120 |> halt() 121 end 122 end 123 124 def call(conn, %{header: header, sandbox: sandbox}) do 125 _result = 126 conn 127 |> extract_metadata(header) 128 |> allow_sandbox_access(sandbox) 129 130 conn 131 end 132 133 defp extract_metadata(%Conn{} = conn, header) do 134 conn 135 |> get_req_header(header) 136 |> List.first() 137 |> decode_metadata() 138 end 139 140 @doc """ 141 Returns metadata to associate with the session 142 to allow the endpoint to acces the database connection checked 143 out by the test process. 144 """ 145 @spec metadata_for(Ecto.Repo.t | [Ecto.Repo.t], pid) :: map 146 def metadata_for(repo_or_repos, pid) when is_pid(pid) do 147 %{repo: repo_or_repos, owner: pid} 148 end 149 150 @doc """ 151 Encodes metadata generated by `metadata_for/2` for client response. 152 """ 153 def encode_metadata(metadata) do 154 encoded = 155 {:v1, metadata} 156 |> :erlang.term_to_binary() 157 |> Base.url_encode64() 158 159 "BeamMetadata (#{encoded})" 160 end 161 162 @doc """ 163 Decodes encoded metadata back into map generated from `metadata_for/2`. 164 """ 165 def decode_metadata(encoded_meta) when is_binary(encoded_meta) do 166 last_part = encoded_meta |> String.split("/") |> List.last() 167 case Regex.run(~r/BeamMetadata \((.*?)\)/, last_part) do 168 [_, metadata] -> parse_metadata(metadata) 169 _ -> %{} 170 end 171 end 172 def decode_metadata(_), do: %{} 173 174 defp allow_sandbox_access(%{repo: repo, owner: owner}, sandbox) do 175 Enum.each(List.wrap(repo), &sandbox.allow(&1, owner, self())) 176 end 177 defp allow_sandbox_access(_metadata, _sandbox), do: nil 178 179 defp parse_metadata(encoded_metadata) do 180 encoded_metadata 181 |> Base.url_decode64!() 182 |> :erlang.binary_to_term() 183 |> case do 184 {:v1, metadata} -> metadata 185 _ -> %{} 186 end 187 end 188end 189