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[str] = [] self.fail = fail self.fail_on_call = fail_on_call self.calls = 0 def send(self, text: str) -> None: self.calls += 1 if self.fail or self.calls == self.fail_on_call: raise ExternalServiceError("Eitaa unavailable") self.messages.append(text) def _service( path: Path, saapa: FakeSaapa, notifier: FakeNotifier, bill_ids: tuple[str, ...] = ("123",), ) -> OutageService: return OutageService( bill_ids=bill_ids, 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] 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] assert "09:00" in notifier.messages[-1] assert "10:00" in notifier.messages[-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] saapa.snapshots["123"] = [outage] restored = service.run(now) assert restored.sent == 1 assert "بازگشت برنامه" in notifier.messages[-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", "456")) result = service.run(now) assert result.sent == 1 assert result.errors == ["SAAPA failed for 123"] assert len(notifier.messages) == 1 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()