Skip to content

Commit

Permalink
Openapi property based testing (#172)
Browse files Browse the repository at this point in the history
* first tries of property-based testing APIv3

* improve requests handling in CastAndValidate plug

* cover missing field scenario; upgrade missing field response stub

* refactor RequestsGenerator; remove stream_data dep from dev

* ignore StreamData by dialyzer; #makeCircleCIGreenAgain

* apply PR remarks

```
This PR introduces a property-based approach to APIv3 testing, with a little help of the `StreamData` framework. I also incorporated the fix/enhancement of 'the hack of the hack' introduced in #168. Shortly speaking, the request containing, from the needed fields, only data, alert/body and alert/title fields, was matched against SilentNotification schema, what has been leading to improper Unexpected field: alert error.

I also improved the `ControllersHelper.missing_field_response` helper function to better reflect possible error messages we can encounter during tests, based on the API version being used.
```
  • Loading branch information
leszke authored Jun 22, 2020
1 parent 0fd6b03 commit 39fe6ef
Show file tree
Hide file tree
Showing 9 changed files with 194 additions and 11 deletions.
1 change: 1 addition & 0 deletions .dialyzer_ignore.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
~r/Function :asn1ct.compile\/2 does not exist/,
~r/lib\/mix\//,
~r/test\/support\/mix\//,
~r/test\/support\/requests_generator\.ex/,
{"test/support/api.ex"}
]
13 changes: 13 additions & 0 deletions lib/mongoose_push_web/plugs/cast_and_validate.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ defmodule MongoosePushWeb.Plug.CastAndValidate do
# "Failed to cast value to one of: [] (no schemas provided)", instead of something more meaningful.
# Here we avoid it by manual modification of the pattern the request is matched against,
# so the framework can serve us as expected.
defp update_schema_and_do_call(
conn = %{params: %{"alert" => _, "data" => _}},
opts = %{operation_id: operation_id}
) do
new_schema = %OpenApiSpex.Reference{
"$ref": "#/components/schemas/Request.SendNotification.Deep.MixedNotification"
}

conn
|> update_schema(operation_id, new_schema)
|> OpenApiSpex.Plug.CastAndValidate.call(opts)
end

defp update_schema_and_do_call(
conn = %{params: %{"data" => _}},
opts = %{operation_id: operation_id}
Expand Down
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ defmodule MongoosePush.Mixfile do
{:telemetry, "~>0.4.1"},
{:telemetry_metrics, "~> 0.5"},
{:telemetry_metrics_prometheus_core, "~> 0.4"},
{:logfmt, "~>3.3"}
{:logfmt, "~>3.3"},
{:stream_data, "~> 0.5", only: :test}
]
end

Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"},
"sparrow": {:git, "https://github.com/esl/sparrow.git", "b1896ca4fb0ca18369dd62de3d4c82f14f5bc66e", [ref: "b1896ca"]},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"},
"stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"},
"telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.5.0", "1b796e74add83abf844e808564275dfb342bcc930b04c7577ab780e262b0d998", [:mix], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31225e6ce7a37a421a0a96ec55244386aec1c190b22578bd245188a4a33298fd"},
"telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "0.4.0", "0860e53746f4554cf453a5217a3d2648a6d3a074ae01a21869a3963c54b1d5bc", [:mix], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.5", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "912e4c4421477bfb930a19a8de5b2eb967c2700880698c6d80706b8bc32532bf"},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ defmodule MongoosePushWeb.APIv1NotificationControllerTest do
conn = post(conn, "/v1/notification/666", body)

assert json_response(conn, 422) ==
ControllersHelper.missing_field_response(unquote(dropped))
ControllersHelper.missing_field_response(:v1, unquote(dropped))
end
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ defmodule MongoosePushWeb.APIv2NotificationControllerTest do
conn = post(conn, "/v2/notification/654321", body)

