testing channels

This commit is contained in:
2026-03-03 09:46:12 -07:00
parent c86fbe9f67
commit e0f2d8c4aa
9 changed files with 285 additions and 5 deletions

View File

@@ -1,3 +1,8 @@
{
"css.lint.unknownAtRules": "ignore"
"css.lint.unknownAtRules": "ignore",
"elixirLS.projectDir": "backend",
"cSpell.words": [
"pids",
"whereis"
]
}

View File

@@ -10,3 +10,7 @@ resources:
Channels: <https://hexdocs.pm/phoenix/channels.html>
- 1 channel per browser instance
testing:
- <https://hexdocs.pm/phoenix/testing_channels.html>

View File

@@ -7,8 +7,10 @@ config :backend, BackendWeb.Endpoint,
secret_key_base: "K4i8bHJci8dPJ6LuZHJo147F1h8Wq+SaK4trNGLxJ1cRGkZPZvvm1zk5wWEYY+o2",
server: false
# In test we don't send emails
config :backend, Backend.Mailer, adapter: Swoosh.Adapters.Test
# Disable OpenTelemetry exporter in tests
config :opentelemetry,
traces_exporter: :none,
processors: []
# Print only warnings and errors during test
config :logger, level: :warning

View File

@@ -46,6 +46,10 @@ defmodule Backend.GameRunner do
GenServer.call(@name, :get_state)
end
def get_node_name do
GenServer.call(@name, :get_node_name)
end
# Server Callbacks
@impl true
@@ -137,6 +141,11 @@ defmodule Backend.GameRunner do
{:reply, state, state}
end
@impl true
def handle_call(:get_node_name, _from, state) do
{:reply, node(), state}
end
defp broadcast_state(state) do
Phoenix.PubSub.broadcast(Backend.PubSub, "game_state", {:game_state_updated, state})
state

View File

@@ -52,7 +52,8 @@ defmodule Backend.MixProject do
{:opentelemetry, "~> 1.5"},
{:opentelemetry_exporter, "~> 1.8"},
{:opentelemetry_phoenix, "~> 2.0"},
{:opentelemetry_telemetry, "~> 1.1"}
{:opentelemetry_telemetry, "~> 1.1"},
{:mix_test_interactive, "~> 2.1", only: :dev, runtime: false}
]
end

View File

@@ -5,6 +5,7 @@
"ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"},
@@ -15,6 +16,7 @@
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mix_test_interactive": {:hex, :mix_test_interactive, "2.1.0", "4a803209a81761131ed9a737125a0d021596675fb10e7085e2af256810cd3eb8", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "02b1bd5e29a01463a0acc1a9d8f2edc9ba7f46e84e80d5f8049c35df9bc14b55"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"opentelemetry": {:hex, :opentelemetry, "1.7.0", "20d0f12d3d1c398d3670fd44fd1a7c495dd748ab3e5b692a7906662e2fb1a38a", [:rebar3], [{:opentelemetry_api, "~> 1.5.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "a9173b058c4549bf824cbc2f1d2fa2adc5cdedc22aa3f0f826951187bbd53131"},
@@ -41,6 +43,7 @@
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"tls_certificate_check": {:hex, :tls_certificate_check, "1.31.0", "9a910b54d8cb96cc810cabf4c0129f21360f82022b20180849f1442a25ccbb04", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "9d2b41b128d5507bd8ad93e1a998e06d0ab2f9a772af343f4c00bf76c6be1532"},
"typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},

View File

@@ -0,0 +1,108 @@
defmodule Backend.GameRunnerTest do
use ExUnit.Case, async: true
alias Backend.GameRunner
describe "update_player_keys" do
test "tracks keys_pressed when player sends keydown event" do
player_id = "player_1"
initial_state = %{
players: %{
player_id => %{x: 100, y: 100, keys_pressed: []}
},
tick_number: 0
}
keys_pressed = ["w"]
{:noreply, new_state} =
GameRunner.handle_cast({:update_player_keys, player_id, keys_pressed}, initial_state)
assert new_state.players[player_id].keys_pressed == keys_pressed
end
test "tracks multiple keys_pressed when player sends multiple keydown events" do
player_id = "player_2"
initial_state = %{
players: %{
player_id => %{x: 200, y: 200, keys_pressed: []}
},
tick_number: 0
}
keys_pressed = ["w", "a"]
{:noreply, new_state} =
GameRunner.handle_cast({:update_player_keys, player_id, keys_pressed}, initial_state)
assert new_state.players[player_id].keys_pressed == ["w", "a"]
end
test "updates keys_pressed to empty list when player sends keyup event" do
player_id = "player_3"
initial_state = %{
players: %{
player_id => %{x: 300, y: 300, keys_pressed: ["w", "d"]}
},
tick_number: 0
}
keys_pressed = []
{:noreply, new_state} =
GameRunner.handle_cast({:update_player_keys, player_id, keys_pressed}, initial_state)
assert new_state.players[player_id].keys_pressed == []
end
test "updates keys_pressed when player releases some keys but not all" do
player_id = "player_4"
initial_state = %{
players: %{
player_id => %{x: 400, y: 400, keys_pressed: ["w", "a"]}
},
tick_number: 0
}
keys_pressed = ["w"]
{:noreply, new_state} =
GameRunner.handle_cast({:update_player_keys, player_id, keys_pressed}, initial_state)
assert new_state.players[player_id].keys_pressed == ["w"]
end
test "preserves player position when updating keys_pressed" do
player_id = "player_6"
x = 500
y = 250
initial_state = %{
players: %{
player_id => %{x: x, y: y, keys_pressed: []}
},
tick_number: 0
}
keys_pressed = ["d"]
{:noreply, new_state} =
GameRunner.handle_cast({:update_player_keys, player_id, keys_pressed}, initial_state)
assert new_state.players[player_id].x == x
assert new_state.players[player_id].y == y
assert new_state.players[player_id].keys_pressed == ["d"]
end
end
describe "get_node_name" do
test "returns the current node name" do
node_name = GameRunner.get_node_name()
assert node_name == node()
end
end
end

