|
208e970…
|
lmata
|
1 |
""" |
|
208e970…
|
lmata
|
2 |
Tests for navegador v0.6 cluster issues: |
|
208e970…
|
lmata
|
3 |
|
|
208e970…
|
lmata
|
4 |
#49 — DistributedLock (locking.py) |
|
208e970…
|
lmata
|
5 |
#50 — CheckpointManager (checkpoint.py) |
|
208e970…
|
lmata
|
6 |
#51 — SwarmDashboard (observability.py) |
|
208e970…
|
lmata
|
7 |
#52 — MessageBus (messaging.py) |
|
208e970…
|
lmata
|
8 |
#57 — FossilLiveAdapter (fossil_live.py) |
|
208e970…
|
lmata
|
9 |
|
|
208e970…
|
lmata
|
10 |
All Redis and Fossil operations are mocked so no real infrastructure is needed. |
|
208e970…
|
lmata
|
11 |
""" |
|
208e970…
|
lmata
|
12 |
|
|
208e970…
|
lmata
|
13 |
from __future__ import annotations |
|
208e970…
|
lmata
|
14 |
|
|
208e970…
|
lmata
|
15 |
import json |
|
208e970…
|
lmata
|
16 |
import time |
|
208e970…
|
lmata
|
17 |
from pathlib import Path |
|
208e970…
|
lmata
|
18 |
from unittest.mock import MagicMock, patch |
|
208e970…
|
lmata
|
19 |
|
|
208e970…
|
lmata
|
20 |
import pytest |
|
208e970…
|
lmata
|
21 |
|
|
208e970…
|
lmata
|
22 |
|
|
208e970…
|
lmata
|
23 |
# ── Helpers ─────────────────────────────────────────────────────────────────── |
|
208e970…
|
lmata
|
24 |
|
|
208e970…
|
lmata
|
25 |
|
|
208e970…
|
lmata
|
26 |
def _make_store(): |
|
208e970…
|
lmata
|
27 |
store = MagicMock() |
|
208e970…
|
lmata
|
28 |
store.query.return_value = MagicMock(result_set=[]) |
|
208e970…
|
lmata
|
29 |
store.node_count.return_value = 5 |
|
208e970…
|
lmata
|
30 |
store.edge_count.return_value = 3 |
|
208e970…
|
lmata
|
31 |
return store |
|
208e970…
|
lmata
|
32 |
|
|
208e970…
|
lmata
|
33 |
|
|
208e970…
|
lmata
|
34 |
def _make_redis(): |
|
208e970…
|
lmata
|
35 |
"""Return a MagicMock that behaves like a minimal Redis client.""" |
|
208e970…
|
lmata
|
36 |
r = MagicMock() |
|
208e970…
|
lmata
|
37 |
_store: dict = {} |
|
208e970…
|
lmata
|
38 |
_sets: dict = {} |
|
208e970…
|
lmata
|
39 |
_lists: dict = {} |
|
208e970…
|
lmata
|
40 |
_expiry: dict = {} |
|
208e970…
|
lmata
|
41 |
|
|
208e970…
|
lmata
|
42 |
def _set(key, value, nx=False, ex=None): |
|
208e970…
|
lmata
|
43 |
if nx and key in _store: |
|
208e970…
|
lmata
|
44 |
return False |
|
208e970…
|
lmata
|
45 |
_store[key] = value |
|
208e970…
|
lmata
|
46 |
if ex is not None: |
|
208e970…
|
lmata
|
47 |
_expiry[key] = ex |
|
208e970…
|
lmata
|
48 |
return True |
|
208e970…
|
lmata
|
49 |
|
|
208e970…
|
lmata
|
50 |
def _setex(key, ttl, value): |
|
208e970…
|
lmata
|
51 |
_store[key] = value |
|
208e970…
|
lmata
|
52 |
_expiry[key] = ttl |
|
208e970…
|
lmata
|
53 |
return True |
|
208e970…
|
lmata
|
54 |
|
|
208e970…
|
lmata
|
55 |
def _get(key): |
|
208e970…
|
lmata
|
56 |
return _store.get(key) |
|
208e970…
|
lmata
|
57 |
|
|
208e970…
|
lmata
|
58 |
def _delete(key): |
|
208e970…
|
lmata
|
59 |
_store.pop(key, None) |
|
208e970…
|
lmata
|
60 |
|
|
208e970…
|
lmata
|
61 |
def _keys(pattern): |
|
208e970…
|
lmata
|
62 |
# Very simple glob: support trailing * |
|
208e970…
|
lmata
|
63 |
prefix = pattern.rstrip("*") |
|
208e970…
|
lmata
|
64 |
return [k for k in _store if k.startswith(prefix)] |
|
208e970…
|
lmata
|
65 |
|
|
208e970…
|
lmata
|
66 |
def _sadd(key, *members): |
|
208e970…
|
lmata
|
67 |
_sets.setdefault(key, set()).update(members) |
|
208e970…
|
lmata
|
68 |
|
|
208e970…
|
lmata
|
69 |
def _smembers(key): |
|
208e970…
|
lmata
|
70 |
return _sets.get(key, set()) |
|
208e970…
|
lmata
|
71 |
|
|
208e970…
|
lmata
|
72 |
def _rpush(key, value): |
|
208e970…
|
lmata
|
73 |
_lists.setdefault(key, []).append(value) |
|
208e970…
|
lmata
|
74 |
|
|
208e970…
|
lmata
|
75 |
def _lrange(key, start, end): |
|
208e970…
|
lmata
|
76 |
items = _lists.get(key, []) |
|
208e970…
|
lmata
|
77 |
if end == -1: |
|
208e970…
|
lmata
|
78 |
return items[start:] |
|
208e970…
|
lmata
|
79 |
return items[start: end + 1] |
|
208e970…
|
lmata
|
80 |
|
|
208e970…
|
lmata
|
81 |
r.set.side_effect = _set |
|
208e970…
|
lmata
|
82 |
r.setex.side_effect = _setex |
|
208e970…
|
lmata
|
83 |
r.get.side_effect = _get |
|
208e970…
|
lmata
|
84 |
r.delete.side_effect = _delete |
|
208e970…
|
lmata
|
85 |
r.keys.side_effect = _keys |
|
208e970…
|
lmata
|
86 |
r.sadd.side_effect = _sadd |
|
208e970…
|
lmata
|
87 |
r.smembers.side_effect = _smembers |
|
208e970…
|
lmata
|
88 |
r.rpush.side_effect = _rpush |
|
208e970…
|
lmata
|
89 |
r.lrange.side_effect = _lrange |
|
208e970…
|
lmata
|
90 |
return r |
|
208e970…
|
lmata
|
91 |
|
|
208e970…
|
lmata
|
92 |
|
|
208e970…
|
lmata
|
93 |
# ============================================================================= |
|
208e970…
|
lmata
|
94 |
# #49 — DistributedLock |
|
208e970…
|
lmata
|
95 |
# ============================================================================= |
|
208e970…
|
lmata
|
96 |
|
|
208e970…
|
lmata
|
97 |
|
|
208e970…
|
lmata
|
98 |
class TestDistributedLock: |
|
208e970…
|
lmata
|
99 |
def test_acquire_release(self): |
|
208e970…
|
lmata
|
100 |
from navegador.cluster.locking import DistributedLock |
|
208e970…
|
lmata
|
101 |
|
|
208e970…
|
lmata
|
102 |
r = _make_redis() |
|
208e970…
|
lmata
|
103 |
lock = DistributedLock("redis://localhost", "my-lock", _redis_client=r) |
|
208e970…
|
lmata
|
104 |
acquired = lock.acquire() |
|
208e970…
|
lmata
|
105 |
assert acquired is True |
|
208e970…
|
lmata
|
106 |
assert lock._token is not None |
|
208e970…
|
lmata
|
107 |
lock.release() |
|
208e970…
|
lmata
|
108 |
assert lock._token is None |
|
208e970…
|
lmata
|
109 |
# Key should have been deleted |
|
208e970…
|
lmata
|
110 |
assert r.get("navegador:lock:my-lock") is None |
|
208e970…
|
lmata
|
111 |
|
|
208e970…
|
lmata
|
112 |
def test_acquire_twice_fails(self): |
|
208e970…
|
lmata
|
113 |
from navegador.cluster.locking import DistributedLock |
|
208e970…
|
lmata
|
114 |
|
|
208e970…
|
lmata
|
115 |
r = _make_redis() |
|
208e970…
|
lmata
|
116 |
lock1 = DistributedLock("redis://localhost", "shared", _redis_client=r) |
|
208e970…
|
lmata
|
117 |
lock2 = DistributedLock("redis://localhost", "shared", _redis_client=r) |
|
208e970…
|
lmata
|
118 |
|
|
208e970…
|
lmata
|
119 |
assert lock1.acquire() is True |
|
208e970…
|
lmata
|
120 |
assert lock2.acquire() is False # lock1 holds it |
|
208e970…
|
lmata
|
121 |
lock1.release() |
|
208e970…
|
lmata
|
122 |
|
|
208e970…
|
lmata
|
123 |
def test_context_manager_acquires_and_releases(self): |
|
208e970…
|
lmata
|
124 |
from navegador.cluster.locking import DistributedLock |
|
208e970…
|
lmata
|
125 |
|
|
208e970…
|
lmata
|
126 |
r = _make_redis() |
|
208e970…
|
lmata
|
127 |
lock = DistributedLock("redis://localhost", "ctx-lock", _redis_client=r) |
|
208e970…
|
lmata
|
128 |
with lock: |
|
208e970…
|
lmata
|
129 |
assert lock._token is not None |
|
208e970…
|
lmata
|
130 |
assert lock._token is None |
|
208e970…
|
lmata
|
131 |
|
|
208e970…
|
lmata
|
132 |
def test_context_manager_raises_lock_timeout(self): |
|
208e970…
|
lmata
|
133 |
from navegador.cluster.locking import DistributedLock, LockTimeout |
|
208e970…
|
lmata
|
134 |
|
|
208e970…
|
lmata
|
135 |
r = _make_redis() |
|
208e970…
|
lmata
|
136 |
# Pre-occupy the lock |
|
208e970…
|
lmata
|
137 |
holder = DistributedLock("redis://localhost", "busy-lock", timeout=1, _redis_client=r) |
|
208e970…
|
lmata
|
138 |
holder.acquire() |
|
208e970…
|
lmata
|
139 |
|
|
208e970…
|
lmata
|
140 |
waiter = DistributedLock( |
|
208e970…
|
lmata
|
141 |
"redis://localhost", "busy-lock", timeout=1, retry_interval=0.05, _redis_client=r |
|
208e970…
|
lmata
|
142 |
) |
|
208e970…
|
lmata
|
143 |
with pytest.raises(LockTimeout): |
|
208e970…
|
lmata
|
144 |
with waiter: |
|
208e970…
|
lmata
|
145 |
pass |
|
208e970…
|
lmata
|
146 |
|
|
208e970…
|
lmata
|
147 |
def test_release_noop_when_not_holding(self): |
|
208e970…
|
lmata
|
148 |
from navegador.cluster.locking import DistributedLock |
|
208e970…
|
lmata
|
149 |
|
|
208e970…
|
lmata
|
150 |
r = _make_redis() |
|
208e970…
|
lmata
|
151 |
lock = DistributedLock("redis://localhost", "noop", _redis_client=r) |
|
208e970…
|
lmata
|
152 |
lock.release() # should not raise |
|
208e970…
|
lmata
|
153 |
|
|
208e970…
|
lmata
|
154 |
def test_lock_uses_setnx_semantics(self): |
|
208e970…
|
lmata
|
155 |
"""set() should be called with nx=True.""" |
|
208e970…
|
lmata
|
156 |
from navegador.cluster.locking import DistributedLock |
|
208e970…
|
lmata
|
157 |
|
|
208e970…
|
lmata
|
158 |
r = _make_redis() |
|
208e970…
|
lmata
|
159 |
lock = DistributedLock("redis://localhost", "nx-test", _redis_client=r) |
|
208e970…
|
lmata
|
160 |
lock.acquire() |
|
208e970…
|
lmata
|
161 |
call_kwargs = r.set.call_args[1] |
|
208e970…
|
lmata
|
162 |
assert call_kwargs.get("nx") is True |
|
208e970…
|
lmata
|
163 |
|
|
208e970…
|
lmata
|
164 |
|
|
208e970…
|
lmata
|
165 |
# ============================================================================= |
|
208e970…
|
lmata
|
166 |
# #50 — CheckpointManager |
|
208e970…
|
lmata
|
167 |
# ============================================================================= |
|
208e970…
|
lmata
|
168 |
|
|
208e970…
|
lmata
|
169 |
|
|
208e970…
|
lmata
|
170 |
class TestCheckpointManager: |
|
208e970…
|
lmata
|
171 |
def test_create_returns_id(self, tmp_path): |
|
208e970…
|
lmata
|
172 |
from navegador.cluster.checkpoint import CheckpointManager |
|
208e970…
|
lmata
|
173 |
|
|
208e970…
|
lmata
|
174 |
store = _make_store() |
|
208e970…
|
lmata
|
175 |
with patch("navegador.cluster.checkpoint.export_graph") as mock_export: |
|
208e970…
|
lmata
|
176 |
mock_export.return_value = {"nodes": 4, "edges": 2} |
|
208e970…
|
lmata
|
177 |
mgr = CheckpointManager(store, tmp_path / "checkpoints") |
|
208e970…
|
lmata
|
178 |
cid = mgr.create(label="before-refactor") |
|
208e970…
|
lmata
|
179 |
assert isinstance(cid, str) and len(cid) == 36 # UUID4 |
|
208e970…
|
lmata
|
180 |
|
|
208e970…
|
lmata
|
181 |
def test_create_writes_index(self, tmp_path): |
|
208e970…
|
lmata
|
182 |
from navegador.cluster.checkpoint import CheckpointManager |
|
208e970…
|
lmata
|
183 |
|
|
208e970…
|
lmata
|
184 |
store = _make_store() |
|
208e970…
|
lmata
|
185 |
ckdir = tmp_path / "ckpts" |
|
208e970…
|
lmata
|
186 |
with patch("navegador.cluster.checkpoint.export_graph") as mock_export: |
|
208e970…
|
lmata
|
187 |
mock_export.return_value = {"nodes": 3, "edges": 1} |
|
208e970…
|
lmata
|
188 |
mgr = CheckpointManager(store, ckdir) |
|
208e970…
|
lmata
|
189 |
cid = mgr.create(label="snap1") |
|
208e970…
|
lmata
|
190 |
|
|
208e970…
|
lmata
|
191 |
index_path = ckdir / "checkpoints.json" |
|
208e970…
|
lmata
|
192 |
assert index_path.exists() |
|
208e970…
|
lmata
|
193 |
index = json.loads(index_path.read_text()) |
|
208e970…
|
lmata
|
194 |
assert len(index) == 1 |
|
208e970…
|
lmata
|
195 |
assert index[0]["id"] == cid |
|
208e970…
|
lmata
|
196 |
assert index[0]["label"] == "snap1" |
|
208e970…
|
lmata
|
197 |
assert index[0]["node_count"] == 3 |
|
208e970…
|
lmata
|
198 |
|
|
208e970…
|
lmata
|
199 |
def test_list_checkpoints(self, tmp_path): |
|
208e970…
|
lmata
|
200 |
from navegador.cluster.checkpoint import CheckpointManager |
|
208e970…
|
lmata
|
201 |
|
|
208e970…
|
lmata
|
202 |
store = _make_store() |
|
208e970…
|
lmata
|
203 |
ckdir = tmp_path / "ckpts" |
|
208e970…
|
lmata
|
204 |
with patch("navegador.cluster.checkpoint.export_graph") as mock_export: |
|
208e970…
|
lmata
|
205 |
mock_export.return_value = {"nodes": 2, "edges": 0} |
|
208e970…
|
lmata
|
206 |
mgr = CheckpointManager(store, ckdir) |
|
208e970…
|
lmata
|
207 |
id1 = mgr.create(label="first") |
|
208e970…
|
lmata
|
208 |
id2 = mgr.create(label="second") |
|
208e970…
|
lmata
|
209 |
|
|
208e970…
|
lmata
|
210 |
checkpoints = mgr.list_checkpoints() |
|
208e970…
|
lmata
|
211 |
assert len(checkpoints) == 2 |
|
208e970…
|
lmata
|
212 |
ids = [c["id"] for c in checkpoints] |
|
208e970…
|
lmata
|
213 |
assert id1 in ids and id2 in ids |
|
208e970…
|
lmata
|
214 |
|
|
208e970…
|
lmata
|
215 |
def test_restore(self, tmp_path): |
|
208e970…
|
lmata
|
216 |
from navegador.cluster.checkpoint import CheckpointManager |
|
208e970…
|
lmata
|
217 |
|
|
208e970…
|
lmata
|
218 |
store = _make_store() |
|
208e970…
|
lmata
|
219 |
ckdir = tmp_path / "ckpts" |
|
208e970…
|
lmata
|
220 |
|
|
208e970…
|
lmata
|
221 |
def _fake_export(store, path): |
|
208e970…
|
lmata
|
222 |
# Create the file so restore can find it |
|
208e970…
|
lmata
|
223 |
Path(path).touch() |
|
208e970…
|
lmata
|
224 |
return {"nodes": 5, "edges": 2} |
|
208e970…
|
lmata
|
225 |
|
|
208e970…
|
lmata
|
226 |
with patch("navegador.cluster.checkpoint.export_graph", side_effect=_fake_export), \ |
|
208e970…
|
lmata
|
227 |
patch("navegador.cluster.checkpoint.import_graph") as mock_import: |
|
208e970…
|
lmata
|
228 |
mock_import.return_value = {"nodes": 5, "edges": 2} |
|
208e970…
|
lmata
|
229 |
mgr = CheckpointManager(store, ckdir) |
|
208e970…
|
lmata
|
230 |
cid = mgr.create(label="snapshot") |
|
208e970…
|
lmata
|
231 |
mgr.restore(cid) |
|
208e970…
|
lmata
|
232 |
mock_import.assert_called_once() |
|
208e970…
|
lmata
|
233 |
call_args = mock_import.call_args |
|
208e970…
|
lmata
|
234 |
assert call_args[0][0] is store |
|
208e970…
|
lmata
|
235 |
assert call_args[1].get("clear", True) is True |
|
208e970…
|
lmata
|
236 |
|
|
208e970…
|
lmata
|
237 |
def test_restore_unknown_id_raises(self, tmp_path): |
|
208e970…
|
lmata
|
238 |
from navegador.cluster.checkpoint import CheckpointManager |
|
208e970…
|
lmata
|
239 |
|
|
208e970…
|
lmata
|
240 |
store = _make_store() |
|
208e970…
|
lmata
|
241 |
mgr = CheckpointManager(store, tmp_path / "ckpts") |
|
208e970…
|
lmata
|
242 |
with pytest.raises(KeyError): |
|
208e970…
|
lmata
|
243 |
mgr.restore("nonexistent-id") |
|
208e970…
|
lmata
|
244 |
|
|
208e970…
|
lmata
|
245 |
def test_delete_removes_from_index(self, tmp_path): |
|
208e970…
|
lmata
|
246 |
from navegador.cluster.checkpoint import CheckpointManager |
|
208e970…
|
lmata
|
247 |
|
|
208e970…
|
lmata
|
248 |
store = _make_store() |
|
208e970…
|
lmata
|
249 |
ckdir = tmp_path / "ckpts" |
|
208e970…
|
lmata
|
250 |
with patch("navegador.cluster.checkpoint.export_graph") as mock_export: |
|
208e970…
|
lmata
|
251 |
mock_export.return_value = {"nodes": 1, "edges": 0} |
|
208e970…
|
lmata
|
252 |
mgr = CheckpointManager(store, ckdir) |
|
208e970…
|
lmata
|
253 |
cid = mgr.create() |
|
208e970…
|
lmata
|
254 |
mgr.delete(cid) |
|
208e970…
|
lmata
|
255 |
|
|
208e970…
|
lmata
|
256 |
assert len(mgr.list_checkpoints()) == 0 |
|
208e970…
|
lmata
|
257 |
|
|
208e970…
|
lmata
|
258 |
def test_delete_unknown_id_raises(self, tmp_path): |
|
208e970…
|
lmata
|
259 |
from navegador.cluster.checkpoint import CheckpointManager |
|
208e970…
|
lmata
|
260 |
|
|
208e970…
|
lmata
|
261 |
mgr = CheckpointManager(_make_store(), tmp_path / "ckpts") |
|
208e970…
|
lmata
|
262 |
with pytest.raises(KeyError): |
|
208e970…
|
lmata
|
263 |
mgr.delete("ghost-id") |
|
208e970…
|
lmata
|
264 |
|
|
208e970…
|
lmata
|
265 |
|
|
208e970…
|
lmata
|
266 |
# ============================================================================= |
|
208e970…
|
lmata
|
267 |
# #51 — SwarmDashboard |
|
208e970…
|
lmata
|
268 |
# ============================================================================= |
|
208e970…
|
lmata
|
269 |
|
|
208e970…
|
lmata
|
270 |
|
|
208e970…
|
lmata
|
271 |
class TestSwarmDashboard: |
|
208e970…
|
lmata
|
272 |
def test_register_and_agent_status(self): |
|
208e970…
|
lmata
|
273 |
from navegador.cluster.observability import SwarmDashboard |
|
208e970…
|
lmata
|
274 |
|
|
208e970…
|
lmata
|
275 |
r = _make_redis() |
|
208e970…
|
lmata
|
276 |
dash = SwarmDashboard("redis://localhost", _redis_client=r) |
|
208e970…
|
lmata
|
277 |
dash.register_agent("agent-1", {"role": "ingestor"}) |
|
208e970…
|
lmata
|
278 |
dash.register_agent("agent-2") |
|
208e970…
|
lmata
|
279 |
|
|
208e970…
|
lmata
|
280 |
agents = dash.agent_status() |
|
208e970…
|
lmata
|
281 |
ids = {a["agent_id"] for a in agents} |
|
208e970…
|
lmata
|
282 |
assert "agent-1" in ids |
|
208e970…
|
lmata
|
283 |
assert "agent-2" in ids |
|
208e970…
|
lmata
|
284 |
|
|
208e970…
|
lmata
|
285 |
def test_agent_status_empty(self): |
|
208e970…
|
lmata
|
286 |
from navegador.cluster.observability import SwarmDashboard |
|
208e970…
|
lmata
|
287 |
|
|
208e970…
|
lmata
|
288 |
r = _make_redis() |
|
208e970…
|
lmata
|
289 |
dash = SwarmDashboard("redis://localhost", _redis_client=r) |
|
208e970…
|
lmata
|
290 |
assert dash.agent_status() == [] |
|
208e970…
|
lmata
|
291 |
|
|
208e970…
|
lmata
|
292 |
def test_task_metrics_default(self): |
|
208e970…
|
lmata
|
293 |
from navegador.cluster.observability import SwarmDashboard |
|
208e970…
|
lmata
|
294 |
|
|
208e970…
|
lmata
|
295 |
r = _make_redis() |
|
208e970…
|
lmata
|
296 |
dash = SwarmDashboard("redis://localhost", _redis_client=r) |
|
208e970…
|
lmata
|
297 |
metrics = dash.task_metrics() |
|
208e970…
|
lmata
|
298 |
assert metrics == {"pending": 0, "active": 0, "completed": 0, "failed": 0} |
|
208e970…
|
lmata
|
299 |
|
|
208e970…
|
lmata
|
300 |
def test_update_task_metrics(self): |
|
208e970…
|
lmata
|
301 |
from navegador.cluster.observability import SwarmDashboard |
|
208e970…
|
lmata
|
302 |
|
|
208e970…
|
lmata
|
303 |
r = _make_redis() |
|
208e970…
|
lmata
|
304 |
dash = SwarmDashboard("redis://localhost", _redis_client=r) |
|
208e970…
|
lmata
|
305 |
dash.update_task_metrics(pending=3, active=1) |
|
208e970…
|
lmata
|
306 |
m = dash.task_metrics() |
|
208e970…
|
lmata
|
307 |
assert m["pending"] == 3 |
|
208e970…
|
lmata
|
308 |
assert m["active"] == 1 |
|
208e970…
|
lmata
|
309 |
|
|
208e970…
|
lmata
|
310 |
def test_graph_metrics(self): |
|
208e970…
|
lmata
|
311 |
from navegador.cluster.observability import SwarmDashboard |
|
208e970…
|
lmata
|
312 |
|
|
208e970…
|
lmata
|
313 |
r = _make_redis() |
|
208e970…
|
lmata
|
314 |
dash = SwarmDashboard("redis://localhost", _redis_client=r) |
|
208e970…
|
lmata
|
315 |
store = _make_store() |
|
208e970…
|
lmata
|
316 |
gm = dash.graph_metrics(store) |
|
208e970…
|
lmata
|
317 |
assert gm["node_count"] == 5 |
|
208e970…
|
lmata
|
318 |
assert gm["edge_count"] == 3 |
|
208e970…
|
lmata
|
319 |
assert "last_modified" in gm |
|
208e970…
|
lmata
|
320 |
|
|
208e970…
|
lmata
|
321 |
def test_to_json_contains_all_sections(self): |
|
208e970…
|
lmata
|
322 |
from navegador.cluster.observability import SwarmDashboard |
|
208e970…
|
lmata
|
323 |
|
|
208e970…
|
lmata
|
324 |
r = _make_redis() |
|
208e970…
|
lmata
|
325 |
dash = SwarmDashboard("redis://localhost", _redis_client=r) |
|
208e970…
|
lmata
|
326 |
dash.register_agent("a1") |
|
208e970…
|
lmata
|
327 |
dash.update_task_metrics(completed=7) |
|
208e970…
|
lmata
|
328 |
store = _make_store() |
|
208e970…
|
lmata
|
329 |
dash.graph_metrics(store) |
|
208e970…
|
lmata
|
330 |
|
|
208e970…
|
lmata
|
331 |
snapshot = json.loads(dash.to_json()) |
|
208e970…
|
lmata
|
332 |
assert "agents" in snapshot |
|
208e970…
|
lmata
|
333 |
assert "task_metrics" in snapshot |
|
208e970…
|
lmata
|
334 |
assert "graph_metrics" in snapshot |
|
208e970…
|
lmata
|
335 |
assert snapshot["task_metrics"]["completed"] == 7 |
|
208e970…
|
lmata
|
336 |
|
|
208e970…
|
lmata
|
337 |
|
|
208e970…
|
lmata
|
338 |
# ============================================================================= |
|
208e970…
|
lmata
|
339 |
# #52 — MessageBus |
|
208e970…
|
lmata
|
340 |
# ============================================================================= |
|
208e970…
|
lmata
|
341 |
|
|
208e970…
|
lmata
|
342 |
|
|
208e970…
|
lmata
|
343 |
class TestMessageBus: |
|
208e970…
|
lmata
|
344 |
def test_send_returns_message_id(self): |
|
208e970…
|
lmata
|
345 |
from navegador.cluster.messaging import MessageBus |
|
208e970…
|
lmata
|
346 |
|
|
208e970…
|
lmata
|
347 |
r = _make_redis() |
|
208e970…
|
lmata
|
348 |
bus = MessageBus("redis://localhost", _redis_client=r) |
|
208e970…
|
lmata
|
349 |
mid = bus.send("alice", "bob", "task.assign", {"task_id": "t1"}) |
|
208e970…
|
lmata
|
350 |
assert isinstance(mid, str) and len(mid) == 36 |
|
208e970…
|
lmata
|
351 |
|
|
208e970…
|
lmata
|
352 |
def test_receive_pending_messages(self): |
|
208e970…
|
lmata
|
353 |
from navegador.cluster.messaging import MessageBus |
|
208e970…
|
lmata
|
354 |
|
|
208e970…
|
lmata
|
355 |
r = _make_redis() |
|
208e970…
|
lmata
|
356 |
bus = MessageBus("redis://localhost", _redis_client=r) |
|
208e970…
|
lmata
|
357 |
bus.send("alice", "bob", "greeting", {"text": "hello"}) |
|
208e970…
|
lmata
|
358 |
bus.send("alice", "bob", "greeting", {"text": "world"}) |
|
208e970…
|
lmata
|
359 |
|
|
208e970…
|
lmata
|
360 |
msgs = bus.receive("bob", limit=10) |
|
208e970…
|
lmata
|
361 |
assert len(msgs) == 2 |
|
208e970…
|
lmata
|
362 |
assert msgs[0].from_agent == "alice" |
|
208e970…
|
lmata
|
363 |
assert msgs[0].to_agent == "bob" |
|
208e970…
|
lmata
|
364 |
assert msgs[0].payload["text"] == "hello" |
|
208e970…
|
lmata
|
365 |
|
|
208e970…
|
lmata
|
366 |
def test_acknowledge_removes_from_pending(self): |
|
208e970…
|
lmata
|
367 |
from navegador.cluster.messaging import MessageBus |
|
208e970…
|
lmata
|
368 |
|
|
208e970…
|
lmata
|
369 |
r = _make_redis() |
|
208e970…
|
lmata
|
370 |
bus = MessageBus("redis://localhost", _redis_client=r) |
|
208e970…
|
lmata
|
371 |
mid = bus.send("alice", "bob", "ping", {}) |
|
208e970…
|
lmata
|
372 |
bus.acknowledge(mid, agent_id="bob") |
|
208e970…
|
lmata
|
373 |
|
|
208e970…
|
lmata
|
374 |
# Message was acked — receive should return empty for bob |
|
208e970…
|
lmata
|
375 |
msgs = bus.receive("bob") |
|
208e970…
|
lmata
|
376 |
assert all(m.id != mid for m in msgs) |
|
208e970…
|
lmata
|
377 |
|
|
208e970…
|
lmata
|
378 |
def test_broadcast_reaches_all_agents(self): |
|
208e970…
|
lmata
|
379 |
from navegador.cluster.messaging import MessageBus |
|
208e970…
|
lmata
|
380 |
|
|
208e970…
|
lmata
|
381 |
r = _make_redis() |
|
208e970…
|
lmata
|
382 |
bus = MessageBus("redis://localhost", _redis_client=r) |
|
208e970…
|
lmata
|
383 |
# Register recipients |
|
208e970…
|
lmata
|
384 |
bus.receive("carol") # touching the queue registers the agent |
|
208e970…
|
lmata
|
385 |
bus.receive("dave") |
|
208e970…
|
lmata
|
386 |
|
|
208e970…
|
lmata
|
387 |
mids = bus.broadcast("alice", "announcement", {"msg": "deploy"}) |
|
208e970…
|
lmata
|
388 |
# carol and dave should each have the message |
|
208e970…
|
lmata
|
389 |
carol_msgs = bus.receive("carol") |
|
208e970…
|
lmata
|
390 |
dave_msgs = bus.receive("dave") |
|
208e970…
|
lmata
|
391 |
assert any(m.type == "announcement" for m in carol_msgs) |
|
208e970…
|
lmata
|
392 |
assert any(m.type == "announcement" for m in dave_msgs) |
|
208e970…
|
lmata
|
393 |
|
|
208e970…
|
lmata
|
394 |
def test_broadcast_excludes_sender(self): |
|
208e970…
|
lmata
|
395 |
from navegador.cluster.messaging import MessageBus |
|
208e970…
|
lmata
|
396 |
|
|
208e970…
|
lmata
|
397 |
r = _make_redis() |
|
208e970…
|
lmata
|
398 |
bus = MessageBus("redis://localhost", _redis_client=r) |
|
208e970…
|
lmata
|
399 |
bus.receive("carol") # register carol |
|
208e970…
|
lmata
|
400 |
|
|
208e970…
|
lmata
|
401 |
bus.broadcast("alice", "news", {"x": 1}) |
|
208e970…
|
lmata
|
402 |
# alice should not have received the broadcast |
|
208e970…
|
lmata
|
403 |
alice_msgs = bus.receive("alice") |
|
208e970…
|
lmata
|
404 |
assert all(m.from_agent != "alice" or m.type != "news" for m in alice_msgs) |
|
208e970…
|
lmata
|
405 |
|
|
208e970…
|
lmata
|
406 |
def test_message_fields(self): |
|
208e970…
|
lmata
|
407 |
from navegador.cluster.messaging import MessageBus, Message |
|
208e970…
|
lmata
|
408 |
|
|
208e970…
|
lmata
|
409 |
r = _make_redis() |
|
208e970…
|
lmata
|
410 |
bus = MessageBus("redis://localhost", _redis_client=r) |
|
208e970…
|
lmata
|
411 |
bus.send("sender", "receiver", "status.update", {"code": 42}) |
|
208e970…
|
lmata
|
412 |
msgs = bus.receive("receiver") |
|
208e970…
|
lmata
|
413 |
assert len(msgs) == 1 |
|
208e970…
|
lmata
|
414 |
m = msgs[0] |
|
208e970…
|
lmata
|
415 |
assert m.from_agent == "sender" |
|
208e970…
|
lmata
|
416 |
assert m.to_agent == "receiver" |
|
208e970…
|
lmata
|
417 |
assert m.type == "status.update" |
|
208e970…
|
lmata
|
418 |
assert m.payload == {"code": 42} |
|
208e970…
|
lmata
|
419 |
assert m.acknowledged is False |
|
208e970…
|
lmata
|
420 |
assert m.timestamp > 0 |
|
208e970…
|
lmata
|
421 |
|
|
208e970…
|
lmata
|
422 |
|
|
208e970…
|
lmata
|
423 |
# ============================================================================= |
|
208e970…
|
lmata
|
424 |
# #57 — FossilLiveAdapter |
|
208e970…
|
lmata
|
425 |
# ============================================================================= |
|
208e970…
|
lmata
|
426 |
|
|
208e970…
|
lmata
|
427 |
|
|
208e970…
|
lmata
|
428 |
class TestFossilLiveAdapter: |
|
208e970…
|
lmata
|
429 |
def _make_sqlite_conn(self, rows_event=None, rows_ticket=None): |
|
208e970…
|
lmata
|
430 |
"""Create a mock sqlite3 connection.""" |
|
208e970…
|
lmata
|
431 |
import sqlite3 |
|
208e970…
|
lmata
|
432 |
|
|
208e970…
|
lmata
|
433 |
conn = MagicMock(spec=sqlite3.Connection) |
|
208e970…
|
lmata
|
434 |
cursor = MagicMock() |
|
208e970…
|
lmata
|
435 |
cursor.fetchall.return_value = rows_event or [] |
|
208e970…
|
lmata
|
436 |
cursor.description = [ |
|
208e970…
|
lmata
|
437 |
("type",), ("mtime",), ("objid",), ("uid",), ("user",), |
|
208e970…
|
lmata
|
438 |
("euser",), ("comment",), ("ecomment",), |
|
208e970…
|
lmata
|
439 |
] |
|
208e970…
|
lmata
|
440 |
|
|
208e970…
|
lmata
|
441 |
ticket_cursor = MagicMock() |
|
208e970…
|
lmata
|
442 |
ticket_cursor.fetchall.return_value = rows_ticket or [] |
|
208e970…
|
lmata
|
443 |
ticket_cursor.description = [ |
|
208e970…
|
lmata
|
444 |
("tkt_uuid",), ("title",), ("status",), ("type",), ("tkt_mtime",), |
|
208e970…
|
lmata
|
445 |
] |
|
208e970…
|
lmata
|
446 |
|
|
208e970…
|
lmata
|
447 |
# execute returns event cursor by default; ticket cursor when queried |
|
208e970…
|
lmata
|
448 |
def _execute(sql, params=()): |
|
208e970…
|
lmata
|
449 |
if "ticket" in sql: |
|
208e970…
|
lmata
|
450 |
return ticket_cursor |
|
208e970…
|
lmata
|
451 |
return cursor |
|
208e970…
|
lmata
|
452 |
|
|
208e970…
|
lmata
|
453 |
conn.execute.side_effect = _execute |
|
208e970…
|
lmata
|
454 |
return conn |
|
208e970…
|
lmata
|
455 |
|
|
208e970…
|
lmata
|
456 |
def test_query_timeline_returns_rows(self): |
|
208e970…
|
lmata
|
457 |
from navegador.cluster.fossil_live import FossilLiveAdapter |
|
208e970…
|
lmata
|
458 |
|
|
208e970…
|
lmata
|
459 |
raw_rows = [ |
|
208e970…
|
lmata
|
460 |
("ci", 2460000.0, 12345, 1, "alice", "alice", "initial commit", ""), |
|
208e970…
|
lmata
|
461 |
] |
|
208e970…
|
lmata
|
462 |
conn = self._make_sqlite_conn(rows_event=raw_rows) |
|
208e970…
|
lmata
|
463 |
adapter = FossilLiveAdapter("/fake/repo.fossil", _sqlite_conn=conn) |
|
208e970…
|
lmata
|
464 |
rows = adapter.query_timeline(limit=10) |
|
208e970…
|
lmata
|
465 |
assert len(rows) == 1 |
|
208e970…
|
lmata
|
466 |
conn.execute.assert_called() |
|
208e970…
|
lmata
|
467 |
|
|
208e970…
|
lmata
|
468 |
def test_query_tickets_returns_rows(self): |
|
208e970…
|
lmata
|
469 |
from navegador.cluster.fossil_live import FossilLiveAdapter |
|
208e970…
|
lmata
|
470 |
|
|
208e970…
|
lmata
|
471 |
ticket_rows = [("abc123", "Bug in login", "open", "defect", 2460001.0)] |
|
208e970…
|
lmata
|
472 |
conn = self._make_sqlite_conn(rows_ticket=ticket_rows) |
|
208e970…
|
lmata
|
473 |
adapter = FossilLiveAdapter("/fake/repo.fossil", _sqlite_conn=conn) |
|
208e970…
|
lmata
|
474 |
tickets = adapter.query_tickets() |
|
208e970…
|
lmata
|
475 |
assert len(tickets) == 1 |
|
208e970…
|
lmata
|
476 |
|
|
208e970…
|
lmata
|
477 |
def test_query_tickets_exception_returns_empty(self): |
|
208e970…
|
lmata
|
478 |
from navegador.cluster.fossil_live import FossilLiveAdapter |
|
208e970…
|
lmata
|
479 |
|
|
208e970…
|
lmata
|
480 |
conn = MagicMock() |
|
208e970…
|
lmata
|
481 |
conn.execute.side_effect = Exception("no ticket table") |
|
208e970…
|
lmata
|
482 |
adapter = FossilLiveAdapter("/fake/repo.fossil", _sqlite_conn=conn) |
|
208e970…
|
lmata
|
483 |
result = adapter.query_tickets() |
|
208e970…
|
lmata
|
484 |
assert result == [] |
|
208e970…
|
lmata
|
485 |
|
|
208e970…
|
lmata
|
486 |
def test_sync_to_graph_imports_commits(self): |
|
208e970…
|
lmata
|
487 |
from navegador.cluster.fossil_live import FossilLiveAdapter |
|
208e970…
|
lmata
|
488 |
|
|
208e970…
|
lmata
|
489 |
conn = MagicMock() |
|
208e970…
|
lmata
|
490 |
cursor = MagicMock() |
|
208e970…
|
lmata
|
491 |
cursor.fetchall.return_value = [ |
|
208e970…
|
lmata
|
492 |
("ci", 2460000.0, 9999, 1, "bob", "bob", "fix bug", ""), |
|
208e970…
|
lmata
|
493 |
("w", 2460001.0, 1000, 2, "carol", "carol", "wiki edit", ""), # skipped |
|
208e970…
|
lmata
|
494 |
] |
|
208e970…
|
lmata
|
495 |
cursor.description = [ |
|
208e970…
|
lmata
|
496 |
("type",), ("mtime",), ("objid",), ("uid",), ("user",), |
|
208e970…
|
lmata
|
497 |
("euser",), ("comment",), ("ecomment",), |
|
208e970…
|
lmata
|
498 |
] |
|
208e970…
|
lmata
|
499 |
ticket_cursor = MagicMock() |
|
208e970…
|
lmata
|
500 |
ticket_cursor.fetchall.return_value = [] |
|
208e970…
|
lmata
|
501 |
ticket_cursor.description = [("tkt_uuid",), ("title",), ("status",), ("type",), ("tkt_mtime",)] |
|
208e970…
|
lmata
|
502 |
|
|
208e970…
|
lmata
|
503 |
def _execute(sql, params=()): |
|
208e970…
|
lmata
|
504 |
if "ticket" in sql: |
|
208e970…
|
lmata
|
505 |
return ticket_cursor |
|
208e970…
|
lmata
|
506 |
return cursor |
|
208e970…
|
lmata
|
507 |
|
|
208e970…
|
lmata
|
508 |
conn.execute.side_effect = _execute |
|
208e970…
|
lmata
|
509 |
store = _make_store() |
|
208e970…
|
lmata
|
510 |
adapter = FossilLiveAdapter("/fake/repo.fossil", _sqlite_conn=conn) |
|
208e970…
|
lmata
|
511 |
result = adapter.sync_to_graph(store) |
|
208e970…
|
lmata
|
512 |
# Only "ci" type events should be imported |
|
208e970…
|
lmata
|
513 |
assert result["commits"] == 1 |
|
208e970…
|
lmata
|
514 |
assert result["tickets"] == 0 |
|
208e970…
|
lmata
|
515 |
|
|
208e970…
|
lmata
|
516 |
def test_sync_to_graph_imports_tickets(self): |
|
208e970…
|
lmata
|
517 |
from navegador.cluster.fossil_live import FossilLiveAdapter |
|
208e970…
|
lmata
|
518 |
|
|
208e970…
|
lmata
|
519 |
conn = MagicMock() |
|
208e970…
|
lmata
|
520 |
event_cursor = MagicMock() |
|
208e970…
|
lmata
|
521 |
event_cursor.fetchall.return_value = [] |
|
208e970…
|
lmata
|
522 |
event_cursor.description = [ |
|
208e970…
|
lmata
|
523 |
("type",), ("mtime",), ("objid",), ("uid",), ("user",), |
|
208e970…
|
lmata
|
524 |
("euser",), ("comment",), ("ecomment",), |
|
208e970…
|
lmata
|
525 |
] |
|
208e970…
|
lmata
|
526 |
ticket_cursor = MagicMock() |
|
208e970…
|
lmata
|
527 |
ticket_cursor.fetchall.return_value = [ |
|
208e970…
|
lmata
|
528 |
("ticket-uuid-1", "Login fails", "open", "defect", 2460002.0), |
|
208e970…
|
lmata
|
529 |
] |
|
208e970…
|
lmata
|
530 |
ticket_cursor.description = [ |
|
208e970…
|
lmata
|
531 |
("tkt_uuid",), ("title",), ("status",), ("type",), ("tkt_mtime",), |
|
208e970…
|
lmata
|
532 |
] |
|
208e970…
|
lmata
|
533 |
|
|
208e970…
|
lmata
|
534 |
def _execute(sql, params=()): |
|
208e970…
|
lmata
|
535 |
if "ticket" in sql: |
|
208e970…
|
lmata
|
536 |
return ticket_cursor |
|
208e970…
|
lmata
|
537 |
return event_cursor |
|
208e970…
|
lmata
|
538 |
|
|
208e970…
|
lmata
|
539 |
conn.execute.side_effect = _execute |
|
208e970…
|
lmata
|
540 |
store = _make_store() |
|
208e970…
|
lmata
|
541 |
adapter = FossilLiveAdapter("/fake/repo.fossil", _sqlite_conn=conn) |
|
208e970…
|
lmata
|
542 |
result = adapter.sync_to_graph(store) |
|
208e970…
|
lmata
|
543 |
assert result["tickets"] == 1 |
|
208e970…
|
lmata
|
544 |
|
|
208e970…
|
lmata
|
545 |
def test_attach_calls_attach_database_on_sqlite_conn(self): |
|
208e970…
|
lmata
|
546 |
from navegador.cluster.fossil_live import FossilLiveAdapter |
|
208e970…
|
lmata
|
547 |
import sqlite3 |
|
208e970…
|
lmata
|
548 |
|
|
208e970…
|
lmata
|
549 |
conn = MagicMock(spec=sqlite3.Connection) |
|
208e970…
|
lmata
|
550 |
conn.execute = MagicMock() |
|
208e970…
|
lmata
|
551 |
store = _make_store() |
|
208e970…
|
lmata
|
552 |
store._client = MagicMock() |
|
208e970…
|
lmata
|
553 |
store._client._db = conn |
|
208e970…
|
lmata
|
554 |
|
|
208e970…
|
lmata
|
555 |
adapter = FossilLiveAdapter("/fake/repo.fossil") |
|
208e970…
|
lmata
|
556 |
adapter.attach(store) |
|
208e970…
|
lmata
|
557 |
# Should have called ATTACH DATABASE |
|
208e970…
|
lmata
|
558 |
call_args = conn.execute.call_args |
|
208e970…
|
lmata
|
559 |
assert "ATTACH" in call_args[0][0].upper() |
|
208e970…
|
lmata
|
560 |
assert adapter._attached is True |
|
208e970…
|
lmata
|
561 |
|
|
208e970…
|
lmata
|
562 |
def test_attach_fallback_when_no_sqlite(self, tmp_path): |
|
208e970…
|
lmata
|
563 |
"""When the store is Redis-backed, adapter falls back gracefully.""" |
|
208e970…
|
lmata
|
564 |
from navegador.cluster.fossil_live import FossilLiveAdapter |
|
208e970…
|
lmata
|
565 |
import sqlite3 |
|
208e970…
|
lmata
|
566 |
|
|
208e970…
|
lmata
|
567 |
# Create a real (tiny) Fossil-like sqlite db so the fallback connect works |
|
208e970…
|
lmata
|
568 |
fossil_path = tmp_path / "repo.fossil" |
|
208e970…
|
lmata
|
569 |
db = sqlite3.connect(str(fossil_path)) |
|
208e970…
|
lmata
|
570 |
db.execute( |
|
208e970…
|
lmata
|
571 |
"CREATE TABLE event (type TEXT, mtime REAL, objid INT, uid INT, " |
|
208e970…
|
lmata
|
572 |
"user TEXT, euser TEXT, comment TEXT, ecomment TEXT)" |
|
208e970…
|
lmata
|
573 |
) |
|
208e970…
|
lmata
|
574 |
db.commit() |
|
208e970…
|
lmata
|
575 |
db.close() |
|
208e970…
|
lmata
|
576 |
|
|
208e970…
|
lmata
|
577 |
store = _make_store() |
|
208e970…
|
lmata
|
578 |
store._client = MagicMock() |
|
208e970…
|
lmata
|
579 |
# No _db attribute — simulates Redis backend |
|
208e970…
|
lmata
|
580 |
del store._client._db |
|
208e970…
|
lmata
|
581 |
|
|
208e970…
|
lmata
|
582 |
adapter = FossilLiveAdapter(fossil_path) |
|
208e970…
|
lmata
|
583 |
adapter.attach(store) # should not raise |
|
208e970…
|
lmata
|
584 |
assert adapter._attached is False # fallback path: no attachment |