import io import json import time import pytest from nisse_dev.gitea import Comment, GiteaError from nisse_dev.oplog import OpLog from nisse_dev.poller import MAX_BACKOFF_S, CommentPoller class FakeGitea: def __init__(self, pages: list[list[Comment]] | None = None) -> None: self.pages = pages or [] self.calls = 0 self.fail_times = 0 def list_comments(self, repo: str, index: int, since: str | None = None): self.calls += 1 if self.fail_times > 0: self.fail_times -= 1 raise GiteaError("gitea unreachable") if not self.pages: return [] return self.pages.pop(0) def comment(cid: int, author: str = "don", body: str = "please fix") -> Comment: return Comment(id=cid, author=author, body=body, created_at="t") def build(client, **kw) -> tuple[CommentPoller, list[Comment]]: seen: list[Comment] = [] poller = CommentPoller(client, "o/r", seen.append, **kw) return poller, seen def test_new_comments_are_delivered_once() -> None: client = FakeGitea([[comment(1)], [comment(1), comment(2)]]) poller, seen = build(client) poller.poll_once(1) poller.poll_once(1) assert [c.id for c in seen] == [1, 2] # id 1 not redelivered def test_prime_suppresses_the_existing_backlog() -> None: # Starting a watch must not replay feedback already addressed. client = FakeGitea([[comment(1), comment(2)], [comment(1), comment(2), comment(3)]]) poller, seen = build(client) poller.prime(1) poller.poll_once(1) assert [c.id for c in seen] == [3] def test_own_comments_are_ignored() -> None: client = FakeGitea([[comment(1, author="nisse-dev-testing"), comment(2, "don")]]) poller, seen = build(client, ignore_authors={"nisse-dev-testing"}) poller.poll_once(1) assert [c.id for c in seen] == [2] def test_ignored_comments_are_not_redelivered_later() -> None: client = FakeGitea( [ [comment(1, author="bot")], [comment(1, author="bot"), comment(2, "don")], ] ) poller, seen = build(client, ignore_authors={"bot"}) poller.poll_once(1) poller.poll_once(1) assert [c.id for c in seen] == [2] def test_outage_does_not_raise_out_of_the_loop() -> None: stream = io.StringIO() client = FakeGitea([[comment(1)]]) client.fail_times = 2 poller, seen = build( client, oplog=OpLog("nisse-dev", stream), interval_s=0.01, initial_backoff_s=0.01, ) poller.start(1) deadline = time.monotonic() + 5 while not seen and time.monotonic() < deadline: time.sleep(0.05) poller.stop() # It recovered and delivered rather than dying on the outage (L5: an # outage delays a reaction, never fails a task). assert [c.id for c in seen] == [1] assert "gitea.poll_failed" in stream.getvalue() def test_prime_survives_an_outage() -> None: client = FakeGitea() client.fail_times = 1 poller, _ = build(client) poller.prime(1) # must not raise def test_backoff_grows_then_caps() -> None: client = FakeGitea() client.fail_times = 50 stream = io.StringIO() poller, _ = build( client, oplog=OpLog("nisse-dev", stream), interval_s=0.001, initial_backoff_s=0.001, max_backoff_s=0.008, ) poller.start(1) time.sleep(0.3) poller.stop() delays = [json.loads(line)["retry_in_s"] for line in stream.getvalue().splitlines()] assert delays[:4] == [0.001, 0.002, 0.004, 0.008] assert max(delays) == 0.008 # capped, and retried indefinitely (L5) assert MAX_BACKOFF_S <= 300.0 # the shipped cap is "a few minutes" @pytest.mark.parametrize("author", ["don", "someone-else"]) def test_poller_does_not_filter_by_author_itself(author: str) -> None: # Authorisation belongs to the orchestrator's allowlist, not here: the # poller is pure intake. client = FakeGitea([[comment(1, author=author)]]) poller, seen = build(client) poller.poll_once(1) assert len(seen) == 1