Skip to content

Commit 6f5be8b

Browse files
committed
improvement: make testing helpers public and document them
improvement: deprecate the DSL router configuration
1 parent 56ae3a3 commit 6f5be8b

27 files changed

+305
-81
lines changed

lib/ash_json_api/domain/domain.ex

+3-1
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,9 @@ defmodule AshJsonApi.Domain do
119119
],
120120
modules: [:router],
121121
deprecations: [
122-
serve_schema?: "Use the `json_schema` option to `use AshJsonApi.Router` instead."
122+
serve_schema?: "Use the `json_schema` option to `use AshJsonApi.Router` instead.",
123+
router:
124+
"Specify the router option in your calls to test helpers, or configure it via `config :your_app, YourDomain, test_router: YourRouter` in config/test.exs."
123125
],
124126
schema: [
125127
router: [

lib/ash_json_api/domain/info.ex

+2-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ defmodule AshJsonApi.Domain.Info do
3535
end
3636

3737
def router(domain) do
38-
Extension.get_opt(domain, [:json_api], :router, nil, false)
38+
Extension.get_opt(domain, [:json_api], :test_router, nil, true) ||
39+
Extension.get_opt(domain, [:json_api], :router, nil, false)
3940
end
4041

4142
def include_nil_values?(domain) do

lib/ash_json_api/test/test.ex

+143-29
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,52 @@
11
defmodule AshJsonApi.Test do
2-
@moduledoc false
2+
@moduledoc """
3+
Utilities for testing AshJsonApi.
4+
5+
## Making Requests
6+
7+
The request testing functions get/patch/post/delete all support the following options
8+
9+
- `:status`: Asserts that the response has the provided status after making the request
10+
- `:router`: The corresponding JsonApiRouter to go through. Can be set statically in config, see below for more.
11+
- `:actor`: Sets the provided actor as the actor for the request
12+
- `:tenant`: Sets the provided tenant as the tenant for the request
13+
14+
A standard test would look like this:
15+
16+
```elixir
17+
test "can list posts", %{current_user: current_user} do
18+
Domain
19+
# GET /posts
20+
# assert resp.status == 200
21+
|> get("/posts", status: 200, actor: current_user, router: MyAppWeb.JsonApiRouter)
22+
# pattern match on the data key of the response
23+
|> assert_data_matches([
24+
%{
25+
"attributes" => %{
26+
"name" => "foo"
27+
}
28+
}
29+
])
30+
end
31+
```
32+
"""
333
use Plug.Test
434

535
require ExUnit.Assertions
636
import ExUnit.Assertions
737

8-
# This probably won't work for users of ashjsonapi
9-
@schema_file "lib/ash_json_api/test/response_schema"
10-
@external_resource @schema_file
11-
38+
@doc """
39+
Sends a GET request to the given path. See the module docs for more.
40+
"""
1241
def get(domain, path, opts \\ []) do
1342
result =
1443
:get
1544
|> conn(path)
45+
|> set_req_headers(opts)
46+
|> set_context_opts(opts)
1647
|> maybe_set_endpoint(opts)
1748
|> set_accept_request_header(opts)
18-
|> AshJsonApi.Domain.Info.router(domain).call(
19-
AshJsonApi.Domain.Info.router(domain).init([])
20-
)
49+
|> call_router(domain, opts)
2150

2251
assert result.state == :sent
2352

@@ -50,15 +79,18 @@ defmodule AshJsonApi.Test do
5079
end
5180
end
5281

82+
@doc """
83+
Sends a POST request to the given path. See the module docs for more.
84+
"""
5385
def post(domain, path, body, opts \\ []) do
5486
result =
5587
:post
5688
|> conn(path, Jason.encode!(body))
89+
|> set_req_headers(opts)
90+
|> set_context_opts(opts)
5791
|> set_content_type_request_header(opts)
5892
|> set_accept_request_header(opts)
59-
|> AshJsonApi.Domain.Info.router(domain).call(
60-
AshJsonApi.Domain.Info.router(domain).init([])
61-
)
93+
|> call_router(domain, opts)
6294

6395
assert result.state == :sent
6496

@@ -95,22 +127,25 @@ defmodule AshJsonApi.Test do
95127
end
96128

97129
if Code.ensure_loaded?(Multipart) do
130+
@doc """
131+
Sends a multipart POST request to the given path. See the module docs for more.
132+
"""
98133
def multipart_post(domain, path, body, opts \\ []) do
99134
parser_opts =
100135
Plug.Parsers.init(parsers: [AshJsonApi.Plug.Parser], pass: ["*/*"], json_decoder: Jason)
101136

102137
result =
103138
:post
104139
|> conn(path, Multipart.body_binary(body))
140+
|> set_req_headers(opts)
141+
|> set_context_opts(opts)
105142
|> put_req_header(
106143
"content-type",
107144
Multipart.content_type(body, "multipart/x.ash+form-data")
108145
)
109146
|> set_accept_request_header(opts)
110147
|> Plug.Parsers.call(parser_opts)
111-
|> AshJsonApi.Domain.Info.router(domain).call(
112-
AshJsonApi.Domain.Info.router(domain).init([])
113-
)
148+
|> call_router(domain, opts)
114149

115150
assert result.state == :sent
116151

@@ -157,15 +192,18 @@ defmodule AshJsonApi.Test do
157192
end
158193
end
159194

195+
@doc """
196+
Sends a PATCH request to the given path. See the module docs for more.
197+
"""
160198
def patch(domain, path, body, opts \\ []) do
161199
result =
162200
:patch
163201
|> conn(path, Jason.encode!(body))
202+
|> set_req_headers(opts)
203+
|> set_context_opts(opts)
164204
|> set_content_type_request_header(opts)
165205
|> set_accept_request_header(opts)
166-
|> AshJsonApi.Domain.Info.router(domain).call(
167-
AshJsonApi.Domain.Info.router(domain).init([])
168-
)
206+
|> call_router(domain, opts)
169207

170208
assert result.state == :sent
171209

@@ -201,14 +239,17 @@ defmodule AshJsonApi.Test do
201239
end
202240
end
203241

242+
@doc """
243+
Sends a DELETE request to the given path. See the module docs for more.
244+
"""
204245
def delete(domain, path, opts \\ []) do
205246
result =
206247
:delete
207248
|> conn(path)
249+
|> set_req_headers(opts)
250+
|> set_context_opts(opts)
208251
|> set_accept_request_header(opts)
209-
|> AshJsonApi.Domain.Info.router(domain).call(
210-
AshJsonApi.Domain.Info.router(domain).init([])
211-
)
252+
|> call_router(domain, opts)
212253

213254
assert result.state == :sent
214255

@@ -241,6 +282,9 @@ defmodule AshJsonApi.Test do
241282
end
242283
end
243284

285+
@doc """
286+
Assert that the response body's `"data"` equals an exact value
287+
"""
244288
defmacro assert_data_equals(conn, expected_data) do
245289
quote do
246290
conn = unquote(conn)
@@ -251,6 +295,9 @@ defmodule AshJsonApi.Test do
251295
end
252296
end
253297

298+
@doc """
299+
Assert that the response body's `"data"` matches a pattern
300+
"""
254301
defmacro assert_data_matches(conn, data_pattern) do
255302
quote do
256303
conn = unquote(conn)
@@ -260,6 +307,7 @@ defmodule AshJsonApi.Test do
260307
end
261308
end
262309

310+
@doc false
263311
defmacro assert_meta_equals(conn, expected_meta) do
264312
quote bind_quoted: [conn: conn, expected_meta: expected_meta] do
265313
assert %{"meta" => ^expected_meta} = conn.resp_body
@@ -268,11 +316,13 @@ defmodule AshJsonApi.Test do
268316
end
269317
end
270318

319+
@doc false
271320
def assert_response_header_equals(conn, header, value) do
272321
assert get_resp_header(conn, header) == [value]
273322
conn
274323
end
275324

325+
@doc false
276326
defmacro assert_attribute_equals(conn, attribute, expected_value) do
277327
quote bind_quoted: [attribute: attribute, expected_value: expected_value, conn: conn] do
278328
assert %{"data" => %{"attributes" => %{^attribute => ^expected_value}}} = conn.resp_body
@@ -281,6 +331,7 @@ defmodule AshJsonApi.Test do
281331
end
282332
end
283333

334+
@doc false
284335
defmacro assert_id_equals(conn, expected_value) do
285336
quote bind_quoted: [expected_value: expected_value, conn: conn] do
286337
assert %{"data" => %{"id" => ^expected_value}} = conn.resp_body
@@ -289,15 +340,7 @@ defmodule AshJsonApi.Test do
289340
end
290341
end
291342

292-
@doc """
293-
Validate the response contains a Resource Object as per 5.2 Specification 1.0
294-
295-
A resource object MUST contain at least the following top-level members:
296-
- id
297-
- type
298-
299-
see: https://jsonapi.org/format/1.0/#document-resource-objects
300-
"""
343+
@doc false
301344
defmacro assert_valid_resource_object(conn, expected_type, expected_id) do
302345
quote bind_quoted: [conn: conn, expected_type: expected_type, expected_id: expected_id] do
303346
assert %{
@@ -311,6 +354,7 @@ defmodule AshJsonApi.Test do
311354
end
312355
end
313356

357+
@doc false
314358
defmacro assert_valid_resource_objects(conn, expected_type, expected_ids) do
315359
quote bind_quoted: [conn: conn, expected_type: expected_type, expected_ids: expected_ids] do
316360
assert %{
@@ -329,6 +373,7 @@ defmodule AshJsonApi.Test do
329373
end
330374
end
331375

376+
@doc false
332377
defmacro assert_invalid_resource_objects(conn, expected_type, expected_ids) do
333378
quote bind_quoted: [conn: conn, expected_type: expected_type, expected_ids: expected_ids] do
334379
assert %{
@@ -347,6 +392,7 @@ defmodule AshJsonApi.Test do
347392
end
348393
end
349394

395+
@doc false
350396
defmacro assert_attribute_missing(conn, attribute) do
351397
quote bind_quoted: [conn: conn, attribute: attribute] do
352398
assert %{"data" => %{"attributes" => attributes}} = conn.resp_body
@@ -357,6 +403,22 @@ defmodule AshJsonApi.Test do
357403
end
358404
end
359405

406+
@doc """
407+
Asserts that an error is in the response where each key present in the provided map
408+
has the same value in the error.
409+
410+
## Example
411+
412+
```elixr
413+
Domain
414+
|> delete("/posts/1", status: 404)
415+
|> assert_has_error(%{
416+
"code" => "not_found",
417+
"detail" => "No post record found with `id: 1`",
418+
"title" => "Entity Not Found"
419+
})
420+
```
421+
"""
360422
defmacro assert_has_error(conn, fields) do
361423
quote do
362424
assert %{"errors" => [_ | _] = errors} = unquote(conn).resp_body
@@ -371,6 +433,21 @@ defmodule AshJsonApi.Test do
371433
end
372434
end
373435

436+
@doc """
437+
Assert that the given function returns true for at least one included record
438+
439+
## Example
440+
441+
Domain
442+
|> get("/posts/\#{post.id}/?include=author", status: 200)
443+
|> assert_has_matching_include(fn
444+
%{"type" => "author", "id" => ^author_id} ->
445+
true
446+
447+
_ ->
448+
false
449+
end)
450+
"""
374451
defmacro assert_has_matching_include(conn, function) do
375452
quote do
376453
assert %{"included" => included} = unquote(conn).resp_body
@@ -384,6 +461,21 @@ defmodule AshJsonApi.Test do
384461
end
385462
end
386463

464+
@doc """
465+
Refute that the given function returns true for at least one included record
466+
467+
## Example
468+
469+
Domain
470+
|> get("/posts/\#{post.id}/?include=author", status: 200)
471+
|> refute_has_matching_include(fn
472+
%{"type" => "author", "id" => ^author_id} ->
473+
true
474+
475+
_ ->
476+
false
477+
end)
478+
"""
387479
defmacro refute_has_matching_include(conn, function) do
388480
quote do
389481
with %{"included" => included} when is_list(included) <- unquote(conn).resp_body do
@@ -396,6 +488,7 @@ defmodule AshJsonApi.Test do
396488
end
397489
end
398490

491+
@doc false
399492
defmacro assert_equal_links(conn, expected_links) do
400493
quote bind_quoted: [expected_links: expected_links, conn: conn] do
401494
%{"links" => resp_links} = conn.resp_body
@@ -411,6 +504,7 @@ defmodule AshJsonApi.Test do
411504
end
412505
end
413506

507+
@doc false
414508
def uri_with_query(nil), do: nil
415509

416510
def uri_with_query(value) do
@@ -456,4 +550,24 @@ defmodule AshJsonApi.Test do
456550
conn
457551
end
458552
end
553+
554+
defp set_context_opts(conn, opts) do
555+
conn
556+
|> Ash.PlugHelpers.set_actor(opts[:actor])
557+
|> Ash.PlugHelpers.set_tenant(opts[:tenant])
558+
end
559+
560+
defp set_req_headers(conn, opts) do
561+
opts[:headers]
562+
|> Kernel.||([])
563+
|> Enum.reduce(conn, fn {header, value}, conn ->
564+
Plug.Conn.put_req_header(conn, to_string(header), to_string(value))
565+
end)
566+
end
567+
568+
defp call_router(conn, domain, opts) do
569+
router = opts[:router] || AshJsonApi.Domain.Info.router(domain)
570+
571+
router.call(conn, router.init([]))
572+
end
459573
end

mix.exs

+2-1
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ defmodule AshJsonApi.MixProject do
9292
AshJsonApi.Domain
9393
],
9494
Utilities: [
95-
AshJsonApi.OpenApi
95+
AshJsonApi.OpenApi,
96+
AshJsonApi.Test
9697
],
9798
Introspection: [
9899
AshJsonApi.Resource.Info,

0 commit comments

Comments
 (0)