from __future__ import annotations from collections.abc import Mapping from dataclasses import replace from datetime import datetime from pathlib import Path from blackout_notifier.clients import ExternalServiceError from blackout_notifier.models import Outage from blackout_notifier.service import OutageService from blackout_notifier.state import StateRepository class FakeSaapa: def __init__(self, snapshots: Mapping[str, list[Outage] | Exception]) -> None: self.snapshots = dict(snapshots) self.calls: list[tuple[str, str, str]] = [] def fetch(self, bill_id: str, from_date: str, to_date: str) -> list[Outage]: self.calls.append((bill_id, from_date, to_date)) result = self.snapshots[bill_id] if isinstance(result, Exception): raise result return result class FakeNotifier: def __init__(self, *, fail: bool = False, fail_on_call: int | None = None) -> None: self.messages: list[tuple[str, str]] = [] self.fail = fail self.fail_on_call = fail_on_call self.calls = 0 def send(self, chat_id: str, text: str) -> None: self.calls += 1 if self.fail or self.calls == self.fail_on_call: raise ExternalServiceError("Eitaa unavailable") self.messages.append((chat_id, text)) def _service( path: Path, saapa: FakeSaapa, notifier: FakeNotifier, bill_chat_ids: Mapping[str, tuple[str, ...]] | None = None, ) -> OutageService: return OutageService( bill_chat_ids=bill_chat_ids or {"123": ("chat-a",)}, lookahead_days=5, saapa=saapa, # type: ignore[arg-type] notifier=notifier, state_repository=StateRepository(path), ) def test_first_run_sends_upcoming_once(tmp_path: Path, now: datetime, outage: Outage) -> None: path = tmp_path / "state.json" saapa = FakeSaapa({"123": [outage]}) notifier = FakeNotifier() service = _service(path, saapa, notifier) first = service.run(now) first_state = path.read_text(encoding="utf-8") second = service.run(now) assert first.sent == 1 assert first.state_changed is True assert "خاموشی جدید" in notifier.messages[0][1] assert second.sent == 0 assert second.state_changed is False assert path.read_text(encoding="utf-8") == first_state def test_changed_outage_sends_field_diff(tmp_path: Path, now: datetime, outage: Outage) -> None: path = tmp_path / "state.json" notifier = FakeNotifier() saapa = FakeSaapa({"123": [outage]}) service = _service(path, saapa, notifier) service.run(now) saapa.snapshots["123"] = [replace(outage, start_time="10:00")] result = service.run(now) assert result.sent == 1 assert "تغییر در برنامه" in notifier.messages[-1][1] assert "09:00" in notifier.messages[-1][1] assert "10:00" in notifier.messages[-1][1] def test_cancellation_requires_two_successful_omissions_and_can_reactivate( tmp_path: Path, now: datetime, outage: Outage ) -> None: path = tmp_path / "state.json" notifier = FakeNotifier() saapa = FakeSaapa({"123": [outage]}) service = _service(path, saapa, notifier) service.run(now) notifier.messages.clear() saapa.snapshots["123"] = [] first_missing = service.run(now) second_missing = service.run(now) assert first_missing.sent == 0 assert second_missing.sent == 1 assert "دو بررسی پیاپی" in notifier.messages[-1][1] saapa.snapshots["123"] = [outage] restored = service.run(now) assert restored.sent == 1 assert "بازگشت برنامه" in notifier.messages[-1][1] def test_failed_notification_does_not_advance_state( tmp_path: Path, now: datetime, outage: Outage ) -> None: path = tmp_path / "state.json" service = _service(path, FakeSaapa({"123": [outage]}), FakeNotifier(fail=True)) result = service.run(now) assert result.sent == 0 assert result.errors == ["Eitaa unavailable"] assert result.state_changed is False assert not path.exists() def test_fetch_failure_for_one_bill_does_not_block_another( tmp_path: Path, now: datetime, outage: Outage ) -> None: saapa = FakeSaapa({"123": ExternalServiceError("SAAPA failed for 123"), "456": [outage]}) notifier = FakeNotifier() service = _service( tmp_path / "state.json", saapa, notifier, {"123": ("chat-a",), "456": ("chat-b",)}, ) result = service.run(now) assert result.sent == 1 assert result.errors == ["SAAPA failed for 123"] assert len(notifier.messages) == 1 assert notifier.messages[0][0] == "chat-b" def test_each_bill_is_sent_to_its_configured_chat( tmp_path: Path, now: datetime, outage: Outage ) -> None: second_outage = replace(outage, outage_number="404992999002") saapa = FakeSaapa({"123": [outage], "456": [second_outage]}) notifier = FakeNotifier() service = _service( tmp_path / "state.json", saapa, notifier, {"123": ("chat-a",), "456": ("chat-b",)}, ) result = service.run(now) assert result.sent == 2 assert [chat_id for chat_id, _ in notifier.messages] == ["chat-a", "chat-b"] assert "شناسه قبض: 123" in notifier.messages[0][1] assert "شناسه قبض: 456" in notifier.messages[1][1] def test_one_bill_is_sent_to_all_its_configured_chats( tmp_path: Path, now: datetime, outage: Outage ) -> None: saapa = FakeSaapa({"123": [outage]}) notifier = FakeNotifier() service = _service( tmp_path / "state.json", saapa, notifier, {"123": ("chat-a", "chat-b")}, ) result = service.run(now) assert result.sent == 2 assert len(saapa.calls) == 1 assert [chat_id for chat_id, _ in notifier.messages] == ["chat-a", "chat-b"] def test_state_advances_only_after_all_chats_accept_notification( tmp_path: Path, now: datetime, outage: Outage ) -> None: path = tmp_path / "state.json" notifier = FakeNotifier(fail_on_call=2) service = _service( path, FakeSaapa({"123": [outage]}), notifier, {"123": ("chat-a", "chat-b", "chat-c")}, ) result = service.run(now) assert result.sent == 2 assert result.errors == ["Eitaa unavailable"] assert [chat_id for chat_id, _ in notifier.messages] == ["chat-a", "chat-c"] assert result.state_changed is False assert not path.exists() def test_partial_delivery_persists_only_successful_events( tmp_path: Path, now: datetime, outage: Outage ) -> None: second_outage = replace(outage, outage_number="404992999002", start_time="12:00") path = tmp_path / "state.json" notifier = FakeNotifier(fail_on_call=2) service = _service(path, FakeSaapa({"123": [outage, second_outage]}), notifier) result = service.run(now) records = StateRepository(path).load()["bills"]["123"]["events"] assert result.sent == 1 assert result.errors == ["Eitaa unavailable"] assert set(records) == {outage.outage_number} def test_failed_fetch_never_advances_cancellation_counter( tmp_path: Path, now: datetime, outage: Outage ) -> None: path = tmp_path / "state.json" saapa = FakeSaapa({"123": [outage]}) service = _service(path, saapa, FakeNotifier()) service.run(now) original_state = path.read_text(encoding="utf-8") saapa.snapshots["123"] = ExternalServiceError("SAAPA unavailable") result = service.run(now) assert result.errors == ["SAAPA unavailable"] assert result.state_changed is False assert path.read_text(encoding="utf-8") == original_state def test_dry_run_never_writes_state(tmp_path: Path, now: datetime, outage: Outage) -> None: path = tmp_path / "state.json" service = _service(path, FakeSaapa({"123": [outage]}), FakeNotifier()) result = service.run(now, persist=False) assert result.sent == 1 assert result.state_changed is True assert not path.exists()