assert json_response(conn, 422) ==
ControllersHelper.missing_field_response(unquote(dropped))
ControllersHelper.missing_field_response(:v2, unquote(dropped))
end
end

Expand Down Expand Up @@ -81,7 +81,7 @@ defmodule MongoosePushWeb.APIv2NotificationControllerTest do
conn = post(conn, "/v2/notification/654321", body)

assert json_response(conn, 422) ==
ControllersHelper.missing_field_response(unquote(missing))
ControllersHelper.missing_field_response(:v2, unquote(missing))
end
end

Expand Down Expand Up @@ -124,8 +124,8 @@ defmodule MongoosePushWeb.APIv2NotificationControllerTest do

assert json_response(conn, 422) ==
Map.merge(
ControllersHelper.missing_field_response("service"),
ControllersHelper.missing_field_response("alert"),
ControllersHelper.missing_field_response(:v2, "service"),
ControllersHelper.missing_field_response(:v2, "alert"),
fn _k, v1, v2 -> v1 ++ v2 end
)
end
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
defmodule MongoosePushWeb.APIv3NotificationControllerTest do
alias MongoosePushWeb.Support.ControllersHelper
alias MongoosePushWeb.Support.RequestsGenerator
use ExUnitProperties
use MongoosePushWeb.ConnCase, async: true
import Mox

Expand Down Expand Up @@ -31,7 +33,7 @@ defmodule MongoosePushWeb.APIv3NotificationControllerTest do
conn = post(conn, "/v3/notification/123456", body)

assert json_response(conn, 422) ==
ControllersHelper.missing_field_response(unquote(dropped))
ControllersHelper.missing_field_response(:v3, unquote(dropped))
end
end

Expand Down Expand Up @@ -83,7 +85,7 @@ defmodule MongoosePushWeb.APIv3NotificationControllerTest do
conn = post(conn, "/v3/notification/123456", body)

assert json_response(conn, 422) ==
ControllersHelper.missing_field_response(unquote(missing))
ControllersHelper.missing_field_response(:v3, unquote(missing))
end
end

Expand Down Expand Up @@ -126,8 +128,8 @@ defmodule MongoosePushWeb.APIv3NotificationControllerTest do

assert json_response(conn, 422) ==
Map.merge(
ControllersHelper.missing_field_response("service"),
ControllersHelper.missing_field_response("alert"),
ControllersHelper.missing_field_response(:v3, "service"),
ControllersHelper.missing_field_response(:v3, "alert"),
fn _k, v1, v2 -> v1 ++ v2 end
)
end
Expand Down Expand Up @@ -816,6 +818,35 @@ defmodule MongoosePushWeb.APIv3NotificationControllerTest do
post_and_assert(conn, device_id, expected_device_id, request, expected_request)
end

property "APIv3 notification decoder property-based test", %{conn: conn} do
check all(
mandatory <- RequestsGenerator.mandatory_fields(),
optionals <- RequestsGenerator.optional_fields(),
device_id <- RequestsGenerator.device_id()
) do
request = Map.merge(mandatory, optionals, fn _k, v1, v2 -> Map.merge(v1, v2) end)
expected_device_id = device_id
expected_request = create_expected_request(request)
post_and_assert(conn, device_id, expected_device_id, request, expected_request)
end
end

property "APIv3 notification with dropped one mandatory field", %{conn: conn} do
check all(
mandatory <- RequestsGenerator.mandatory_fields(),
optionals <- RequestsGenerator.optional_fields(),
device_id <- RequestsGenerator.device_id(),
dropped <- RequestsGenerator.mandatory_field()
) do
request =
Map.merge(mandatory, optionals, fn _k, v1, v2 -> Map.merge(v1, v2) end)
|> drop_field(dropped)

conn = post(conn, "/v3/notification/#{device_id}", Jason.encode!(request))
assert json_response(conn, 422) == ControllersHelper.missing_field_response(:v3, dropped)
end
end

