"""A target the account cannot post to must not spin the same post through the queue forever.""" import asyncio import sys sys.path.insert(0, "/app") from telethon.errors import ChatAdminRequiredError from db.models import TargetChannel from services.publisher import PublisherService class FakeQueue: def __init__(self, payloads): self.items = list(payloads) self.pushes = 0 async def get_target_queue_size(self, target_id): return len(self.items) async def pop_target_post(self, target_id, dispatch_order="order"): return self.items.pop(0) if self.items else None async def push_target_post(self, target_id, payload): self.pushes += 1 self.items.append(payload) class FakeRepo: def __init__(self, targets): self._targets = targets self.published = [] async def get_active_targets(self): return self._targets async def record_post_published_to_target(self, *a): self.published.append(a) async def update_target_last_post(self, *a): pass def make_target(): return TargetChannel(id=2, channel_id=-1003848540849, title="newsjoker", username=None, post_interval_min=1, last_post_time=None) class FailingClient: def __init__(self, error): self.error = error self.attempts = 0 def is_connected(self): return True async def send_message(self, *a, **kw): self.attempts += 1 raise self.error async def send_file(self, *a, **kw): self.attempts += 1 raise self.error async def drive(error, cycles=3): queue = FakeQueue([{"post_id": 117, "text": "hello", "media_path": None}]) repo = FakeRepo([make_target()]) notes = [] async def notify(text): notes.append(text) pub = PublisherService(repo=repo, queue=queue, client=FailingClient(error), notify_fn=notify) for _ in range(cycles): await pub._process_all_target_queues() return queue, notes async def _cleanup_error_rows(): """log_exception writes to the real table even from fakes; remove those rows.""" from db.database import init_db, get_db_pool await init_db() pool = await get_db_pool() async with pool.acquire() as conn: await conn.execute( "DELETE FROM error_logs WHERE service_name = 'publisher.publish' " "AND context->>'target_id' = '2' AND context->>'post_id' = '117';") async def run_tests(): # Permanent: drained after the first attempt, admins told exactly once. queue, notes = await drive(ChatAdminRequiredError(request=None)) assert queue.pushes == 0, f"permanent failure must not requeue, got {queue.pushes} pushes" assert len(queue.items) == 0, f"queue should be drained, still holds {queue.items}" assert len(notes) == 1, f"admins should be warned exactly once, got {len(notes)}" assert "newsjoker" in notes[0] and "-1003848540849" in notes[0] # Transient: the post survives every failed cycle. queue, notes = await drive(ConnectionError("network blip")) assert queue.pushes == 3, f"transient failure must requeue each time, got {queue.pushes}" assert len(queue.items) == 1, f"post must still be queued, got {queue.items}" assert notes == [], "a transient blip must not raise a permanent-failure alarm" await _cleanup_error_rows() from db.database import close_db_pool await close_db_pool() print("All publisher failure-handling tests passed successfully!") if __name__ == "__main__": asyncio.run(run_tests())