Merge pull request 'Node page Discord picker: org's servers + voice channels, not raw IDs (#213 item 3)' (#220) from feat/213-node-join-picker into main
Build & Deploy / Build & push images (push) Successful in 4m58s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 29s
Build & Deploy / Deploy to VM (push) Successful in 2m37s
Build & Deploy / Report a failed deploy (push) Skipped

Reviewed-on: #220
This commit was merged in pull request #220.
This commit is contained in:
2026-10-04 22:05:49 -04:00
11 changed files with 1230 additions and 104 deletions
@@ -0,0 +1,217 @@
"""
Node-page Discord picker (server-26#213 item 3, #109 item 13).
The web no longer takes raw guild/channel IDs: it picks from the org's
Discord servers (discord_guilds resolving to the node's org) and the voice
channels the server bot pushed, and the pick becomes a stream pinned to the
node. These tests pin the org boundary, the directory-only channel rule, the
caps, and that the directory push never clobbers a guild's org link.
"""
import asyncio
import pytest
from fastapi import HTTPException
from app.config import settings
from app.internal import discord_leases as leases
from app.internal import discord_streams as streams
from app.models import CommandPayload
from app.routers import discord_web as web
from app.routers import nodes as nodes_router
from tests.fake_firestore import FakeFirestore
ORG, OTHER, SYS = "org-a", "org-b", "sys-1"
ADMIN = {"uid": "u-admin", "role": "admin"}
OWNER = {"uid": "u-own", "org_id": ORG, "org_role": "owner"}
MEMBER = {"uid": "u-mem", "org_id": ORG, "org_role": "member"}
OPERATOR = {"uid": "u-op", "org_id": ORG, "org_role": "member", "role": "operator", "owned_node_ids": ["n1"]}
STRANGER = {"uid": "u-x", "org_id": OTHER, "org_role": "owner"}
def run(coro):
return asyncio.run(coro)
@pytest.fixture
def fs(monkeypatch):
fake = FakeFirestore.install(monkeypatch)
published: list = []
from app.internal.mqtt_handler import mqtt_handler
monkeypatch.setattr(mqtt_handler, "publish_discord_desired", lambda n, p: published.append((n, p)) or True)
streams._publish_locks.clear()
async def _no_discord(token_ids):
return 0
monkeypatch.setattr(leases, "refresh_membership", _no_discord)
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
fake.published = published
for nid, org in (("n1", ORG), ("n2", ORG), ("nx", OTHER)):
fake.put("nodes", nid, {"node_id": nid, "name": f"Node {nid}", "org_id": org, "status": "online",
"approval_status": "approved", "assigned_system_id": SYS})
fake.put("systems", SYS, {"system_id": SYS, "name": "County P25", "org_id": ORG})
fake.put("discord_guilds", "100", {"guild_id": "100", "org_id": ORG, "name": "Fire Co. 7",
"bot_present": True,
"voice_channels": [{"channel_id": "1001", "name": "Dispatch",
"category": "Radio", "position": 1},
{"channel_id": "1002", "name": "Fireground",
"category": "Radio", "position": 2}]})
fake.put("discord_guilds", "200", {"guild_id": "200", "org_id": OTHER, "name": "Other Org Server",
"voice_channels": [{"channel_id": "2001", "name": "Theirs"}]})
fake.put("bot_tokens", "t1", {"token_id": "t1", "name": "DRB Radio 1", "token": "secret-t1",
"org_id": None, "guild_ids": ["100", "200"], "valid": True,
"application_id": "app1", "lease": None})
return fake
# ---------------------------------------------------------------------------
# Directory push
# ---------------------------------------------------------------------------
def test_directory_push_keeps_org_link(fs):
body = web.GuildDirectory(name="Fire Co. 7 (renamed)",
voice_channels=[web.VoiceChannelIn(channel_id="1003", name="Night")])
run(web.put_guild_directory("100", body, {"service": True}))
g = fs.get("discord_guilds", "100")
assert g["org_id"] == ORG
assert g["guild_name"] == "Fire Co. 7 (renamed)"
assert [c["channel_id"] for c in g["voice_channels"]] == ["1003"]
def test_directory_bot_removed_hides_guild(fs):
run(web.put_guild_directory("100", web.GuildDirectory(present=False), {"service": True}))
opts = run(web.join_options("n1", OWNER))
assert opts["guilds"] == []
def test_directory_rejects_non_snowflake(fs):
with pytest.raises(HTTPException) as e:
run(web.put_guild_directory("abc", web.GuildDirectory(), {"service": True}))
assert e.value.status_code == 400
# ---------------------------------------------------------------------------
# Options — org-scoped
# ---------------------------------------------------------------------------
def test_options_only_this_orgs_servers(fs):
opts = run(web.join_options("n1", OWNER))
assert [g["guild_id"] for g in opts["guilds"]] == ["100"]
g = opts["guilds"][0]
assert [c["name"] for c in g["voice_channels"]] == ["Dispatch", "Fireground"]
assert g["bot"]["ok"] is True
assert opts["can_control"] is True
assert opts["system_name"] == "County P25"
assert "secret" not in repr(opts)
def test_options_unlinked_fallback_follows_bot_rule(fs, monkeypatch):
fs.put("discord_guilds", "300", {"guild_id": "300", "name": "Unlinked", "voice_channels": []})
assert "300" not in [g["guild_id"] for g in run(web.join_options("n1", OWNER))["guilds"]]
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", ORG)
got = {g["guild_id"]: g for g in run(web.join_options("n1", OWNER))["guilds"]}
assert got["300"]["linked"] is False
assert "200" not in got
# An org that explicitly unlinked its server (PR #218) never falls back.
fs.put("discord_guilds", "300", {"guild_id": "300", "org_id": None, "unlinked_at": "2026-10-04T00:00:00+00:00",
"guild_name": "Unlinked", "voice_channels": []})
assert "300" not in [g["guild_id"] for g in run(web.join_options("n1", OWNER))["guilds"]]
def test_options_other_org_node_is_404(fs):
with pytest.raises(HTTPException) as e:
run(web.join_options("nx", OWNER))
assert e.value.status_code == 404
def test_member_reads_but_cannot_control(fs):
assert run(web.join_options("n1", MEMBER))["can_control"] is False
with pytest.raises(HTTPException) as e:
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), MEMBER))
assert e.value.status_code == 403
def test_operator_controls_only_owned_node(fs):
assert run(web.join_options("n1", OPERATOR))["can_control"] is True
assert run(web.join_options("n2", OPERATOR))["can_control"] is False
def test_options_reports_missing_bot_with_invite(fs):
fs.put("bot_tokens", "t1", {**fs.get("bot_tokens", "t1"), "guild_ids": ["200"]})
g = run(web.join_options("n1", OWNER))["guilds"][0]
assert g["bot"]["ok"] is False and g["bot"]["code"] == "no_bot_in_guild"
assert g["bot"]["invite"][0]["invite_url"].startswith("https://discord.com/oauth2/authorize")
# ---------------------------------------------------------------------------
# Start / stop
# ---------------------------------------------------------------------------
def test_start_pins_stream_to_node_with_names(fs):
r = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), OWNER))
assert r["ok"] and r["stream_id"] == "100:1001" and r["token_id"] == "t1"
doc = fs.get("discord_streams", "100:1001")
assert doc["node_id"] == "n1" and doc["org_id"] == ORG and doc["system_id"] == SYS
assert doc["channel_name"] == "Dispatch" and doc["guild_name"] == "Fire Co. 7"
assert doc["created_by_discord_id"] == "web:u-own"
assert fs.published and fs.published[-1][0] == "n1"
opts = run(web.join_options("n1", OWNER))
assert [s["stream_id"] for s in opts["streams"]] == ["100:1001"]
assert opts["guilds"][0]["voice_channels"][0]["stream"]["node_name"] == "Node n1"
def test_start_refuses_other_orgs_server(fs):
with pytest.raises(HTTPException) as e:
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="200", channel_id="2001"), OWNER))
assert e.value.status_code == 404
assert fs.get("discord_streams", "200:2001") is None
def test_start_refuses_channel_not_in_directory(fs):
with pytest.raises(HTTPException) as e:
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="9999"), OWNER))
assert e.value.status_code == 400
def test_start_respects_node_cap(fs, monkeypatch):
monkeypatch.setattr(settings, "discord_node_max_streams", 1)
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), OWNER))
# Re-picking the same channel is idempotent, not a second slot.
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), OWNER))
with pytest.raises(HTTPException) as e:
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1002"), OWNER))
assert e.value.status_code == 409
def test_start_lease_refusal_carries_invite(fs):
fs.put("bot_tokens", "t1", {**fs.get("bot_tokens", "t1"), "guild_ids": ["200"]})
r = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), ADMIN))
assert r.status_code == 409
assert b"no_bot_in_guild" in r.body and b"secret" not in r.body
def test_stop_one_and_scoped_to_node(fs):
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), OWNER))
with pytest.raises(HTTPException) as e:
run(web.stop_node_streams("n2", web.NodeStreamStop(stream_id="100:1001"), ADMIN))
assert e.value.status_code == 404
r = run(web.stop_node_streams("n1", web.NodeStreamStop(stream_id="100:1001"), OWNER))
assert r["stopped"] == ["100:1001"]
assert fs.get("discord_streams", "100:1001")["desired"] == "off"
def test_legacy_raw_id_join_refuses_other_orgs_server(fs):
cmd = CommandPayload(action="discord_join", guild_id="200", channel_id="2001")
with pytest.raises(HTTPException) as e:
run(nodes_router.send_command("n1", cmd, ADMIN))
assert e.value.status_code == 404
def test_stop_rejects_malformed_stream_id():
# "a/b" would otherwise reach Firestore as a document path and 500.
from pydantic import ValidationError
for bad in ("100/1001", "../x", "100:1001/extra", ""):
with pytest.raises(ValidationError):
web.NodeStreamStop(stream_id=bad)
assert web.NodeStreamStop(stream_id="100:1001").stream_id == "100:1001"
assert web.NodeStreamStop().stream_id is None