Skip to content

Commit eac48ab

Browse files
committed
Address PR #1749 review M5 — Backpex.InitAssigns test coverage
Restores test coverage for the on_mount hook that replaced the deleted ThemeSelectorPlug. Covers the happy path, malformed-session fallbacks, and adapter-driven overrides for `global.theme`. Also fixes a latent bug in `Backpex.Preferences.Adapters.Session.root/1` uncovered while writing the malformed-session tests: a host app that stomps on the session key with a non-map (binary/nil/other) caused `get_in/2` to crash. `root/1` now coerces any non-map value to `%{}`.
1 parent df88d7a commit eac48ab

2 files changed

Lines changed: 245 additions & 1 deletion

File tree

lib/backpex/preferences/adapters/session.ex

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,17 @@ defmodule Backpex.Preferences.Adapters.Session do
6666
{:error, :requires_http}
6767
end
6868

69-
defp root(session) when is_map(session), do: Map.get(session, @session_key) || %{}
69+
# The session key is expected to hold a map, but a misbehaving host app (or
70+
# a session rewrite by another plug) can stomp on it with a non-map. Coerce
71+
# any non-map value to `%{}` here so `get_in/2` upstream can't crash on a
72+
# binary/number/etc.
73+
defp root(session) when is_map(session) do
74+
case Map.get(session, @session_key) do
75+
map when is_map(map) -> map
76+
_other -> %{}
77+
end
78+
end
79+
7080
defp root(_other), do: %{}
7181

7282
defp deep_put(map, [k], value), do: Map.put(map, k, value)

