defmodule KaufmannEx.TestSupport.MockBus do use ExUnit.CaseTemplate @moduledoc """ Helper module for testing event flows. Cannot be run Async, relies on sending messages to `self()` For every `given_event/2` and `then_event/2`. * Validates events against schemas registered with Schema registry * Injects generic Meta payload into given_event `then_event/2` asserts that the given event is emitted and verifies or returned the payload If you have a custom metadata schema or specific metadata handling, set a module exporting `event_metadata/2` in app_env `:kaufmann_ex, :metadata_mod` ### Example Usage ``` defmodule EvenHandlerTests use KaufmannEx.TestSupport.MockBus test "event emission" do given_event(:"TestCommand", %{new_key: "test"}) then_event(:"Testevent", %{new_key: "test"}) then_no_event end end ``` """ setup do default_producer_mod = bus_setup() on_exit(fn -> teardown(default_producer_mod) end) :ok end using do quote do # Using doesn't just import itself import KaufmannEx.TestSupport.MockBus end end require Map.Helpers import ExUnit.Assertions alias KaufmannEx.Schemas.Event alias KaufmannEx.TestSupport.MockSchemaRegistry # Setup Helper @doc false def bus_setup do default_producer_mod = Application.get_env(:kaufmann_ex, :producer_mod) Application.put_env(:kaufmann_ex, :producer_mod, KaufmannEx.TestSupport.MockBus) try do Process.register(self(), :producer) rescue ArgumentError -> Process.unregister(:producer) Process.register(self(), :producer) end default_producer_mod end # Setup Helper @doc false def teardown(producer_mod) do try do Process.unregister(:producer) rescue # Nothing else to be done _ -> nil end Application.put_env(:kaufmann_ex, :producer_mod, producer_mod) end @doc """ Dispatches event to the default subscriber. Schema must be defined & payload must be valid/enocodable """ @spec given_event(atom, any, binary | nil) :: :ok def given_event(event_name, payload, callback_id \\ nil) do schema_name = schema_name_if_query(event_name) assert MockSchemaRegistry.defined_event?(schema_name), "Schema #{schema_name} not registered" # Inject fake MetaData into event event = %Event{ name: event_name, meta: event_metadata(event_name, %{callback_id: callback_id}), payload: payload } encodable_payload = event |> Map.from_struct() |> Map.drop([:name]) |> Map.Helpers.stringify_keys() # If message isn't encodable, big problems assert {:ok, _} = MockSchemaRegistry.encode_event(schema_name, encodable_payload), "Payload does not match schema for #{schema_name}, #{inspect(encodable_payload)}" event_consumer = Application.fetch_env!(:kaufmann_ex, :event_handler_mod) event_consumer.given_event(event) end @doc """ Asserts an event will be emitted to the bus Will test emitted payload from event matches payload Asserts payload matches argument """ @spec then_event(atom, any) :: boolean def then_event(event_name, expected_payload) do assert_received( {:produce, {^event_name, %{payload: message_payload, meta: meta}, _topic}}, "#{event_name} was not triggered" ) assert_matches_schema(event_name, message_payload, meta) assert message_payload == expected_payload end @doc """ Asserts an event has been emitted, returns the payload Returned payload will include `meta` metadata """ @spec then_event(atom) :: %{meta: map, payload: any} def then_event(event_name) do assert_received( {:produce, {^event_name, %{payload: message_payload, meta: meta}, topic}}, "#{event_name} was not triggered" ) assert_matches_schema(event_name, message_payload, meta) %{payload: message_payload, meta: meta, topic: topic} |> Map.Helpers.atomize_keys() end @doc """ Asserts no more events will be emitted """ @spec then_no_event :: boolean def then_no_event do refute_received({:produce, _, _}, "No events expected") end @doc """ Assert an event will not be emitted """ @spec then_no_event(atom) :: boolean def then_no_event(message_name) do refute_received({:produce, {^message_name, _, _}}, "Unexpected #{message_name} recieved") end @doc """ Assert a named event is encodable by its specified schema """ @spec assert_matches_schema(atom, any, map) :: boolean def assert_matches_schema(event_name, payload, meta) do schema_name = schema_name_if_query(event_name) assert MockSchemaRegistry.defined_event?(schema_name), "Schema #{schema_name} not registered" encodable_payload = Map.Helpers.stringify_keys(%{payload: payload, meta: meta}) assert MockSchemaRegistry.encodable?(schema_name, encodable_payload), "Payload does not match schema for #{schema_name}, #{inspect(encodable_payload)}" end defp event_metadata(event_name, context) do metadata_mod = Application.get_env(:kaufmann_ex, :metadata_mod) if module_defined?(metadata_mod, :event_metadata, 2) do metadata_mod.event_metadata(event_name, context) else fake_meta(event_name, context[:callback_id]) end end defp module_defined?(module, method, arity) do # runtime and compiled evaluation need different methods module && (:erlang.function_exported(module, method, arity) || Keyword.has_key?(module.__info__(:functions), method)) end @doc false def fake_meta(event_name \\ "TestEvent", callback_id \\ nil) do %{ message_id: Nanoid.generate(), emitter_service: Nanoid.generate(), emitter_service_id: Nanoid.generate(), callback_id: callback_id, message_name: event_name |> to_string, timestamp: DateTime.to_string(DateTime.utc_now()) } end def produce(topic, event_name, payload, _context), do: produce(event_name, payload, topic) # Internal Produce call, sends to self for assertion @doc false def produce(event_name, payload, topic) do send(:producer, {:produce, {event_name, payload, topic}}) :ok end # Rename events were we use a generic schema for entire classes of events @doc false def schema_name_if_query(event_name) do event_string = event_name |> to_string cond do Regex.match?(~r/^query\./, event_string) -> String.slice(event_string, 0..8) Regex.match?(~r/^event\.error\./, event_string) -> String.slice(event_string, 0..10) true -> event_string end end def encoded_event(event_name, payload, callback_id \\ nil) do %Event{ meta: event_metadata(event_name, callback_id: callback_id), payload: payload } |> Map.from_struct() |> Map.drop([:name]) |> Map.Helpers.stringify_keys() |> encode_payload(event_name) end defp encode_payload(payload, event_name) do schema_name = schema_name_if_query(event_name) MockSchemaRegistry.encode_event(schema_name, payload) end end