Navegador

navegador / tests / test_cluster2.py
Source Blame History 584 lines
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

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button