From cc480652aa6d376bfdca0de24c19857a37404491 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 16 Apr 2026 14:38:41 -0500 Subject: [PATCH] test: cover repo_listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10 characterization tests for PostgreSQL NOTIFY → PubSub relay: contact_status_changed channel → db:contact_status topic, oban_job_changed → db:oban_jobs, channel isolation, invalid JSON silent-swallow, catch-all handling of unknown channels and info messages. --- test/microwaveprop/repo_listener_test.exs | 137 ++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 test/microwaveprop/repo_listener_test.exs diff --git a/test/microwaveprop/repo_listener_test.exs b/test/microwaveprop/repo_listener_test.exs new file mode 100644 index 00000000..fa6dc9a1 --- /dev/null +++ b/test/microwaveprop/repo_listener_test.exs @@ -0,0 +1,137 @@ +defmodule Microwaveprop.RepoListenerTest do + @moduledoc """ + Characterization tests for `Microwaveprop.RepoListener`. + + The module is a GenServer that subscribes to two PostgreSQL `NOTIFY` + channels via `Postgrex.Notifications` and rebroadcasts decoded + payloads via `Phoenix.PubSub`. These tests exercise the `handle_info/2` + seam directly using the exact message shape `Postgrex.Notifications` + delivers — no database connection required. + """ + + use ExUnit.Case, async: false + + alias Microwaveprop.RepoListener + + # Shape of the message that `Postgrex.Notifications` delivers to a + # listening process. Ref. https://hexdocs.pm/postgrex/Postgrex.Notifications.html + defp notification(channel, payload) do + {:notification, self(), make_ref(), channel, payload} + end + + describe "handle_info/2 — contact_status_changed" do + setup do + :ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "db:contact_status") + on_exit(fn -> Phoenix.PubSub.unsubscribe(Microwaveprop.PubSub, "db:contact_status") end) + :ok + end + + test "broadcasts decoded payload on db:contact_status topic" do + payload = Jason.encode!(%{"contact_id" => "abc-123", "status" => "queued"}) + msg = notification("contact_status_changed", payload) + + assert {:noreply, %{pid: nil}} = RepoListener.handle_info(msg, %{pid: nil}) + + assert_receive {:contact_status_changed, %{"contact_id" => "abc-123", "status" => "queued"}} + end + + test "preserves full decoded payload structure (nested maps/lists)" do + data = %{ + "contact_id" => "xyz", + "fields" => ["weather", "hrrr"], + "meta" => %{"source" => "notify"} + } + + msg = notification("contact_status_changed", Jason.encode!(data)) + + assert {:noreply, _} = RepoListener.handle_info(msg, %{pid: nil}) + assert_receive {:contact_status_changed, ^data} + end + + test "silently ignores invalid JSON payload" do + msg = notification("contact_status_changed", "not-json-at-all{") + + assert {:noreply, %{pid: nil}} = RepoListener.handle_info(msg, %{pid: nil}) + refute_receive {:contact_status_changed, _}, 50 + end + end + + describe "handle_info/2 — oban_job_changed" do + setup do + :ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "db:oban_jobs") + on_exit(fn -> Phoenix.PubSub.unsubscribe(Microwaveprop.PubSub, "db:oban_jobs") end) + :ok + end + + test "broadcasts decoded payload on db:oban_jobs topic" do + payload = Jason.encode!(%{"id" => 42, "state" => "completed", "queue" => "weather"}) + msg = notification("oban_job_changed", payload) + + assert {:noreply, %{pid: nil}} = RepoListener.handle_info(msg, %{pid: nil}) + + assert_receive {:oban_job_changed, %{"id" => 42, "state" => "completed", "queue" => "weather"}} + end + + test "silently ignores invalid JSON payload" do + msg = notification("oban_job_changed", "{\"broken\":") + + assert {:noreply, %{pid: nil}} = RepoListener.handle_info(msg, %{pid: nil}) + refute_receive {:oban_job_changed, _}, 50 + end + end + + describe "handle_info/2 — channel isolation" do + test "contact_status_changed does not broadcast on db:oban_jobs" do + :ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "db:oban_jobs") + on_exit(fn -> Phoenix.PubSub.unsubscribe(Microwaveprop.PubSub, "db:oban_jobs") end) + + msg = notification("contact_status_changed", Jason.encode!(%{"contact_id" => "abc"})) + + assert {:noreply, _} = RepoListener.handle_info(msg, %{pid: nil}) + refute_receive {:oban_job_changed, _}, 50 + refute_receive {:contact_status_changed, _}, 50 + end + + test "oban_job_changed does not broadcast on db:contact_status" do + :ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "db:contact_status") + on_exit(fn -> Phoenix.PubSub.unsubscribe(Microwaveprop.PubSub, "db:contact_status") end) + + msg = notification("oban_job_changed", Jason.encode!(%{"id" => 1})) + + assert {:noreply, _} = RepoListener.handle_info(msg, %{pid: nil}) + refute_receive {:contact_status_changed, _}, 50 + refute_receive {:oban_job_changed, _}, 50 + end + end + + describe "handle_info/2 — unknown / unrelated messages" do + test "ignores notifications on unknown channels without crashing" do + :ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "db:contact_status") + :ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "db:oban_jobs") + + on_exit(fn -> + Phoenix.PubSub.unsubscribe(Microwaveprop.PubSub, "db:contact_status") + Phoenix.PubSub.unsubscribe(Microwaveprop.PubSub, "db:oban_jobs") + end) + + msg = notification("some_other_channel", Jason.encode!(%{"x" => 1})) + + assert {:noreply, %{pid: nil}} = RepoListener.handle_info(msg, %{pid: nil}) + refute_receive {:contact_status_changed, _}, 50 + refute_receive {:oban_job_changed, _}, 50 + end + + test "ignores arbitrary info messages without crashing" do + assert {:noreply, %{pid: nil}} = RepoListener.handle_info(:random_message, %{pid: nil}) + assert {:noreply, %{pid: nil}} = RepoListener.handle_info({:foo, :bar}, %{pid: nil}) + end + + test "returns state unchanged regardless of message" do + state = %{pid: nil, extra: :keep_me} + assert {:noreply, ^state} = RepoListener.handle_info(:whatever, state) + + msg = notification("contact_status_changed", Jason.encode!(%{"a" => 1})) + assert {:noreply, ^state} = RepoListener.handle_info(msg, state) + end + end +end