mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-30 19:41:58 +01:00
681 lines
26 KiB
Python
681 lines
26 KiB
Python
# Copyright © 2025-26 l5yth & contributors
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""Unit tests for :mod:`data.mesh_ingestor.announce` (SPEC MA6).
|
|
|
|
Covers the character-limited announcement message builder and the read-only
|
|
*dogfeed* HTTP client (fetch the instance's own ``/version`` and ``/api/stats``),
|
|
including the fail-soft behaviour on network / shape errors.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import urllib.error
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
import data.mesh_ingestor.announce as announce
|
|
|
|
|
|
class _FakeResponse:
|
|
"""Minimal context-manager stand-in for an ``http.client.HTTPResponse``."""
|
|
|
|
def __init__(self, body: str):
|
|
self._body = body.encode("utf-8")
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_exc):
|
|
return False
|
|
|
|
def read(self):
|
|
return self._body
|
|
|
|
|
|
def _install_fake_http(monkeypatch, url_to_payload):
|
|
"""Patch ``announce``'s ``urlopen`` to serve *url_to_payload* by URL."""
|
|
|
|
def _fake_urlopen(req, timeout=None):
|
|
url = getattr(req, "full_url", req)
|
|
if url not in url_to_payload:
|
|
raise urllib.error.URLError(f"no fake response for {url}")
|
|
return _FakeResponse(json.dumps(url_to_payload[url]))
|
|
|
|
monkeypatch.setattr(announce.urllib.request, "urlopen", _fake_urlopen)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Message builder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildAnnouncementMessage:
|
|
"""Tests for :func:`announce.build_announcement` (MA6)."""
|
|
|
|
def test_message_format_matches_spec(self):
|
|
"""The announcement line matches the SPEC MA6 template exactly."""
|
|
text = announce.build_announcement("meshcore", 12, 50, "https://mesh.example")
|
|
assert text == (
|
|
"MeshCore activity in the last 24h: 12 active nodes, "
|
|
"50 packets/hour. https://mesh.example"
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
("rate", "shown"),
|
|
[(41.667, "42"), (50.0, "50"), (0.042, "0"), (0.5, "1"), (2.5, "3"), (7, "7")],
|
|
)
|
|
def test_message_rounds_the_rate_half_up_to_a_whole_number(self, rate, shown):
|
|
"""The served 3-decimal rate prints as a whole number, halves up (IR3)."""
|
|
text = announce.build_announcement("meshcore", 12, rate, "https://mesh.example")
|
|
assert f"12 active nodes, {shown} packets/hour. " in text
|
|
|
|
def test_message_uses_protocol_display_name(self):
|
|
"""Meshtastic renders its capitalised display name."""
|
|
text = announce.build_announcement("meshtastic", 3, 7, "https://m")
|
|
assert text.startswith("Meshtastic activity in the last 24h:")
|
|
|
|
def test_message_truncates_to_protocol_char_limit(self):
|
|
"""An over-long line is clipped to the protocol's character limit."""
|
|
long_url = "https://" + ("a" * 300) + ".example"
|
|
text = announce.build_announcement("meshtastic", 5, 10, long_url)
|
|
assert len(text) == announce.ANNOUNCE_CHAR_LIMITS["meshtastic"]
|
|
|
|
def test_message_honours_explicit_char_limit(self):
|
|
"""An explicit ``char_limit`` overrides the per-protocol default."""
|
|
text = announce.build_announcement(
|
|
"meshcore", 5, 10, "https://x", char_limit=10
|
|
)
|
|
assert len(text) == 10
|
|
|
|
def test_message_unknown_protocol_uses_fallback_limit(self):
|
|
"""An unrecognised protocol falls back to the default char limit."""
|
|
long_url = "https://" + ("z" * 300)
|
|
text = announce.build_announcement("lxmf", 1, 2, long_url)
|
|
assert text.startswith("Lxmf activity")
|
|
assert len(text) == announce._DEFAULT_CHAR_LIMIT
|
|
|
|
|
|
class TestProtocolDisplayName:
|
|
"""Tests for :func:`announce.protocol_display_name`."""
|
|
|
|
def test_known_protocols(self):
|
|
"""Known keys map to their branded labels."""
|
|
assert announce.protocol_display_name("meshcore") == "MeshCore"
|
|
assert announce.protocol_display_name("meshtastic") == "Meshtastic"
|
|
|
|
def test_unknown_protocol_is_capitalised(self):
|
|
"""An unknown key is capitalised as a best effort."""
|
|
assert announce.protocol_display_name("lxmf") == "Lxmf"
|
|
|
|
def test_empty_protocol_is_blank(self):
|
|
"""An empty/``None`` protocol yields an empty label."""
|
|
assert announce.protocol_display_name("") == ""
|
|
assert announce.protocol_display_name(None) == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dogfeed HTTP client
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDogfeedFetchActivity:
|
|
"""Tests for :func:`announce.fetch_activity` (MA6)."""
|
|
|
|
def test_dogfeed_reads_nodes_and_packets_per_hour(self, monkeypatch):
|
|
"""Returns ``(<protocol>.nodes.day, <protocol>.packets.hour)``."""
|
|
_install_fake_http(
|
|
monkeypatch,
|
|
{
|
|
"https://mesh.example/api/stats": {
|
|
"meshcore": {
|
|
"nodes": {"day": 12, "hour": 3},
|
|
"packets": {"hour": 50},
|
|
},
|
|
"meshtastic": {"nodes": {"day": 99}, "packets": {"hour": 30}},
|
|
},
|
|
},
|
|
)
|
|
assert announce.fetch_activity("https://mesh.example", "meshcore") == (12, 50)
|
|
|
|
def test_dogfeed_returns_none_when_packets_section_missing(self, monkeypatch):
|
|
"""A scope present but lacking a ``packets`` sub-object yields ``None``."""
|
|
_install_fake_http(
|
|
monkeypatch,
|
|
{"https://mesh.example/api/stats": {"meshcore": {"nodes": {"day": 1}}}},
|
|
)
|
|
assert announce.fetch_activity("https://mesh.example", "meshcore") is None
|
|
|
|
def test_dogfeed_returns_none_when_nodes_object_missing(self, monkeypatch):
|
|
"""A scope present but lacking a ``nodes`` sub-object yields ``None``."""
|
|
_install_fake_http(
|
|
monkeypatch,
|
|
{
|
|
"https://mesh.example/api/stats": {
|
|
"meshcore": {"packets": {"hour": 50}},
|
|
},
|
|
},
|
|
)
|
|
assert announce.fetch_activity("https://mesh.example", "meshcore") is None
|
|
|
|
def test_dogfeed_returns_none_on_non_integer_counts(self, monkeypatch):
|
|
"""Non-numeric counts are rejected as malformed."""
|
|
_install_fake_http(
|
|
monkeypatch,
|
|
{
|
|
"https://mesh.example/api/stats": {
|
|
"meshcore": {
|
|
"nodes": {"day": "lots"},
|
|
"packets": {"hour": 50},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
assert announce.fetch_activity("https://mesh.example", "meshcore") is None
|
|
|
|
def test_dogfeed_returns_none_on_non_numeric_packets(self, monkeypatch):
|
|
"""A valid node count but a non-numeric packets/hour yields ``None``."""
|
|
_install_fake_http(
|
|
monkeypatch,
|
|
{
|
|
"https://mesh.example/api/stats": {
|
|
"meshcore": {
|
|
"nodes": {"day": 5},
|
|
"packets": {"hour": "lots"},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
assert announce.fetch_activity("https://mesh.example", "meshcore") is None
|
|
|
|
@pytest.mark.parametrize("rate", [float("nan"), float("inf")])
|
|
def test_dogfeed_returns_none_on_non_finite_packets(self, monkeypatch, rate):
|
|
"""A packets/hour no whole number can print is malformed (IR3)."""
|
|
_install_fake_http(
|
|
monkeypatch,
|
|
{
|
|
"https://mesh.example/api/stats": {
|
|
"meshcore": {"nodes": {"day": 5}, "packets": {"hour": rate}},
|
|
},
|
|
},
|
|
)
|
|
assert announce.fetch_activity("https://mesh.example", "meshcore") is None
|
|
|
|
def test_dogfeed_passes_an_integer_beyond_float_range(self, monkeypatch):
|
|
"""A JSON integer no float can hold is returned and printed whole (IR3)."""
|
|
huge = 10**400
|
|
_install_fake_http(
|
|
monkeypatch,
|
|
{
|
|
"https://mesh.example/api/stats": {
|
|
"meshcore": {"nodes": {"day": 5}, "packets": {"hour": huge}},
|
|
},
|
|
},
|
|
)
|
|
assert announce.fetch_activity("https://mesh.example", "meshcore") == (5, huge)
|
|
text = announce.build_announcement(
|
|
"meshcore", 5, huge, "https://m", char_limit=1000
|
|
)
|
|
assert f"5 active nodes, {huge} packets/hour. " in text
|
|
|
|
def test_dogfeed_returns_none_on_network_error(self, monkeypatch):
|
|
"""A transport error fails soft to ``None``."""
|
|
|
|
def _boom(req, timeout=None):
|
|
raise urllib.error.URLError("boom")
|
|
|
|
monkeypatch.setattr(announce.urllib.request, "urlopen", _boom)
|
|
assert announce.fetch_activity("https://mesh.example", "meshtastic") is None
|
|
|
|
|
|
class TestDogfeedFetchPrivateMode:
|
|
"""Tests for :func:`announce.fetch_private_mode` (MA7 privacy gate)."""
|
|
|
|
@pytest.mark.parametrize("flag", [True, False])
|
|
def test_dogfeed_reads_private_mode_flag(self, monkeypatch, flag):
|
|
"""Reads ``config.private_mode`` from ``/version``."""
|
|
_install_fake_http(
|
|
monkeypatch,
|
|
{"https://mesh.example/version": {"config": {"private_mode": flag}}},
|
|
)
|
|
assert announce.fetch_private_mode("https://mesh.example") is flag
|
|
|
|
def test_dogfeed_missing_flag_returns_none(self, monkeypatch):
|
|
"""A response without the flag yields ``None`` (caller fails closed)."""
|
|
_install_fake_http(
|
|
monkeypatch,
|
|
{"https://mesh.example/version": {"config": {"site_name": "x"}}},
|
|
)
|
|
assert announce.fetch_private_mode("https://mesh.example") is None
|
|
|
|
def test_dogfeed_missing_config_block_returns_none(self, monkeypatch):
|
|
"""A response without a ``config`` block yields ``None``."""
|
|
_install_fake_http(
|
|
monkeypatch, {"https://mesh.example/version": {"version": "0.7.3"}}
|
|
)
|
|
assert announce.fetch_private_mode("https://mesh.example") is None
|
|
|
|
def test_dogfeed_network_error_returns_none(self, monkeypatch):
|
|
"""A transport error fails soft to ``None`` (fail-closed input)."""
|
|
|
|
def _boom(req, timeout=None):
|
|
raise urllib.error.URLError("boom")
|
|
|
|
monkeypatch.setattr(announce.urllib.request, "urlopen", _boom)
|
|
assert announce.fetch_private_mode("https://mesh.example") is None
|
|
|
|
|
|
class TestDogfeedGetJson:
|
|
"""Tests for the low-level :func:`announce._get_json` helper."""
|
|
|
|
def test_dogfeed_non_object_json_returns_none(self, monkeypatch):
|
|
"""A JSON array (not an object) is rejected."""
|
|
_install_fake_http(monkeypatch, {"https://mesh.example/x": [1, 2, 3]})
|
|
assert announce._get_json("https://mesh.example/x") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scheduling & gating (SPEC MA7/MA8)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _RecordingProvider:
|
|
"""Fake provider that records ``send_channel_announcement`` calls."""
|
|
|
|
def __init__(self):
|
|
self.sent = []
|
|
|
|
def send_channel_announcement(self, iface, text):
|
|
self.sent.append((iface, text))
|
|
|
|
|
|
def _stub_dogfeed(monkeypatch, *, private=False, numbers=(12, 50)):
|
|
"""Patch the dogfeed fetchers to controlled return values (no HTTP)."""
|
|
monkeypatch.setattr(announce, "fetch_private_mode", lambda url, **_kw: private)
|
|
monkeypatch.setattr(announce, "fetch_activity", lambda url, proto, **_kw: numbers)
|
|
|
|
|
|
class TestAnnounceDue:
|
|
"""Tests for :func:`announce.announce_due` (SPEC MA7 d / MA8)."""
|
|
|
|
def test_gate_not_due_before_24h_elapsed(self):
|
|
"""Withheld until 24 h after start."""
|
|
now = 1_000_000
|
|
assert (
|
|
announce.announce_due(
|
|
start_monotonic=now - 1000, last_announce=None, now=now
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_due_after_24h_elapsed(self):
|
|
"""Eligible once 24 h have elapsed since start."""
|
|
now = 1_000_000
|
|
assert (
|
|
announce.announce_due(
|
|
start_monotonic=now - 90_000, last_announce=None, now=now
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_cadence_interval_blocks_second_within_24h(self):
|
|
"""A recent cycle blocks the next until the 24 h interval passes."""
|
|
now = 1_000_000
|
|
assert (
|
|
announce.announce_due(
|
|
start_monotonic=now - 200_000, last_announce=now - 3600, now=now
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_cadence_due_again_after_24h_interval(self):
|
|
"""Due again once 24 h have elapsed since the previous cycle."""
|
|
now = 1_000_000
|
|
assert (
|
|
announce.announce_due(
|
|
start_monotonic=now - 200_000, last_announce=now - 90_000, now=now
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
class TestSendAnnouncementToInstance:
|
|
"""Tests for :func:`announce.send_announcement_to_instance` (MA6/MA7)."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _permit(self, permit_tx):
|
|
"""Isolate these tests from the transmit policy.
|
|
|
|
Transmission is off by default, so without this every test here would
|
|
stop at the gate and pass vacuously. The gate itself is covered by
|
|
``test_refuses_when_transmit_policy_closed`` below and by
|
|
``tests/test_tx_policy_unit.py``.
|
|
"""
|
|
|
|
def test_sends_the_built_line_when_public(self, monkeypatch):
|
|
"""A public instance receives the dogfed announcement line."""
|
|
_stub_dogfeed(monkeypatch, private=False, numbers=(12, 50))
|
|
provider = _RecordingProvider()
|
|
sent = announce.send_announcement_to_instance(
|
|
provider, "IFACE", "https://mesh.example", "meshcore"
|
|
)
|
|
assert sent is True
|
|
assert provider.sent == [
|
|
(
|
|
"IFACE",
|
|
"MeshCore activity in the last 24h: 12 active nodes, "
|
|
"50 packets/hour. https://mesh.example",
|
|
)
|
|
]
|
|
|
|
def test_private_instance_is_skipped_fail_closed(self, monkeypatch):
|
|
"""A private instance is skipped (nothing transmitted)."""
|
|
_stub_dogfeed(monkeypatch, private=True)
|
|
provider = _RecordingProvider()
|
|
assert (
|
|
announce.send_announcement_to_instance(
|
|
provider, "I", "https://m", "meshcore"
|
|
)
|
|
is False
|
|
)
|
|
assert provider.sent == []
|
|
|
|
def test_private_fetch_error_fails_closed(self, monkeypatch):
|
|
"""An unknown privacy state (None) is treated as private (fail-closed)."""
|
|
_stub_dogfeed(monkeypatch, private=None)
|
|
provider = _RecordingProvider()
|
|
assert (
|
|
announce.send_announcement_to_instance(
|
|
provider, "I", "https://m", "meshcore"
|
|
)
|
|
is False
|
|
)
|
|
assert provider.sent == []
|
|
|
|
def test_skips_when_activity_numbers_unavailable(self, monkeypatch):
|
|
"""No announcement when the stats dogfeed yields nothing."""
|
|
monkeypatch.setattr(announce, "fetch_private_mode", lambda url, **_kw: False)
|
|
monkeypatch.setattr(announce, "fetch_activity", lambda url, proto, **_kw: None)
|
|
provider = _RecordingProvider()
|
|
assert (
|
|
announce.send_announcement_to_instance(
|
|
provider, "I", "https://m", "meshcore"
|
|
)
|
|
is False
|
|
)
|
|
assert provider.sent == []
|
|
|
|
def test_skips_when_provider_cannot_send(self, monkeypatch):
|
|
"""A provider without send_channel_announcement is a no-op."""
|
|
_stub_dogfeed(monkeypatch, private=False, numbers=(1, 2))
|
|
assert (
|
|
announce.send_announcement_to_instance(
|
|
object(), "I", "https://m", "meshcore"
|
|
)
|
|
is False
|
|
)
|
|
|
|
def test_logs_transmitting_with_full_context(self, monkeypatch):
|
|
"""Every outbound announcement emits an ``announce.tx`` debug line so the
|
|
TX is traceable end-to-end (target, protocol, numbers, text)."""
|
|
_stub_dogfeed(monkeypatch, private=False, numbers=(12, 50))
|
|
logs = []
|
|
monkeypatch.setattr(
|
|
announce.config,
|
|
"_debug_log",
|
|
lambda message, **meta: logs.append((message, meta)),
|
|
)
|
|
announce.send_announcement_to_instance(
|
|
_RecordingProvider(), "IFACE", "https://mesh.example", "meshcore"
|
|
)
|
|
transmit = [
|
|
meta for msg, meta in logs if msg == "Activity announcement transmitting"
|
|
]
|
|
assert len(transmit) == 1
|
|
meta = transmit[0]
|
|
assert meta["context"] == "announce.tx"
|
|
assert meta["url"] == "https://mesh.example"
|
|
assert meta["protocol"] == "meshcore"
|
|
assert meta["active_nodes"] == 12
|
|
assert meta["packets_per_hour"] == 50
|
|
assert "packets/hour" in meta["text"]
|
|
|
|
def test_logs_skip_reason_when_private(self, monkeypatch):
|
|
"""A skipped (private/unreachable) instance emits a skip debug line."""
|
|
_stub_dogfeed(monkeypatch, private=True)
|
|
logs = []
|
|
monkeypatch.setattr(
|
|
announce.config,
|
|
"_debug_log",
|
|
lambda message, **meta: logs.append((message, meta)),
|
|
)
|
|
announce.send_announcement_to_instance(
|
|
_RecordingProvider(), "I", "https://m", "meshcore"
|
|
)
|
|
assert any("skipped: instance private" in msg for msg, _meta in logs)
|
|
|
|
def test_refuses_when_transmit_policy_closed(self, monkeypatch):
|
|
"""The exported send path re-checks the policy itself.
|
|
|
|
``send_announcement_to_instance`` is in ``__all__`` and dispatches to a
|
|
duck-typed provider method, so a provider written without the gate must
|
|
still not be able to transmit.
|
|
"""
|
|
monkeypatch.setattr(announce.config, "TX_ENABLED", False)
|
|
_stub_dogfeed(monkeypatch)
|
|
provider = _RecordingProvider()
|
|
assert (
|
|
announce.send_announcement_to_instance(
|
|
provider, "IFACE", "https://x", "meshtastic"
|
|
)
|
|
is False
|
|
)
|
|
assert provider.sent == []
|
|
|
|
|
|
class TestRunAnnouncementCycle:
|
|
"""Tests for :func:`announce.run_announcement_cycle` (SPEC MA8 per-domain)."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _permit(self, permit_tx):
|
|
"""Isolate these tests from the transmit policy.
|
|
|
|
Transmission is off by default, so without this every test here would
|
|
stop at the gate and pass vacuously. The gate itself is covered by
|
|
``test_refuses_when_transmit_policy_closed`` below and by
|
|
``tests/test_tx_policy_unit.py``.
|
|
"""
|
|
|
|
def test_domains_announces_each_configured_instance(self, monkeypatch):
|
|
"""Every configured instance is announced to, with its own link."""
|
|
monkeypatch.setattr(
|
|
announce.config, "INSTANCES", (("https://a", ""), ("https://b", ""))
|
|
)
|
|
monkeypatch.setattr(announce.config, "PROTOCOL", "meshtastic")
|
|
_stub_dogfeed(monkeypatch, private=False, numbers=(3, 7))
|
|
provider = _RecordingProvider()
|
|
assert announce.run_announcement_cycle(provider, "IFACE") is True
|
|
urls = [text.rsplit(" ", 1)[-1] for _iface, text in provider.sent]
|
|
assert urls == ["https://a", "https://b"]
|
|
|
|
def test_cycle_survives_one_instance_error(self, monkeypatch):
|
|
"""A failure against one instance never aborts the others."""
|
|
monkeypatch.setattr(
|
|
announce.config, "INSTANCES", (("https://bad", ""), ("https://good", ""))
|
|
)
|
|
monkeypatch.setattr(announce.config, "PROTOCOL", "meshcore")
|
|
|
|
def _priv(url, **_kw):
|
|
if url == "https://bad":
|
|
raise RuntimeError("boom")
|
|
return False
|
|
|
|
monkeypatch.setattr(announce, "fetch_private_mode", _priv)
|
|
monkeypatch.setattr(
|
|
announce, "fetch_activity", lambda url, proto, **_kw: (1, 1)
|
|
)
|
|
provider = _RecordingProvider()
|
|
assert announce.run_announcement_cycle(provider, "I") is True
|
|
assert [t.rsplit(" ", 1)[-1] for _i, t in provider.sent] == ["https://good"]
|
|
|
|
|
|
class TestMaybeRunAnnouncements:
|
|
"""Tests for the daemon entry point :func:`announce.maybe_run_announcements`."""
|
|
|
|
def test_gate_noop_when_not_enabled(self, monkeypatch):
|
|
"""No cycle runs and last_announce is unchanged when RX_ONLY forbids TX."""
|
|
monkeypatch.setattr(announce.config, "TX_ENABLED", True)
|
|
monkeypatch.setattr(announce.config, "TX_ANNOUNCE", True)
|
|
monkeypatch.setattr(announce.config, "RX_ONLY", True)
|
|
ran = []
|
|
monkeypatch.setattr(
|
|
announce, "run_announcement_cycle", lambda *a, **k: ran.append(True)
|
|
)
|
|
result = announce.maybe_run_announcements(
|
|
"P", "I", start_monotonic=0, last_announce=42.0, now=1_000_000
|
|
)
|
|
assert result == 42.0
|
|
assert ran == []
|
|
|
|
def test_cadence_noop_when_not_due(self, monkeypatch):
|
|
"""No cycle runs before the 24 h delay; last_announce is unchanged."""
|
|
monkeypatch.setattr(announce.config, "TX_ENABLED", True)
|
|
monkeypatch.setattr(announce.config, "TX_ANNOUNCE", True)
|
|
monkeypatch.setattr(announce.config, "RX_ONLY", False)
|
|
ran = []
|
|
monkeypatch.setattr(
|
|
announce, "run_announcement_cycle", lambda *a, **k: ran.append(True)
|
|
)
|
|
now = 1_000_000
|
|
result = announce.maybe_run_announcements(
|
|
"P", "I", start_monotonic=now - 1000, last_announce=None, now=now
|
|
)
|
|
assert result is None
|
|
assert ran == []
|
|
|
|
def test_cadence_runs_cycle_and_advances_timestamp(self, monkeypatch):
|
|
"""When due, the cycle runs and last_announce advances to now."""
|
|
monkeypatch.setattr(announce.config, "TX_ENABLED", True)
|
|
monkeypatch.setattr(announce.config, "TX_ANNOUNCE", True)
|
|
monkeypatch.setattr(announce.config, "RX_ONLY", False)
|
|
ran = []
|
|
monkeypatch.setattr(
|
|
announce,
|
|
"run_announcement_cycle",
|
|
lambda provider, iface, **k: ran.append((provider, iface)) or True,
|
|
)
|
|
now = 1_000_000
|
|
result = announce.maybe_run_announcements(
|
|
"P", "IFACE", start_monotonic=now - 90_000, last_announce=None, now=now
|
|
)
|
|
assert result == now
|
|
assert ran == [("P", "IFACE")]
|
|
|
|
def test_defaults_now_to_monotonic_not_wall_clock(self, monkeypatch):
|
|
"""With ``now`` omitted the *monotonic* clock is used, never wall clock.
|
|
|
|
Guards the NTP-step defect: a wall-clock basis lets a clock jump on an
|
|
RTC-less host clear the 24 h anti-spam delay instantly.
|
|
"""
|
|
monkeypatch.setattr(announce.config, "TX_ENABLED", True)
|
|
monkeypatch.setattr(announce.config, "TX_ANNOUNCE", True)
|
|
monkeypatch.setattr(announce.config, "RX_ONLY", False)
|
|
monkeypatch.setattr(announce.time, "monotonic", lambda: 500_000.0)
|
|
monkeypatch.setattr(
|
|
announce.time, "time", lambda: pytest.fail("wall clock consulted")
|
|
)
|
|
ran = []
|
|
monkeypatch.setattr(
|
|
announce, "run_announcement_cycle", lambda *a, **k: ran.append(True)
|
|
)
|
|
# 500_000 s since start comfortably exceeds the 24 h delay.
|
|
result = announce.maybe_run_announcements(
|
|
"P", "I", start_monotonic=0.0, last_announce=None
|
|
)
|
|
assert result == 500_000.0
|
|
assert ran == [True]
|
|
|
|
@pytest.mark.parametrize(
|
|
("flags", "expected_gate"),
|
|
[
|
|
({}, "TX_ENABLED"),
|
|
({"TX_ANNOUNCE": True}, "TX_ENABLED"),
|
|
({"TX_ENABLED": True}, "TX_ANNOUNCE"),
|
|
({"TX_ENABLED": True, "TX_ANNOUNCE": True, "RX_ONLY": True}, "RX_ONLY"),
|
|
],
|
|
)
|
|
def test_suppressed_paths_run_no_cycle_and_name_the_gate(
|
|
self, monkeypatch, capsys, flags, expected_gate
|
|
):
|
|
"""Every closed-gate path is a silent-on-air no-op that logs *why*.
|
|
|
|
The default (no flags set) is the case every deployment now hits, and it
|
|
was previously exercised only at the predicate level — never through the
|
|
daemon entry point, so a reordering that ran the cycle before the gate
|
|
would not have been caught.
|
|
"""
|
|
monkeypatch.setattr(announce.config, "DEBUG", True)
|
|
for name in ("TX_ENABLED", "TX_ANNOUNCE", "RX_ONLY"):
|
|
monkeypatch.setattr(announce.config, name, flags.get(name, False))
|
|
ran = []
|
|
monkeypatch.setattr(
|
|
announce, "run_announcement_cycle", lambda *a, **k: ran.append(True)
|
|
)
|
|
result = announce.maybe_run_announcements(
|
|
"P", "I", start_monotonic=0, last_announce=42.0, now=1_000_000
|
|
)
|
|
assert result == 42.0
|
|
assert ran == []
|
|
assert f"blocked_by={expected_gate!r}" in capsys.readouterr().out
|
|
|
|
|
|
def test_policy_is_named_only_when_it_blocks_a_due_announcement(monkeypatch):
|
|
"""The suppression line fires on a real attempt, not on every loop (RE-A8).
|
|
|
|
An announcement is due at most once per 24 h, so checking the policy before
|
|
the due check logged one line a minute for something that was never going
|
|
to be sent. The startup "Transmit policy resolved" line already carries the
|
|
diagnosis this message was added for.
|
|
"""
|
|
logged = []
|
|
monkeypatch.setattr(
|
|
announce.config,
|
|
"_debug_log",
|
|
lambda msg, **kw: (
|
|
logged.append(msg) if kw.get("context") == "announce.tx" else None
|
|
),
|
|
)
|
|
monkeypatch.setattr(announce.tx_policy, "announcements_permitted", lambda: False)
|
|
|
|
# Not due yet: the gate is closed, but nothing was going to be sent.
|
|
monkeypatch.setattr(announce, "announce_due", lambda **_kw: False)
|
|
announce.maybe_run_announcements(
|
|
object(), object(), start_monotonic=0.0, last_announce=None, now=60.0
|
|
)
|
|
assert logged == []
|
|
|
|
# Due now: the gate is what stopped it, so name it.
|
|
monkeypatch.setattr(announce, "announce_due", lambda **_kw: True)
|
|
announce.maybe_run_announcements(
|
|
object(), object(), start_monotonic=0.0, last_announce=None, now=90_000.0
|
|
)
|
|
assert len(logged) == 1
|
|
assert "suppressed by transmit policy" in logged[0]
|