View File

@@ -0,0 +1,84 @@
defmodule Backend.NodeClusterIntegrationTests do
use ExUnit.Case, async: false
alias Backend.GameRunner
describe "GameRunner singleton across a 4-node cluster" do
test "GameRunner runs on exactly one of the four nodes" do
peers = start_cluster(4)
peer_nodes = Enum.map(peers, fn {_p, n} -> n end)
game_runner_pids =
Enum.map(peers, fn {peer_pid, peer_node_name} ->
runner_pid = :peer.call(peer_pid, :global, :whereis_name, [Backend.GameRunner])
assert is_pid(runner_pid),
"#{peer_node_name} could not find GameRunner in :global registry"
runner_pid
end)
assert length(Enum.uniq(game_runner_pids)) == 1,
"Peers disagree on GameRunner PID: #{inspect(Enum.uniq(game_runner_pids))}"
runner_pid = hd(game_runner_pids)
owner_node = node(runner_pid)
assert owner_node in peer_nodes,
"Owner node #{owner_node} is not one of the cluster peers #{inspect(peer_nodes)}"
assert length(peer_nodes -- [owner_node]) == 3,
"Expected 3 non-owner peer nodes, got #{inspect(peer_nodes -- [owner_node])}"
end
end
defp start_cluster(count) do
peers =
for index <- 1..count do
peer_name = :"cluster_peer_#{System.unique_integer([:positive])}_#{index}"
{:ok, peer, peer_node} =
:peer.start_link(%{
name: peer_name,
connection: :standard_io,
wait_boot: 30_000
})
_ = :peer.call(peer, :application, :ensure_all_started, [:elixir])
Enum.each(:code.get_path(), fn path ->
_ = :peer.call(peer, :code, :add_patha, [path])
end)
# needed for authentication
:peer.call(peer, :erlang, :set_cookie, [Node.get_cookie()])
:peer.call(peer, :application, :set_env, [:opentelemetry, :traces_exporter, :none])
:peer.call(peer, :application, :set_env, [:opentelemetry, :processors, []])
on_exit(fn ->
try do
:peer.stop(peer)
catch
:exit, _ -> :ok
end
end)
{peer, peer_node}
end
peer_nodes = Enum.map(peers, fn {_p, n} -> n end)
for {peer, _} <- peers do
Enum.each(peer_nodes, fn target ->
:peer.call(peer, :net_kernel, :connect_node, [target])
end)
end
for {peer, _} <- peers do
{:ok, _} = :peer.call(peer, :application, :ensure_all_started, [:backend])
end
peers
end
end

View File

@@ -0,0 +1,64 @@
defmodule BackendWeb.ConnectedUserChannelTest do
use ExUnit.Case, async: false
import Phoenix.ChannelTest
@endpoint BackendWeb.Endpoint
setup do
user_name = "test_user_#{System.unique_integer([:positive])}"
{:ok, socket} = connect(BackendWeb.UserSocket, %{"user_name" => user_name})
{:ok, reply, socket} = subscribe_and_join(socket, "user:#{user_name}", %{})
%{socket: socket, reply: reply, user_name: user_name}
end
describe "ConnectedUserChannel" do
test "join reply includes current game state", %{reply: reply} do
assert Map.has_key?(reply, :game_state)
end
test "game_state_updated pubsub broadcast pushes game_state to socket", %{socket: _socket} do
state = %{players: %{"alice" => %{x: 0, y: 0}}}
Phoenix.PubSub.broadcast(Backend.PubSub, "game_state", {:game_state_updated, state})
assert_push "game_state", %{game_state: ^state}
end
test "new_browser_connection pubsub broadcast pushes to socket", %{
socket: _socket,
user_name: user_name
} do
Phoenix.PubSub.broadcast(
Backend.PubSub,
"user_sessions:#{user_name}",
:new_browser_connection
)
assert_push "new_browser_connection", %{}
end
test "join_game message registers player and replies :ok", %{socket: socket} do
ref = push(socket, "join_game", %{"name" => "player1"})
assert_reply ref, :ok
end
test "key_down before join_game is a no-op", %{socket: socket} do
ref = push(socket, "key_down", %{"key" => "ArrowUp"})
refute_reply ref, _
end
test "key_down after join_game updates keys and replies nothing", %{socket: socket} do
push(socket, "join_game", %{"name" => "player1"})
ref = push(socket, "key_down", %{"key" => "ArrowUp"})
refute_reply ref, _
end
test "terminate removes player from game", %{socket: socket} do
push(socket, "join_game", %{"name" => "player_leaving"})
Process.unlink(socket.channel_pid)
close(socket)
# allow terminate callback to run
:timer.sleep(50)
state = Backend.GameRunner.get_state()
refute Map.has_key?(state, "player_leaving")
end
end
end