test/init_assigns_test.exs

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
defmodule Backpex.InitAssignsTest do
2+
use ExUnit.Case, async: false
3+
4+
alias Backpex.InitAssigns
5+
alias Phoenix.LiveView.Lifecycle
6+
alias Phoenix.LiveView.Socket
7+
8+
# --- test-only adapter --------------------------------------------------
9+
10+
defmodule StubAdapter do
11+
@moduledoc false
12+
# Returns fixed per-key values regardless of the context. Configure via
13+
# application env; the `fetch/1` and `fetch_map/1` functions look the key
14+
# up in a lookup map set by the test.
15+
@behaviour Backpex.Preferences.Adapter
16+
17+
@table_env_key :backpex_init_assigns_test_stub_adapter
18+
19+
def set(values) when is_map(values) do
20+
Application.put_env(:backpex, @table_env_key, values)
21+
end
22+
23+
def clear, do: Application.delete_env(:backpex, @table_env_key)
24+
25+
defp values, do: Application.get_env(:backpex, @table_env_key, %{})
26+
27+
@impl Backpex.Preferences.Adapter
28+
def get(_ctx, key, _opts) do
29+
case Map.fetch(values(), key) do
30+
{:ok, value} -> {:ok, value}
31+
:error -> {:ok, :not_found}
32+
end
33+
end
34+
35+
@impl Backpex.Preferences.Adapter
36+
def get_map(_ctx, prefix, _opts) do
37+
map =
38+
values()
39+
|> Enum.flat_map(fn {k, v} ->
40+
case maybe_strip(k, prefix <> ".") do
41+
nil -> []
42+
rest -> [{rest, v}]
43+
end
44+
end)
45+
|> Map.new()
46+
47+
{:ok, map}
48+
end
49+
50+
@impl Backpex.Preferences.Adapter
51+
def put(_ctx, _key, _value, _opts), do: {:ok, [:noop]}
52+
53+
defp maybe_strip(key, prefix) do
54+
case String.split(key, prefix, parts: 2) do
55+
["", rest] -> rest
56+
_other -> nil
57+
end
58+
end
59+
end
60+
61+
# --- setup --------------------------------------------------------------
62+
63+
setup do
64+
on_exit(fn ->
65+
StubAdapter.clear()
66+
Application.delete_env(:backpex, Backpex.Preferences)
67+
end)
68+
69+
:ok
70+
end
71+
72+
# --- helpers ------------------------------------------------------------
73+
74+
# Builds a socket compatible with `Phoenix.LiveView.attach_hook/4`.
75+
#
76+
# `attach_hook(..., :handle_params, ...)` refuses a socket with `router: nil`,
77+
# and both `attach_hook` and `assign/3` touch `socket.private` — so the
78+
# private map must carry a `:lifecycle` struct and a `:live_temp` map.
79+
defp build_socket do
80+
%Socket{
81+
endpoint: __MODULE__.Endpoint,
82+
router: __MODULE__.Router,
83+
assigns: %{__changed__: %{}},
84+
private: %{
85+
connect_info: %{},
86+
lifecycle: %Lifecycle{},
87+
live_temp: %{}
88+
}
89+
}
90+
end
91+
92+
defp mount(session, socket \\ build_socket()) do
93+
{:cont, socket} = InitAssigns.on_mount(:default, %{}, session, socket)
94+
socket
95+
end
96+
97+
# --- tests --------------------------------------------------------------
98+
99+
describe "on_mount/4 with an empty session" do
100+
test "assigns the documented defaults (theme nil, sidebar open, no sections)" do
101+
socket = mount(%{})
102+
103+
assert socket.assigns.current_theme == nil
104+
assert socket.assigns.sidebar_open == true
105+
assert socket.assigns.sidebar_section_states == %{}
106+
end
107+
108+
test "treats a session that only has the preferences key present as empty" do
109+
socket = mount(%{"backpex_preferences" => %{}})
110+
111+
assert socket.assigns.current_theme == nil
112+
assert socket.assigns.sidebar_open == true
113+
assert socket.assigns.sidebar_section_states == %{}
114+
end
115+
116+
test "returns `{:cont, socket}` so subsequent hooks still run" do
117+
# The public contract of a LiveView `on_mount` hook is the `{:cont | :halt,
118+
# socket}` tuple. Pin the shape here so refactors cannot silently change
119+
# semantics (e.g. to `{:halt, ...}`).
120+
assert {:cont, %Socket{}} = InitAssigns.on_mount(:default, %{}, %{}, build_socket())
121+
end
122+
end
123+
124+
describe "on_mount/4 with preference values present in the session" do
125+
test "mirrors the stored theme, sidebar_open, and sidebar_section_states" do
126+
session = %{
127+
"backpex_preferences" => %{
128+
"global" => %{
129+
"theme" => "dark",
130+
"sidebar_open" => false,
131+
"sidebar_section" => %{"users" => true, "blog" => false}
132+
}
133+
}
134+
}
135+
136+
socket = mount(session)
137+
138+
assert socket.assigns.current_theme == "dark"
139+
assert socket.assigns.sidebar_open == false
140+
assert socket.assigns.sidebar_section_states == %{"users" => true, "blog" => false}
141+
end
142+
143+
test "accepts non-boolean sidebar_open as-is (adapter has no schema enforcement)" do
144+
# The Session adapter is a dumb key/value store — it returns whatever is
145+
# stored. Document the current behavior: `sidebar_open` can hold any term
146+
# if the host app writes one. Layout components are responsible for
147+
# coercing.
148+
session = %{"backpex_preferences" => %{"global" => %{"sidebar_open" => "nope"}}}
149+
socket = mount(session)
150+
assert socket.assigns.sidebar_open == "nope"
151+
end
152+
end
153+
154+
describe "on_mount/4 with a malformed session" do
155+
test "falls back to defaults when `backpex_preferences` is a binary" do
156+
# Pathological but possible: a host app stomps on the session key with a
157+
# non-map. The Session adapter's `root/1` guards against this and the
158+
# on_mount hook must not crash.
159+
socket = mount(%{"backpex_preferences" => "oops"})
160+
161+
assert socket.assigns.current_theme == nil
162+
assert socket.assigns.sidebar_open == true
163+
assert socket.assigns.sidebar_section_states == %{}
164+
end
165+
166+
test "falls back to defaults when `backpex_preferences` is explicitly nil" do
167+
socket = mount(%{"backpex_preferences" => nil})
168+
169+
assert socket.assigns.current_theme == nil
170+
assert socket.assigns.sidebar_open == true
171+
assert socket.assigns.sidebar_section_states == %{}
172+
end
173+
174+
test "returns the stored value unchanged when the theme slot holds a non-string" do
175+
# The Session adapter doesn't type-check; surfacing the raw value lets
176+
# layout code decide whether to coerce. Pin current behavior so a silent
177+
# change to coerce-at-read surfaces here.
178+
session = %{"backpex_preferences" => %{"global" => %{"theme" => 42}}}
179+
socket = mount(session)
180+
assert socket.assigns.current_theme == 42
181+
end
182+
183+
test "sidebar_section_states falls back to %{} when the stored subtree is not a map" do
184+
# `Preferences.get_map/3` must degrade to `%{}` rather than returning a
185+
# scalar for the sidebar_section sub-tree — otherwise callers that
186+
# pattern-match on a map in the layout crash.
187+
session = %{
188+
"backpex_preferences" => %{"global" => %{"sidebar_section" => "not-a-map"}}
189+
}
190+
191+
socket = mount(session)
192+
assert socket.assigns.sidebar_section_states == %{}
193+
end
194+
end
195+
196+
describe "on_mount/4 with a custom Preferences adapter" do
197+
test "the adapter's value for global.theme wins over anything in the session" do
198+
# Route every key through StubAdapter and seed a value for `global.theme`.
199+
# Despite the session also holding a theme, the adapter result must win —
200+
# this is the seam that lets a DB-backed adapter override session state.
201+
Application.put_env(:backpex, Backpex.Preferences, adapters: [{:default, StubAdapter, []}])
202+
203+
StubAdapter.set(%{"global.theme" => "cupcake"})
204+
205+
session = %{"backpex_preferences" => %{"global" => %{"theme" => "dark"}}}
206+
socket = mount(session)
207+
208+
assert socket.assigns.current_theme == "cupcake"
209+
end
210+
211+
test "falls back to the :default option when the adapter reports `:not_found` for sidebar_open" do
212+
# StubAdapter returns `:not_found` for unknown keys. Verify the caller's
213+
# `default: true` option reaches the value.
214+
Application.put_env(:backpex, Backpex.Preferences, adapters: [{:default, StubAdapter, []}])
215+
216+
StubAdapter.set(%{})
217+
218+
socket = mount(%{})
219+
assert socket.assigns.sidebar_open == true
220+
end
221+
end
222+
223+
describe "on_mount/4 hooks the current URL into :handle_params" do
224+
test "attaches a handle_params hook that stores the URL on :current_url" do
225+
# The hook itself runs on each `handle_params`. We can't drive the real
226+
# lifecycle without a mounted LiveView, but we can verify the attachment
227+
# happened — future changes that drop the hook break this test.
228+
socket = mount(%{})
229+
230+
hooks = socket.private.lifecycle.handle_params
231+
assert Enum.any?(hooks, fn hook -> hook.id == :current_url end)
232+
end
233+
end
234+
end

0 commit comments

Comments
 (0)