184 lines
5.8 KiB
Python
184 lines
5.8 KiB
Python
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()
|