neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Admin: close empty unregistered channels
d434ccb · Gabriel Morell · 2026-10-27 17:37
Message
{commit_body(@commit)}
Files changed
modified
lib/oh_my_ex_oh_ircd/channels.ex
+74
−0
@@ -6,6 +6,7 @@ defmodule OhMyExOhIrcd.Channels do
| 6 | 6 | """ |
| 7 | 7 | |
| 8 | 8 | import Ecto.Query, warn: false |
| 9 | + require Logger | |
| 9 | 10 | |
| 10 | 11 | alias OhMyExOhIrcd.Repo |
| 11 | 12 | alias OhMyExOhIrcd.Channels.{ChannelMembership, ChannelRecord, ChannelTopic} |
@@ -306,4 +307,77 @@ defmodule OhMyExOhIrcd.Channels do
| 306 | 307 | rescue |
| 307 | 308 | _ -> %{} |
| 308 | 309 | end |
| 310 | + | |
| 311 | + ## Closing unregistered channels (operator console) | |
| 312 | + | |
| 313 | + @doc """ | |
| 314 | + Close a live, unregistered channel that has nobody in it: stop its | |
| 315 | + cluster singleton and delete what it left in the database. | |
| 316 | + | |
| 317 | + Refuses a ChanServ-registered channel (`{:error, :registered}`) and an | |
| 318 | + occupied one (`{:error, :in_use}`) — stopping a channel with members | |
| 319 | + would silently drop them, since their connections still think they're | |
| 320 | + joined. Membership is checked inside the channel process at close time | |
| 321 | + (see `Channel.close_if_empty/1`), so a JOIN racing the close is safe. | |
| 322 | + `{:error, :not_running}` if there's no live channel by that name. | |
| 323 | + """ | |
| 324 | + @spec close_unregistered(String.t()) :: | |
| 325 | + {:ok, :closed} | {:error, :registered | :in_use | :not_running} | |
| 326 | + def close_unregistered(name) when is_binary(name) do | |
| 327 | + name = String.downcase(name) | |
| 328 | + | |
| 329 | + if get(name) do | |
| 330 | + {:error, :registered} | |
| 331 | + else | |
| 332 | + case OhMyExOhIrcd.IRC.Channel.close_if_empty(name) do | |
| 333 | + :ok -> {:ok, :closed} | |
| 334 | + {:error, _} = err -> err | |
| 335 | + end | |
| 336 | + end | |
| 337 | + end | |
| 338 | + | |
| 339 | + @doc """ | |
| 340 | + Close every live, unregistered channel with no members. Returns | |
| 341 | + `%{closed: n, in_use: n}` — `in_use` counts channels left alone because | |
| 342 | + they have (or just gained) members. | |
| 343 | + """ | |
| 344 | + @spec close_empty_unregistered() :: %{closed: non_neg_integer(), in_use: non_neg_integer()} | |
| 345 | + def close_empty_unregistered do | |
| 346 | + live = OhMyExOhIrcd.IRC.Stats.channel_names() |> Enum.map(&String.downcase/1) |> Enum.uniq() | |
| 347 | + | |
| 348 | + registered = | |
| 349 | + Repo.all(from c in ChannelRecord, where: c.name in ^live, select: c.name) |> MapSet.new() | |
| 350 | + | |
| 351 | + live | |
| 352 | + |> Enum.reject(&MapSet.member?(registered, &1)) | |
| 353 | + |> Enum.reduce(%{closed: 0, in_use: 0}, fn name, acc -> | |
| 354 | + case close_unregistered(name) do | |
| 355 | + {:ok, :closed} -> Map.update!(acc, :closed, &(&1 + 1)) | |
| 356 | + {:error, :in_use} -> Map.update!(acc, :in_use, &(&1 + 1)) | |
| 357 | + {:error, _} -> acc | |
| 358 | + end | |
| 359 | + end) | |
| 360 | + end | |
| 361 | + | |
| 362 | + @doc """ | |
| 363 | + Delete the persisted rows an unregistered channel leaves behind — only | |
| 364 | + its `channel_topics` row: modes and per-user access are persisted only | |
| 365 | + for registered channels (keyed by `channels.id`). Does nothing for a | |
| 366 | + registered channel. Returns the number of rows deleted. | |
| 367 | + """ | |
| 368 | + @spec delete_unregistered_leftovers(String.t()) :: non_neg_integer() | |
| 369 | + def delete_unregistered_leftovers(name) when is_binary(name) do | |
| 370 | + name = String.downcase(name) | |
| 371 | + | |
| 372 | + if get(name) do | |
| 373 | + 0 | |
| 374 | + else | |
| 375 | + {n, _} = Repo.delete_all(from t in ChannelTopic, where: t.name == ^name) | |
| 376 | + n | |
| 377 | + end | |
| 378 | + rescue | |
| 379 | + e -> | |
| 380 | + Logger.warning("Could not delete leftovers of #{name}: #{inspect(e)}") | |
| 381 | + 0 | |
| 382 | + end | |
| 309 | 383 | end |
modified
lib/oh_my_ex_oh_ircd/irc/channel.ex
+68
−0
@@ -191,6 +191,48 @@ defmodule OhMyExOhIrcd.IRC.Channel do
| 191 | 191 | |
| 192 | 192 | def topic_topic(name), do: "channel:" <> normalize(name) |
| 193 | 193 | |
| 194 | + @doc """ | |
| 195 | + `%{topic:, member_count:, flags:, key:, limit:}` for a channel that is | |
| 196 | + running, or `nil` when it isn't. Unlike the other accessors this never | |
| 197 | + starts the channel — the admin console lists live channels, and | |
| 198 | + looking one up must not resurrect a channel that was just closed. | |
| 199 | + """ | |
| 200 | + @spec live_summary(String.t()) :: map() | nil | |
| 201 | + def live_summary(name) do | |
| 202 | + case HordeRegistry.whereis({:channel, normalize(name)}) do | |
| 203 | + pid when is_pid(pid) -> GenServer.call(pid, :live_summary, 2_000) | |
| 204 | + nil -> nil | |
| 205 | + end | |
| 206 | + catch | |
| 207 | + :exit, _ -> nil | |
| 208 | + end | |
| 209 | + | |
| 210 | + @doc """ | |
| 211 | + Stop the channel's singleton if, and only if, it has no members and is | |
| 212 | + not registered — for the operator console's "close" control. The check | |
| 213 | + and the stop happen inside the channel process, so a JOIN can't slip in | |
| 214 | + between them: joins are serialized through the same mailbox, and one | |
| 215 | + queued behind this call finds the process gone and starts a fresh | |
| 216 | + channel (see `call/3`). Leftover persisted state for the channel (its | |
| 217 | + `channel_topics` row) is deleted before the process exits. | |
| 218 | + | |
| 219 | + Stopping with `:normal` makes Horde drop the child (it's `:transient`) | |
| 220 | + exactly as `Horde.DynamicSupervisor.terminate_child/2` would. | |
| 221 | + | |
| 222 | + Never starts a channel: returns `{:error, :not_running}` when there is | |
| 223 | + nothing to close. Otherwise `:ok`, `{:error, :in_use}` or | |
| 224 | + `{:error, :registered}`. | |
| 225 | + """ | |
| 226 | + @spec close_if_empty(String.t()) :: :ok | {:error, :in_use | :registered | :not_running} | |
| 227 | + def close_if_empty(name) do | |
| 228 | + case HordeRegistry.whereis({:channel, normalize(name)}) do | |
| 229 | + pid when is_pid(pid) -> GenServer.call(pid, :close_if_empty) | |
| 230 | + nil -> {:error, :not_running} | |
| 231 | + end | |
| 232 | + catch | |
| 233 | + :exit, _ -> {:error, :not_running} | |
| 234 | + end | |
| 235 | + | |
| 194 | 236 | # Calls the pid ensure_started/1 found or started, not the registry name |
| 195 | 237 | # again: Horde's registry is synced between nodes as a CRDT, so right |
| 196 | 238 | # after a channel starts (or moves) on another node the local copy may |
@@ -459,6 +501,18 @@ defmodule OhMyExOhIrcd.IRC.Channel do
| 459 | 501 | def handle_call(:mode_snapshot, _from, state), |
| 460 | 502 | do: {:reply, %{flags: state.flags, key: state.key, limit: state.limit}, state} |
| 461 | 503 | |
| 504 | + def handle_call(:live_summary, _from, state) do | |
| 505 | + summary = %{ | |
| 506 | + topic: state.topic, | |
| 507 | + member_count: map_size(state.members), | |
| 508 | + flags: state.flags, | |
| 509 | + key: state.key, | |
| 510 | + limit: state.limit | |
| 511 | + } | |
| 512 | + | |
| 513 | + {:reply, summary, state} | |
| 514 | + end | |
| 515 | + | |
| 462 | 516 | def handle_call({:record_invite, nick}, _from, state) do |
| 463 | 517 | {:reply, :ok, %{state | invited: MapSet.put(state.invited, downcase(nick))}} |
| 464 | 518 | end |
@@ -470,6 +524,20 @@ defmodule OhMyExOhIrcd.IRC.Channel do
| 470 | 524 | {:reply, :ok, state} |
| 471 | 525 | end |
| 472 | 526 | |
| 527 | + def handle_call(:close_if_empty, _from, state) do | |
| 528 | + cond do | |
| 529 | + map_size(state.members) > 0 -> | |
| 530 | + {:reply, {:error, :in_use}, state} | |
| 531 | + | |
| 532 | + state.founder_user_id != nil -> | |
| 533 | + {:reply, {:error, :registered}, state} | |
| 534 | + | |
| 535 | + true -> | |
| 536 | + OhMyExOhIrcd.Channels.delete_unregistered_leftovers(state.name) | |
| 537 | + {:stop, :normal, :ok, state} | |
| 538 | + end | |
| 539 | + end | |
| 540 | + | |
| 473 | 541 | def handle_call(:members, _from, state) do |
| 474 | 542 | list = |
| 475 | 543 | state.members |
modified
lib/oh_my_ex_oh_ircd_web/live/admin_live.ex
+84
−12
@@ -173,6 +173,31 @@ defmodule OhMyExOhIrcdWeb.AdminLive do
| 173 | 173 | end |
| 174 | 174 | end |
| 175 | 175 | |
| 176 | + # Close one empty, unregistered channel. The context re-checks | |
| 177 | + # membership inside the channel process, so a late JOIN wins. | |
| 178 | + def handle_event("close_channel", %{"name" => name}, socket) do | |
| 179 | + msg = | |
| 180 | + case Channels.close_unregistered(name) do | |
| 181 | + {:ok, :closed} -> {:info, "Closed #{name}."} | |
| 182 | + {:error, :in_use} -> {:error, "#{name} has members now; left it open."} | |
| 183 | + {:error, :registered} -> {:error, "#{name} is registered; not closing it."} | |
| 184 | + {:error, :not_running} -> {:info, "#{name} was already closed."} | |
| 185 | + end | |
| 186 | + | |
| 187 | + {kind, text} = msg | |
| 188 | + {:noreply, socket |> put_flash(kind, text) |> load_channels()} | |
| 189 | + end | |
| 190 | + | |
| 191 | + def handle_event("clear_empty_unregistered", _params, socket) do | |
| 192 | + %{closed: closed, in_use: in_use} = Channels.close_empty_unregistered() | |
| 193 | + | |
| 194 | + text = | |
| 195 | + "Closed #{closed} empty #{if closed == 1, do: "channel", else: "channels"}." <> | |
| 196 | + if(in_use > 0, do: " #{in_use} in use, left open.", else: "") | |
| 197 | + | |
| 198 | + {:noreply, socket |> put_flash(:info, text) |> load_channels()} | |
| 199 | + end | |
| 200 | + | |
| 176 | 201 | defp invite_form, do: to_form(%{"note" => "", "expires_in_days" => ""}, as: :invite) |
| 177 | 202 | |
| 178 | 203 | defp vhost_email_form, do: to_form(%{"email" => "", "host" => ""}, as: :vhost) |
@@ -223,6 +248,16 @@ defmodule OhMyExOhIrcdWeb.AdminLive do
| 223 | 248 | assign(socket, :users, users) |
| 224 | 249 | end |
| 225 | 250 | |
| 251 | + defp empty_count(channels), do: Enum.count(channels, &(&1.member_count == 0)) | |
| 252 | + | |
| 253 | + # A channel name as a DOM-id fragment: "#Foo.Bar" -> "foo-bar". | |
| 254 | + defp dom_slug(name) do | |
| 255 | + name | |
| 256 | + |> String.downcase() | |
| 257 | + |> String.replace(~r/[^a-z0-9_-]+/u, "-") | |
| 258 | + |> String.trim("-") | |
| 259 | + end | |
| 260 | + | |
| 226 | 261 | defp kind_count(users, kind), do: Enum.count(users, &(&1.kind == kind)) |
| 227 | 262 | |
| 228 | 263 | # Pull live state (members, modes) from each channel's singleton if it's |
@@ -246,18 +281,22 @@ defmodule OhMyExOhIrcdWeb.AdminLive do
| 246 | 281 | end |
| 247 | 282 | |
| 248 | 283 | # Live snapshot for a channel that has no DB record — everything comes |
| 249 | − # from the running singleton. | |
| 284 | + # from the running singleton. `nil` (dropped) when it's no longer | |
| 285 | + # running: the registry can briefly list a channel that was just closed, | |
| 286 | + # and looking it up must not start it again. | |
| 250 | 287 | defp enrich_live(name) do |
| 251 | − snap = Channel.mode_snapshot(name) | |
| 252 | − | |
| 253 | − %{ | |
| 254 | − name: name, | |
| 255 | − topic: Channel.topic(name) || "", | |
| 256 | − member_count: length(Channel.members(name)), | |
| 257 | − mode_string: render_mode_string(snap.flags, snap.key, snap.limit) | |
| 258 | − } | |
| 259 | − catch | |
| 260 | − _, _ -> nil | |
| 288 | + case Channel.live_summary(name) do | |
| 289 | + nil -> | |
| 290 | + nil | |
| 291 | + | |
| 292 | + snap -> | |
| 293 | + %{ | |
| 294 | + name: name, | |
| 295 | + topic: snap.topic || "", | |
| 296 | + member_count: snap.member_count, | |
| 297 | + mode_string: render_mode_string(snap.flags, snap.key, snap.limit) | |
| 298 | + } | |
| 299 | + end | |
| 261 | 300 | end |
| 262 | 301 | |
| 263 | 302 | defp render_mode_string(flags, key, limit) do |
@@ -463,6 +502,22 @@ defmodule OhMyExOhIrcdWeb.AdminLive do
| 463 | 502 | No unregistered channels are live right now. |
| 464 | 503 | </div> |
| 465 | 504 | <% else %> |
| 505 | + <div class="flex items-center gap-3 flex-wrap"> | |
| 506 | + <button | |
| 507 | + type="button" | |
| 508 | + id="clear-empty-unregistered" | |
| 509 | + phx-click="clear_empty_unregistered" | |
| 510 | + data-confirm="Close every unregistered channel with no members? Their topics are deleted too." | |
| 511 | + disabled={empty_count(@unregistered_channels) == 0} | |
| 512 | + class="btn btn-sm btn-outline btn-error" | |
| 513 | + > | |
| 514 | + Clear empty channels | |
| 515 | + </button> | |
| 516 | + <span class="text-xs text-base-content/60 tabular-nums"> | |
| 517 | + {empty_count(@unregistered_channels)} of {length(@unregistered_channels)} empty. | |
| 518 | + Channels with members are left alone. | |
| 519 | + </span> | |
| 520 | + </div> | |
| 466 | 521 | <div class="overflow-x-auto rounded-box border border-base-300"> |
| 467 | 522 | <table class="table table-zebra"> |
| 468 | 523 | <thead> |
@@ -471,14 +526,31 @@ defmodule OhMyExOhIrcdWeb.AdminLive do
| 471 | 526 | <th>Members</th> |
| 472 | 527 | <th>Modes</th> |
| 473 | 528 | <th>Topic</th> |
| 529 | + <th></th> | |
| 474 | 530 | </tr> |
| 475 | 531 | </thead> |
| 476 | 532 | <tbody> |
| 477 | − <tr :for={c <- @unregistered_channels}> | |
| 533 | + <tr :for={c <- @unregistered_channels} id={"unregistered-#{dom_slug(c.name)}"}> | |
| 478 | 534 | <td class="font-mono">{c.name}</td> |
| 479 | 535 | <td class="tabular-nums">{c.member_count}</td> |
| 480 | 536 | <td class="font-mono text-xs">{c.mode_string}</td> |
| 481 | 537 | <td class="max-w-md truncate">{c.topic}</td> |
| 538 | + <td class="whitespace-nowrap text-right"> | |
| 539 | + <%= if c.member_count == 0 do %> | |
| 540 | + <button | |
| 541 | + type="button" | |
| 542 | + id={"close-channel-#{dom_slug(c.name)}"} | |
| 543 | + phx-click="close_channel" | |
| 544 | + phx-value-name={c.name} | |
| 545 | + data-confirm={"Close #{c.name}? Its topic is deleted too."} | |
| 546 | + class="btn btn-ghost btn-xs text-error" | |
| 547 | + > | |
| 548 | + Close | |
| 549 | + </button> | |
| 550 | + <% else %> | |
| 551 | + <span class="text-xs text-base-content/50">in use</span> | |
| 552 | + <% end %> | |
| 553 | + </td> | |
| 482 | 554 | </tr> |
| 483 | 555 | </tbody> |
| 484 | 556 | </table> |
added
test/oh_my_ex_oh_ircd/channels_close_test.exs
+91
−0
@@ -0,0 +1,91 @@
| 1 | +defmodule OhMyExOhIrcd.ChannelsCloseTest do | |
| 2 | + @moduledoc """ | |
| 3 | + `Channels.close_unregistered/1` and `close_empty_unregistered/0` stop | |
| 4 | + live Channel singletons (which hit the DB in `init/1`), so this needs | |
| 5 | + the shared sandbox: `async: false`. | |
| 6 | + """ | |
| 7 | + | |
| 8 | + use OhMyExOhIrcd.DataCase, async: false | |
| 9 | + | |
| 10 | + import OhMyExOhIrcd.AccountsFixtures | |
| 11 | + | |
| 12 | + alias OhMyExOhIrcd.Channels | |
| 13 | + alias OhMyExOhIrcd.Cluster.HordeRegistry | |
| 14 | + alias OhMyExOhIrcd.IRC.Channel | |
| 15 | + | |
| 16 | + defp unique_name(tag), do: "#close#{tag}#{System.unique_integer([:positive])}" | |
| 17 | + | |
| 18 | + defp start_channel(name) do | |
| 19 | + {:ok, pid} = Channel.ensure_started(name) | |
| 20 | + pid | |
| 21 | + end | |
| 22 | + | |
| 23 | + defp assert_stopped(pid) do | |
| 24 | + ref = Process.monitor(pid) | |
| 25 | + assert_receive {:DOWN, ^ref, :process, ^pid, _} | |
| 26 | + end | |
| 27 | + | |
| 28 | + describe "close_unregistered/1" do | |
| 29 | + test "stops an empty unregistered channel and deletes its topic row" do | |
| 30 | + name = unique_name("empty") | |
| 31 | + pid = start_channel(name) | |
| 32 | + :ok = Channels.put_topic(name, "someone", "old topic", 1) | |
| 33 | + assert Channels.get_topic(name) | |
| 34 | + | |
| 35 | + assert {:ok, :closed} = Channels.close_unregistered(name) | |
| 36 | + assert_stopped(pid) | |
| 37 | + refute Channels.get_topic(name) | |
| 38 | + end | |
| 39 | + | |
| 40 | + test "refuses a channel with members" do | |
| 41 | + name = unique_name("busy") | |
| 42 | + pid = start_channel(name) | |
| 43 | + :ok = Channel.join(name, "closer#{System.unique_integer([:positive])}", self()) | |
| 44 | + :ok = Channels.put_topic(name, "someone", "keep me", 1) | |
| 45 | + | |
| 46 | + assert {:error, :in_use} = Channels.close_unregistered(name) | |
| 47 | + assert HordeRegistry.whereis({:channel, name}) == pid | |
| 48 | + assert Channels.get_topic(name).topic == "keep me" | |
| 49 | + end | |
| 50 | + | |
| 51 | + test "refuses a registered channel and leaves its data alone" do | |
| 52 | + name = unique_name("reg") | |
| 53 | + {:ok, _} = Channels.register(name, user_fixture()) | |
| 54 | + pid = start_channel(name) | |
| 55 | + :ok = Channels.put_topic(name, "someone", "registered topic", 1) | |
| 56 | + | |
| 57 | + assert {:error, :registered} = Channels.close_unregistered(name) | |
| 58 | + assert HordeRegistry.whereis({:channel, name}) == pid | |
| 59 | + assert Channels.get(name) | |
| 60 | + assert Channels.get_topic(name) | |
| 61 | + end | |
| 62 | + | |
| 63 | + test "is :not_running for a channel that isn't live, and doesn't start it" do | |
| 64 | + name = unique_name("gone") | |
| 65 | + assert {:error, :not_running} = Channels.close_unregistered(name) | |
| 66 | + assert HordeRegistry.whereis({:channel, name}) == nil | |
| 67 | + end | |
| 68 | + end | |
| 69 | + | |
| 70 | + describe "close_empty_unregistered/0" do | |
| 71 | + test "closes the empty ones and counts the occupied ones" do | |
| 72 | + empty = unique_name("bulkempty") | |
| 73 | + busy = unique_name("bulkbusy") | |
| 74 | + reg = unique_name("bulkreg") | |
| 75 | + | |
| 76 | + empty_pid = start_channel(empty) | |
| 77 | + busy_pid = start_channel(busy) | |
| 78 | + :ok = Channel.join(busy, "bulk#{System.unique_integer([:positive])}", self()) | |
| 79 | + {:ok, _} = Channels.register(reg, user_fixture()) | |
| 80 | + reg_pid = start_channel(reg) | |
| 81 | + | |
| 82 | + %{closed: closed, in_use: in_use} = Channels.close_empty_unregistered() | |
| 83 | + assert closed >= 1 | |
| 84 | + assert in_use >= 1 | |
| 85 | + | |
| 86 | + assert_stopped(empty_pid) | |
| 87 | + assert HordeRegistry.whereis({:channel, busy}) == busy_pid | |
| 88 | + assert HordeRegistry.whereis({:channel, reg}) == reg_pid | |
| 89 | + end | |
| 90 | + end | |
| 91 | +end |
modified
test/oh_my_ex_oh_ircd_web/live/admin_live_channels_test.exs
+43
−0
@@ -45,4 +45,47 @@ defmodule OhMyExOhIrcdWeb.AdminLiveChannelsTest do
| 45 | 45 | unregistered = lv |> element("#unregistered-channels") |> render() |
| 46 | 46 | refute unregistered =~ name |
| 47 | 47 | end |
| 48 | + | |
| 49 | + describe "closing unregistered channels" do | |
| 50 | + defp slug(name), do: name |> String.downcase() |> String.trim_leading("#") | |
| 51 | + | |
| 52 | + test "an empty channel gets a Close button that stops it", %{conn: conn} do | |
| 53 | + name = "#closeme#{System.unique_integer([:positive])}" | |
| 54 | + {:ok, pid} = Channel.ensure_started(name) | |
| 55 | + ref = Process.monitor(pid) | |
| 56 | + | |
| 57 | + {:ok, lv, _html} = conn |> oper_conn() |> live(~p"/admin") | |
| 58 | + | |
| 59 | + assert has_element?(lv, "#clear-empty-unregistered") | |
| 60 | + assert has_element?(lv, "#close-channel-#{slug(name)}") | |
| 61 | + | |
| 62 | + lv |> element("#close-channel-#{slug(name)}") |> render_click() | |
| 63 | + | |
| 64 | + assert_receive {:DOWN, ^ref, :process, ^pid, _} | |
| 65 | + assert render(lv) =~ "Closed #{name}." | |
| 66 | + refute has_element?(lv, "#unregistered-#{slug(name)}") | |
| 67 | + end | |
| 68 | + | |
| 69 | + test "an occupied channel has no Close button, and the bulk clear skips it", %{conn: conn} do | |
| 70 | + busy = "#busy#{System.unique_integer([:positive])}" | |
| 71 | + empty = "#idle#{System.unique_integer([:positive])}" | |
| 72 | + {:ok, busy_pid} = Channel.ensure_started(busy) | |
| 73 | + :ok = Channel.join(busy, "sitter#{System.unique_integer([:positive])}", self()) | |
| 74 | + {:ok, empty_pid} = Channel.ensure_started(empty) | |
| 75 | + ref = Process.monitor(empty_pid) | |
| 76 | + | |
| 77 | + {:ok, lv, _html} = conn |> oper_conn() |> live(~p"/admin") | |
| 78 | + | |
| 79 | + assert has_element?(lv, "#unregistered-#{slug(busy)}") | |
| 80 | + refute has_element?(lv, "#close-channel-#{slug(busy)}") | |
| 81 | + | |
| 82 | + html = lv |> element("#clear-empty-unregistered") |> render_click() | |
| 83 | + | |
| 84 | + assert_receive {:DOWN, ^ref, :process, ^empty_pid, _} | |
| 85 | + assert html =~ ~r/Closed \d+ empty channels?\./ | |
| 86 | + assert OhMyExOhIrcd.Cluster.HordeRegistry.whereis({:channel, busy}) == busy_pid | |
| 87 | + assert has_element?(lv, "#unregistered-#{slug(busy)}") | |
| 88 | + refute has_element?(lv, "#unregistered-#{slug(empty)}") | |
| 89 | + end | |
| 90 | + end | |
| 48 | 91 | end |
Parents: 5e17a59