defp post_and_assert(conn, device_id, expected_device_id, request, expected_request) do
expect(MongoosePush.Notification.MockImpl, :push, fn device_id, request ->
assert request == expected_request
Expand All @@ -838,4 +869,51 @@ defmodule MongoosePushWeb.APIv3NotificationControllerTest do

assert json_response(conn, number) == %{"reason" => to_string(error_reason)}
end

defp create_expected_request(request) do
%{
alert: fetch_alert(request["alert"]),
data: request["data"],
mode: fetch_enum_field(request["mode"]),
service: fetch_enum_field(request["service"]),
priority: fetch_enum_field(request["priority"]),
mutable_content: fetch_mutable_content(request["mutable_content"]),
tags: request["tags"],
topic: request["topic"],
time_to_live: request["time_to_live"]
}
|> drop_nil_values()
end

defp fetch_alert(alert) do
%{
body: alert["body"],
title: alert["title"],
badge: alert["badge"],
click_action: alert["click_action"],
tag: alert["tag"],
sound: alert["sound"]
}
|> drop_nil_values()
end

defp fetch_enum_field(nil), do: nil
defp fetch_enum_field(string), do: String.to_existing_atom(string)

defp fetch_mutable_content(nil), do: false
defp fetch_mutable_content(val), do: val

defp drop_nil_values(map) do
map
|> Enum.filter(fn {_, v} -> v != nil end)
|> Map.new()
end

defp drop_field(map, field) when field in ["body", "title"] do
Kernel.update_in(map, ["alert"], fn _ -> Map.drop(map["alert"], [field]) end)
end

defp drop_field(map, field) do
Map.drop(map, [field])
end
end
14 changes: 13 additions & 1 deletion test/support/controllers_helper.ex
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,19 @@ defmodule MongoosePushWeb.Support.ControllersHelper do
}
end

def missing_field_response(field) do
def missing_field_response(api, field) when api in [:v2, :v3] and field in ["body", "title"] do
%{
"errors" => [
%{
"message" => "Missing field: #{field}",
"source" => %{"pointer" => "/alert/#{field}"},
"title" => "Invalid value"
}
]
}
end

def missing_field_response(_api, field) do
%{
"errors" => [
%{
Expand Down
77 changes: 77 additions & 0 deletions test/support/requests_generator.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
defmodule MongoosePushWeb.Support.RequestsGenerator do
# APIv2/3 requests
def mandatory_fields() do
StreamData.fixed_map(%{
"alert" =>
StreamData.fixed_map(%{
"title" => nonempty_string(),
"body" => nonempty_string()
}),
"service" => service()
})
end

def optional_fields() do
StreamData.optional_map(%{
"alert" =>
StreamData.optional_map(%{
"badge" => positive_integer(),
"click_action" => nonempty_string(),
"tag" => nonempty_string(),
"sound" => nonempty_string()
}),
"data" => data(),
"mode" => mode(),
"priority" => priority(),
"mutable_content" => boolean(),
"tags" => tags(),
"topic" => nonempty_string(),
"time_to_live" => positive_integer()
})
end

def device_id() do
nonempty_string()
end

def mandatory_field() do
one_of_strings([:service, :title, :body])
end

# basic types
defp nonempty_string() do
StreamData.string(:alphanumeric, min_length: 1)
end

defp positive_integer() do
StreamData.positive_integer()
end

defp boolean() do
StreamData.one_of([false, true])
end

defp tags() do
StreamData.list_of(nonempty_string(), min_length: 1, max_length: 5)
end

defp priority() do
one_of_strings([:normal, :high])
end

defp service() do
one_of_strings([:apns, :fcm])
end

defp mode() do
one_of_strings([:prod, :dev])
end

defp data() do
StreamData.map_of(StreamData.string(:ascii), StreamData.string(:ascii))
end

defp one_of_strings(list_of_atoms) do
StreamData.map(StreamData.one_of(list_of_atoms), &Kernel.to_string/1)
end
end

0 comments on commit 39fe6ef

Please sign in to comment.