Files
server-26/drb-c2-core/tests/test_node_sweeper.py
Logan CusanoandClaude Opus 5.5 a31a8df17c Discord radio channels: wire leases + desired-state streams (server-26#203, #204)
c2-core
- discord_streams/discord_leases wired in: POST /discord/streams,
  POST /discord/guilds/{id}/stop, GET /discord/guilds/{id}/overview,
  GET /discord/streams/{id}, PUT /discord/guilds/{id}/link (admin),
  GET /discord/node/{node}/tokens/{id} (node key, lease holder only).
- Retained nodes/{id}/discord/desired (token ids, never token strings),
  nodes/+/discord/actual subscribed and handled; per-node publish lock keeps
  the retained list the newest; failed publishes retried by the reconciler.
- Tokens freed only after the node acks logout (or offline past
  discord_lease_offline_release_seconds) - sweeper and C2 startup no longer
  release leases.
- /nodes/{id}/command discord_join/leave map onto streams; CommandPayload
  keeps preferred_token_id. /tokens: admin-only list, validate on add against
  Discord, refresh, flush via transactional force release.
- Guild -> org via discord_guilds, unlinked guilds fall back to
  DISCORD_UNLINKED_GUILD_ORG_ID (default "founding").
- tests/fake_firestore.py: optimistic-transaction fake; 27 lease/race tests.

discord bot
- /join, /leave, /status, autocomplete scoped to the calling guild; /leave
  never touches another server; Manage Channels default permission;
  /join edits its reply with the node's real result; /help copy fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:05:26 -04:00

157 lines
5.6 KiB
Python

"""
Unit tests for node_sweeper — datetime comparison logic and Firestore update gating.
Firestore and asyncio.to_thread are mocked — no real DB required.
"""
import pytest
import asyncio
from datetime import datetime, timezone, timedelta
from unittest.mock import AsyncMock, patch, MagicMock
from app.internal.node_sweeper import _sweep
def _node(node_id, status, age_seconds):
"""Helper: build a node dict with last_seen age_seconds ago (tz-aware)."""
return {
"node_id": node_id,
"status": status,
"last_seen": datetime.now(timezone.utc) - timedelta(seconds=age_seconds),
}
def _node_naive(node_id, status, age_seconds):
"""Helper: tz-naive last_seen (simulates some Firestore SDK versions)."""
return {
"node_id": node_id,
"status": status,
"last_seen": datetime.utcnow() - timedelta(seconds=age_seconds),
}
# ---------------------------------------------------------------------------
# Core sweep logic
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stale_online_node_marked_offline():
nodes = [_node("node-01", "online", age_seconds=120)]
# The sweeper no longer frees Discord tokens (node-26#25) — the Discord
# reconciler does, after the lease grace. See test_discord_leases.py.
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
patch("app.internal.node_sweeper.fstore") as mock_fstore:
mock_fstore.doc_update = AsyncMock()
await _sweep()
mock_fstore.doc_update.assert_called_once_with(
"nodes", "node-01", {"status": "offline"}
)
@pytest.mark.asyncio
async def test_stale_recording_node_marked_offline():
nodes = [_node("node-02", "recording", age_seconds=200)]
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
patch("app.internal.node_sweeper.fstore") as mock_fstore:
mock_fstore.doc_update = AsyncMock()
await _sweep()
mock_fstore.doc_update.assert_called_once_with(
"nodes", "node-02", {"status": "offline"}
)
@pytest.mark.asyncio
async def test_fresh_node_not_touched():
nodes = [_node("node-03", "online", age_seconds=10)]
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
patch("app.internal.node_sweeper.fstore") as mock_fstore:
mock_fstore.doc_update = AsyncMock()
await _sweep()
mock_fstore.doc_update.assert_not_called()
@pytest.mark.asyncio
async def test_already_offline_node_skipped():
"""Offline nodes must be skipped even if last_seen is ancient."""
nodes = [_node("node-04", "offline", age_seconds=9999)]
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
patch("app.internal.node_sweeper.fstore") as mock_fstore:
mock_fstore.doc_update = AsyncMock()
await _sweep()
mock_fstore.doc_update.assert_not_called()
@pytest.mark.asyncio
async def test_node_with_no_last_seen_skipped():
nodes = [{"node_id": "node-05", "status": "online", "last_seen": None}]
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
patch("app.internal.node_sweeper.fstore") as mock_fstore:
mock_fstore.doc_update = AsyncMock()
await _sweep()
mock_fstore.doc_update.assert_not_called()
# ---------------------------------------------------------------------------
# Timezone edge cases
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tz_naive_last_seen_is_handled():
"""Firestore may return tz-naive datetimes; sweeper must not crash."""
nodes = [_node_naive("node-06", "online", age_seconds=120)]
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
patch("app.internal.node_sweeper.fstore") as mock_fstore:
mock_fstore.doc_update = AsyncMock()
await _sweep()
mock_fstore.doc_update.assert_called_once_with(
"nodes", "node-06", {"status": "offline"}
)
@pytest.mark.asyncio
async def test_tz_naive_fresh_node_not_touched():
nodes = [_node_naive("node-07", "online", age_seconds=5)]
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
patch("app.internal.node_sweeper.fstore") as mock_fstore:
mock_fstore.doc_update = AsyncMock()
await _sweep()
mock_fstore.doc_update.assert_not_called()
# ---------------------------------------------------------------------------
# Batch behaviour
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_only_stale_nodes_updated_in_batch():
nodes = [
_node("node-08", "online", age_seconds=200), # stale → offline
_node("node-09", "online", age_seconds=5), # fresh → skip
_node("node-10", "offline", age_seconds=500), # already offline → skip
_node("node-11", "recording", age_seconds=150), # stale recording → offline
]
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
patch("app.internal.node_sweeper.fstore") as mock_fstore:
mock_fstore.doc_update = AsyncMock()
await _sweep()
assert mock_fstore.doc_update.call_count == 2
updated_ids = {call.args[1] for call in mock_fstore.doc_update.call_args_list}
assert updated_ids == {"node-08", "node-11"}
# Marking a node offline must NOT free its radio-bot tokens: the node may
# still be logged in on them (node-26#25). Only status was written.
assert all(call.args[2] == {"status": "offline"} for call in mock_fstore.doc_update.call_args_list)