add mobile app websocket channel and improve QR linking UX
- add MobileSocket for token-authenticated mobile WebSocket connections - add MobileChannel with org-level and device-level real-time events - add /mobile/socket endpoint for iOS app connections - add "Link Mobile App" to user dropdown menu for discoverability - improve QR code page with numbered steps and clearer instructions - add socket/channel tests (17 total)
This commit is contained in:
parent
326932b68e
commit
7251930404
7 changed files with 487 additions and 24 deletions
97
lib/towerops_web/channels/mobile_channel.ex
Normal file
97
lib/towerops_web/channels/mobile_channel.ex
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
defmodule ToweropsWeb.MobileChannel do
|
||||||
|
@moduledoc """
|
||||||
|
Phoenix channel for real-time mobile app communication.
|
||||||
|
|
||||||
|
Supports two topic patterns:
|
||||||
|
- `mobile:org:<org_id>` — org-level alerts and device status events
|
||||||
|
- `mobile:device:<device_id>` — device-level sensor and interface events
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ToweropsWeb, :channel
|
||||||
|
|
||||||
|
alias Towerops.Devices
|
||||||
|
alias Towerops.Organizations
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def join("mobile:org:" <> org_id, _params, socket) do
|
||||||
|
user_id = socket.assigns.user_id
|
||||||
|
|
||||||
|
if Organizations.user_has_access?(user_id, org_id) do
|
||||||
|
Phoenix.PubSub.subscribe(Towerops.PubSub, "organization:#{org_id}:alerts")
|
||||||
|
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{org_id}")
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
|
else
|
||||||
|
{:error, %{reason: "unauthorized"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def join("mobile:device:" <> device_id, _params, socket) do
|
||||||
|
user_id = socket.assigns.user_id
|
||||||
|
|
||||||
|
case Devices.get_device(device_id) do
|
||||||
|
nil ->
|
||||||
|
{:error, %{reason: "not_found"}}
|
||||||
|
|
||||||
|
device ->
|
||||||
|
if Organizations.user_has_access?(user_id, device.organization_id) do
|
||||||
|
Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device_id}")
|
||||||
|
{:ok, socket}
|
||||||
|
else
|
||||||
|
{:error, %{reason: "unauthorized"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Org-level: alert changed
|
||||||
|
@impl true
|
||||||
|
def handle_info({:alert_changed, org_id}, socket) do
|
||||||
|
push(socket, "alert:changed", %{organization_id: org_id})
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Org-level: device events
|
||||||
|
def handle_info({event, org_id}, socket)
|
||||||
|
when event in [:device_created, :device_updated, :device_status_changed, :device_deleted] do
|
||||||
|
push(socket, "device:changed", %{
|
||||||
|
event: to_string(event),
|
||||||
|
organization_id: org_id
|
||||||
|
})
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Device-level: sensor and interface events from SensorChangeDetector/DevicePollerWorker
|
||||||
|
def handle_info({:device_event, event}, socket) when is_map(event) do
|
||||||
|
push(socket, "device:event", %{
|
||||||
|
device_id: event.device_id,
|
||||||
|
event_type: event.event_type,
|
||||||
|
severity: event.severity,
|
||||||
|
message: event.message,
|
||||||
|
metadata: event.metadata,
|
||||||
|
occurred_at: format_datetime(event.occurred_at)
|
||||||
|
})
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Device-level: bulk data updates
|
||||||
|
def handle_info({update_type, device_id}, socket)
|
||||||
|
when update_type in [:sensors_updated, :state_sensors_updated, :interfaces_updated, :neighbors_updated] and
|
||||||
|
is_binary(device_id) do
|
||||||
|
push(socket, "device:updated", %{
|
||||||
|
device_id: device_id,
|
||||||
|
update_type: to_string(update_type)
|
||||||
|
})
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Catch-all for unhandled PubSub messages
|
||||||
|
def handle_info(_msg, socket) do
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_datetime(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
|
||||||
|
defp format_datetime(other), do: to_string(other)
|
||||||
|
end
|
||||||
48
lib/towerops_web/channels/mobile_socket.ex
Normal file
48
lib/towerops_web/channels/mobile_socket.ex
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
defmodule ToweropsWeb.MobileSocket do
|
||||||
|
@moduledoc """
|
||||||
|
WebSocket endpoint for mobile app communication.
|
||||||
|
|
||||||
|
Mobile clients connect to: ws://server/mobile/socket/websocket
|
||||||
|
Authentication happens at connect time using a bearer token from
|
||||||
|
the mobile session system (QR code login flow).
|
||||||
|
|
||||||
|
The token is passed as a `"token"` parameter during the WebSocket
|
||||||
|
handshake. It is validated against the `mobile_sessions` table,
|
||||||
|
and expired sessions are rejected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Phoenix.Socket
|
||||||
|
|
||||||
|
alias Towerops.MobileSessions
|
||||||
|
|
||||||
|
channel "mobile:org:*", ToweropsWeb.MobileChannel
|
||||||
|
channel "mobile:device:*", ToweropsWeb.MobileChannel
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
@spec connect(map(), Phoenix.Socket.t(), map()) :: {:ok, Phoenix.Socket.t()} | :error
|
||||||
|
def connect(%{"token" => token}, socket, _connect_info) when is_binary(token) do
|
||||||
|
case MobileSessions.get_session_by_token(token) do
|
||||||
|
nil ->
|
||||||
|
:error
|
||||||
|
|
||||||
|
session ->
|
||||||
|
session = Towerops.Repo.preload(session, :user)
|
||||||
|
Task.start(fn -> MobileSessions.touch_session(session) end)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:user_id, session.user_id)
|
||||||
|
|> assign(:mobile_session_id, session.id)
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def connect(_params, _socket, _connect_info), do: :error
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
@spec id(Phoenix.Socket.t()) :: String.t()
|
||||||
|
def id(socket) do
|
||||||
|
"mobile_socket:#{socket.assigns.user_id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -297,6 +297,17 @@ defmodule ToweropsWeb.Layouts do
|
||||||
>
|
>
|
||||||
{t("User Settings")}
|
{t("User Settings")}
|
||||||
</.link>
|
</.link>
|
||||||
|
<.link
|
||||||
|
role="menuitem"
|
||||||
|
navigate={~p"/mobile/qr-login"}
|
||||||
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
||||||
|
phx-click={JS.hide(to: "#org-menu")}
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<.icon name="hero-device-phone-mobile" class="h-4 w-4" />
|
||||||
|
{t("Link Mobile App")}
|
||||||
|
</span>
|
||||||
|
</.link>
|
||||||
<.link
|
<.link
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
navigate={~p"/users/my-data"}
|
navigate={~p"/users/my-data"}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ defmodule ToweropsWeb.Endpoint do
|
||||||
websocket: true,
|
websocket: true,
|
||||||
longpoll: false
|
longpoll: false
|
||||||
|
|
||||||
|
socket "/mobile/socket", ToweropsWeb.MobileSocket,
|
||||||
|
websocket: [timeout: 60_000],
|
||||||
|
longpoll: false
|
||||||
|
|
||||||
# Serve at "/" the static files from "priv/static" directory.
|
# Serve at "/" the static files from "priv/static" directory.
|
||||||
#
|
#
|
||||||
# When code reloading is disabled (e.g., in production),
|
# When code reloading is disabled (e.g., in production),
|
||||||
|
|
|
||||||
|
|
@ -111,22 +111,15 @@ defmodule ToweropsWeb.MobileQRLive do
|
||||||
<Layouts.app flash={@flash} timezone={@timezone}>
|
<Layouts.app flash={@flash} timezone={@timezone}>
|
||||||
<div class="mx-auto max-w-4xl">
|
<div class="mx-auto max-w-4xl">
|
||||||
<.header>
|
<.header>
|
||||||
Mobile App Login
|
Link Mobile App
|
||||||
<:subtitle>Scan this QR code with your Towerops mobile app to log in</:subtitle>
|
<:subtitle>
|
||||||
|
Scan this QR code with the Towerops app on your phone to link it to your account
|
||||||
|
</:subtitle>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<div :if={!@completed} class="mt-8">
|
<div :if={!@completed} class="mt-8">
|
||||||
<div class="rounded-lg bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800/50 dark:ring-white/10">
|
<div class="rounded-lg bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800/50 dark:ring-white/10">
|
||||||
<div class="flex flex-col items-center space-y-6">
|
<div class="flex flex-col items-center space-y-6">
|
||||||
<div class="text-center">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
Scan with Mobile App
|
|
||||||
</h3>
|
|
||||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Open the Towerops mobile app and scan this QR code to log in
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg bg-white p-4">
|
<div class="rounded-lg bg-white p-4">
|
||||||
<img
|
<img
|
||||||
src={qr_code_url(@qr_token.token)}
|
src={qr_code_url(@qr_token.token)}
|
||||||
|
|
@ -139,34 +132,63 @@ defmodule ToweropsWeb.MobileQRLive do
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
This QR code expires in 5 minutes
|
This QR code expires in 5 minutes
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Waiting for mobile app to scan...
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<div class="h-2 w-2 animate-pulse rounded-full bg-blue-500"></div>
|
<div class="h-2 w-2 animate-pulse rounded-full bg-blue-500"></div>
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">Checking...</span>
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Waiting for mobile app to scan...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
|
<div class="mt-6 rounded-lg bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800/50 dark:ring-white/10">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
How to link your phone
|
||||||
|
</h3>
|
||||||
|
<ol class="mt-4 space-y-3">
|
||||||
|
<li class="flex items-start gap-3">
|
||||||
|
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-xs font-bold text-white">
|
||||||
|
1
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Open the <span class="font-medium text-gray-900 dark:text-white">Towerops</span>
|
||||||
|
app on your iPhone
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-3">
|
||||||
|
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-xs font-bold text-white">
|
||||||
|
2
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Tap <span class="font-medium text-gray-900 dark:text-white">"Scan QR Code"</span>
|
||||||
|
on the login screen
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-3">
|
||||||
|
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-xs font-bold text-white">
|
||||||
|
3
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Point your camera at the QR code above
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<.icon name="hero-information-circle" class="h-5 w-5 text-blue-400" />
|
<.icon name="hero-information-circle" class="h-5 w-5 text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3">
|
<div class="ml-3">
|
||||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300">
|
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||||
Don't have the mobile app yet?
|
Don't have the app yet?
|
||||||
</h3>
|
</h3>
|
||||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-400">
|
<p class="mt-1 text-sm text-blue-700 dark:text-blue-400">
|
||||||
<p>Download the Towerops mobile app from:</p>
|
Download Towerops from the App Store on your iPhone.
|
||||||
<ul class="mt-2 list-inside list-disc space-y-1">
|
</p>
|
||||||
<li>App Store (iOS)</li>
|
|
||||||
<li>Google Play (Android - coming soon)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
209
test/towerops_web/channels/mobile_channel_test.exs
Normal file
209
test/towerops_web/channels/mobile_channel_test.exs
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
defmodule ToweropsWeb.MobileChannelTest do
|
||||||
|
use Towerops.DataCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.ChannelTest
|
||||||
|
|
||||||
|
alias Towerops.MobileSessions
|
||||||
|
alias ToweropsWeb.MobileSocket
|
||||||
|
|
||||||
|
@endpoint ToweropsWeb.Endpoint
|
||||||
|
|
||||||
|
setup do
|
||||||
|
user = Towerops.AccountsFixtures.user_fixture()
|
||||||
|
organization = Towerops.OrganizationsFixtures.organization_fixture(user.id)
|
||||||
|
|
||||||
|
device =
|
||||||
|
Towerops.DevicesFixtures.device_fixture(%{
|
||||||
|
organization_id: organization.id,
|
||||||
|
snmp_version: "2c",
|
||||||
|
snmp_community: "public"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, qr_token} = MobileSessions.create_qr_login_token(user.id)
|
||||||
|
|
||||||
|
{:ok, session} =
|
||||||
|
MobileSessions.complete_qr_login(qr_token.token, %{
|
||||||
|
device_name: "Test iPhone",
|
||||||
|
device_os: "iOS 18.0",
|
||||||
|
app_version: "1.0.0"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, socket} = connect(MobileSocket, %{"token" => session.raw_token})
|
||||||
|
|
||||||
|
%{socket: socket, user: user, organization: organization, device: device}
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Org-level join ---
|
||||||
|
|
||||||
|
describe "join mobile:org:* topic" do
|
||||||
|
test "joins successfully with valid org access", %{socket: socket, organization: org} do
|
||||||
|
assert {:ok, _reply, _socket} =
|
||||||
|
subscribe_and_join(socket, "mobile:org:#{org.id}", %{})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects join for unauthorized org", %{socket: socket} do
|
||||||
|
fake_org_id = Ecto.UUID.generate()
|
||||||
|
|
||||||
|
assert {:error, %{reason: "unauthorized"}} =
|
||||||
|
subscribe_and_join(socket, "mobile:org:#{fake_org_id}", %{})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Org-level alert events ---
|
||||||
|
|
||||||
|
describe "org-level alert events" do
|
||||||
|
test "pushes alert:changed when alert broadcast received", %{
|
||||||
|
socket: socket,
|
||||||
|
organization: org
|
||||||
|
} do
|
||||||
|
{:ok, _reply, _socket} = subscribe_and_join(socket, "mobile:org:#{org.id}", %{})
|
||||||
|
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
Towerops.PubSub,
|
||||||
|
"organization:#{org.id}:alerts",
|
||||||
|
{:alert_changed, org.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_push "alert:changed", %{organization_id: _}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Org-level device events ---
|
||||||
|
|
||||||
|
describe "org-level device events" do
|
||||||
|
test "pushes device:changed on device_status_changed", %{
|
||||||
|
socket: socket,
|
||||||
|
organization: org
|
||||||
|
} do
|
||||||
|
{:ok, _reply, _socket} = subscribe_and_join(socket, "mobile:org:#{org.id}", %{})
|
||||||
|
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
Towerops.PubSub,
|
||||||
|
"devices:org:#{org.id}",
|
||||||
|
{:device_status_changed, org.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_push "device:changed", %{event: "device_status_changed"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "pushes device:changed on device_created", %{socket: socket, organization: org} do
|
||||||
|
{:ok, _reply, _socket} = subscribe_and_join(socket, "mobile:org:#{org.id}", %{})
|
||||||
|
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
Towerops.PubSub,
|
||||||
|
"devices:org:#{org.id}",
|
||||||
|
{:device_created, org.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_push "device:changed", %{event: "device_created"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "pushes device:changed on device_updated", %{socket: socket, organization: org} do
|
||||||
|
{:ok, _reply, _socket} = subscribe_and_join(socket, "mobile:org:#{org.id}", %{})
|
||||||
|
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
Towerops.PubSub,
|
||||||
|
"devices:org:#{org.id}",
|
||||||
|
{:device_updated, org.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_push "device:changed", %{event: "device_updated"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "pushes device:changed on device_deleted", %{socket: socket, organization: org} do
|
||||||
|
{:ok, _reply, _socket} = subscribe_and_join(socket, "mobile:org:#{org.id}", %{})
|
||||||
|
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
Towerops.PubSub,
|
||||||
|
"devices:org:#{org.id}",
|
||||||
|
{:device_deleted, org.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_push "device:changed", %{event: "device_deleted"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Device-level join ---
|
||||||
|
|
||||||
|
describe "join mobile:device:* topic" do
|
||||||
|
test "joins successfully for device in user's org", %{socket: socket, device: device} do
|
||||||
|
assert {:ok, _reply, _socket} =
|
||||||
|
subscribe_and_join(socket, "mobile:device:#{device.id}", %{})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects join for non-existent device", %{socket: socket} do
|
||||||
|
assert {:error, %{reason: "not_found"}} =
|
||||||
|
subscribe_and_join(socket, "mobile:device:#{Ecto.UUID.generate()}", %{})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Device-level sensor events ---
|
||||||
|
|
||||||
|
describe "device-level sensor events" do
|
||||||
|
test "pushes device:event for sensor threshold change", %{
|
||||||
|
socket: socket,
|
||||||
|
device: device
|
||||||
|
} do
|
||||||
|
{:ok, _reply, _socket} =
|
||||||
|
subscribe_and_join(socket, "mobile:device:#{device.id}", %{})
|
||||||
|
|
||||||
|
event = %{
|
||||||
|
device_id: device.id,
|
||||||
|
event_type: "sensor_threshold_critical",
|
||||||
|
severity: "critical",
|
||||||
|
message: "CPU temperature exceeded 90C",
|
||||||
|
metadata: %{
|
||||||
|
sensor_name: "CPU Temp",
|
||||||
|
sensor_type: "temperature",
|
||||||
|
current_value: 95.0,
|
||||||
|
threshold_value: 90.0
|
||||||
|
},
|
||||||
|
occurred_at: DateTime.utc_now()
|
||||||
|
}
|
||||||
|
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
Towerops.PubSub,
|
||||||
|
"device:#{device.id}",
|
||||||
|
{:device_event, event}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_push "device:event", payload
|
||||||
|
assert payload.event_type == "sensor_threshold_critical"
|
||||||
|
assert payload.severity == "critical"
|
||||||
|
assert payload.device_id == device.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Device-level bulk updates ---
|
||||||
|
|
||||||
|
describe "device-level bulk updates" do
|
||||||
|
test "pushes device:updated for sensors_updated", %{socket: socket, device: device} do
|
||||||
|
{:ok, _reply, _socket} =
|
||||||
|
subscribe_and_join(socket, "mobile:device:#{device.id}", %{})
|
||||||
|
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
Towerops.PubSub,
|
||||||
|
"device:#{device.id}",
|
||||||
|
{:sensors_updated, device.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_push "device:updated", %{
|
||||||
|
device_id: _,
|
||||||
|
update_type: "sensors_updated"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "pushes device:updated for interfaces_updated", %{socket: socket, device: device} do
|
||||||
|
{:ok, _reply, _socket} =
|
||||||
|
subscribe_and_join(socket, "mobile:device:#{device.id}", %{})
|
||||||
|
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
Towerops.PubSub,
|
||||||
|
"device:#{device.id}",
|
||||||
|
{:interfaces_updated, device.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_push "device:updated", %{update_type: "interfaces_updated"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
72
test/towerops_web/channels/mobile_socket_test.exs
Normal file
72
test/towerops_web/channels/mobile_socket_test.exs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
defmodule ToweropsWeb.MobileSocketTest do
|
||||||
|
use Towerops.DataCase, async: true
|
||||||
|
|
||||||
|
import Towerops.AccountsFixtures
|
||||||
|
import Towerops.OrganizationsFixtures
|
||||||
|
|
||||||
|
alias Towerops.MobileSessions
|
||||||
|
alias Towerops.Repo
|
||||||
|
alias ToweropsWeb.MobileSocket
|
||||||
|
|
||||||
|
setup do
|
||||||
|
user = user_fixture()
|
||||||
|
_org = organization_fixture(user.id)
|
||||||
|
|
||||||
|
{:ok, qr_token} = MobileSessions.create_qr_login_token(user.id)
|
||||||
|
|
||||||
|
{:ok, session} =
|
||||||
|
MobileSessions.complete_qr_login(qr_token.token, %{
|
||||||
|
device_name: "Test iPhone",
|
||||||
|
device_os: "iOS 17.0",
|
||||||
|
app_version: "1.0.0"
|
||||||
|
})
|
||||||
|
|
||||||
|
%{user: user, session: session}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "connect/3" do
|
||||||
|
test "authenticates with valid token", %{session: session, user: user} do
|
||||||
|
assert {:ok, socket} =
|
||||||
|
MobileSocket.connect(%{"token" => session.raw_token}, socket(), %{})
|
||||||
|
|
||||||
|
assert socket.assigns.user_id == user.id
|
||||||
|
assert socket.assigns.mobile_session_id == session.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error with no params" do
|
||||||
|
assert :error = MobileSocket.connect(%{}, socket(), %{})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error with invalid token" do
|
||||||
|
assert :error = MobileSocket.connect(%{"token" => "bogus-token"}, socket(), %{})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error with expired session", %{session: session} do
|
||||||
|
# Expire the session by setting expires_at to the past
|
||||||
|
past = DateTime.utc_now() |> DateTime.add(-1, :day) |> DateTime.truncate(:second)
|
||||||
|
|
||||||
|
session
|
||||||
|
|> Ecto.Changeset.change(expires_at: past)
|
||||||
|
|> Repo.update!()
|
||||||
|
|
||||||
|
assert :error = MobileSocket.connect(%{"token" => session.raw_token}, socket(), %{})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "id/1" do
|
||||||
|
test "returns correct socket id format", %{session: session, user: user} do
|
||||||
|
{:ok, socket} =
|
||||||
|
MobileSocket.connect(%{"token" => session.raw_token}, socket(), %{})
|
||||||
|
|
||||||
|
assert MobileSocket.id(socket) == "mobile_socket:#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp socket do
|
||||||
|
%Phoenix.Socket{
|
||||||
|
transport: :websocket,
|
||||||
|
serializer: Phoenix.Socket.V2.JSONSerializer,
|
||||||
|
endpoint: ToweropsWeb.Endpoint
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue