Implemented the full clean rewrite for Gitea
This commit is contained in:
25
tests/conftest.py
Normal file
25
tests/conftest.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from blackout_notifier.models import TEHRAN, Outage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def now() -> datetime:
|
||||
return datetime(2026, 7, 17, 8, 0, tzinfo=TEHRAN)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outage() -> Outage:
|
||||
return Outage(
|
||||
outage_number="404992999001",
|
||||
outage_date="1405/04/27",
|
||||
start_time="09:00",
|
||||
stop_time="11:00",
|
||||
address="فیدر آزمایشی",
|
||||
reason="مدیریت بار",
|
||||
is_planned=True,
|
||||
)
|
||||
80
tests/test_clients.py
Normal file
80
tests/test_clients.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from blackout_notifier.clients import EitaaClient, ExternalServiceError, SaapaClient
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, body: Any, *, status_code: int = 200) -> None:
|
||||
self._body = body
|
||||
self.status_code = status_code
|
||||
self.ok = 200 <= status_code < 400
|
||||
|
||||
def json(self) -> Any:
|
||||
return self._body
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, response: FakeResponse) -> None:
|
||||
self.response = response
|
||||
self.calls: list[tuple[str, str, dict[str, Any]]] = []
|
||||
|
||||
def post(self, url: str, **kwargs: Any) -> FakeResponse:
|
||||
self.calls.append(("POST", url, kwargs))
|
||||
return self.response
|
||||
|
||||
def get(self, url: str, **kwargs: Any) -> FakeResponse:
|
||||
self.calls.append(("GET", url, kwargs))
|
||||
return self.response
|
||||
|
||||
|
||||
def _payload(number: int = 1) -> dict[str, Any]:
|
||||
return {
|
||||
"outage_number": number,
|
||||
"outage_date": "1405/04/27",
|
||||
"outage_start_time": "09:00",
|
||||
"outage_stop_time": "11:00",
|
||||
"outage_address": "آدرس",
|
||||
"reason_outage": "مدیریت بار",
|
||||
"is_planned": True,
|
||||
}
|
||||
|
||||
|
||||
def test_saapa_client_sends_expected_request() -> None:
|
||||
session = FakeSession(FakeResponse({"data": [_payload()]}))
|
||||
client = SaapaClient("secret", timeout=7, max_events_per_bill=25, session=session) # type: ignore[arg-type]
|
||||
|
||||
events = client.fetch("123", "1405/04/26", "1405/04/31")
|
||||
|
||||
assert len(events) == 1
|
||||
_, _, kwargs = session.calls[0]
|
||||
assert kwargs["headers"]["Authorization"] == "Bearer secret"
|
||||
assert kwargs["json"]["bill_id"] == "123"
|
||||
assert kwargs["timeout"] == 7
|
||||
|
||||
|
||||
def test_saapa_rejects_oversized_or_duplicate_snapshot() -> None:
|
||||
oversized = FakeSession(FakeResponse({"data": [_payload(1), _payload(2)]}))
|
||||
client = SaapaClient("token", timeout=1, max_events_per_bill=1, session=oversized) # type: ignore[arg-type]
|
||||
with pytest.raises(ExternalServiceError, match="limit is 1"):
|
||||
client.fetch("123", "from", "to")
|
||||
|
||||
duplicate = FakeSession(FakeResponse({"data": [_payload(1), _payload(1)]}))
|
||||
client = SaapaClient("token", timeout=1, max_events_per_bill=5, session=duplicate) # type: ignore[arg-type]
|
||||
with pytest.raises(ExternalServiceError, match="duplicate outage_number"):
|
||||
client.fetch("123", "from", "to")
|
||||
|
||||
|
||||
def test_eitaa_requires_ok_response() -> None:
|
||||
session = FakeSession(FakeResponse({"ok": False, "description": "denied"}))
|
||||
client = EitaaClient("token", "chat", timeout=2, session=session) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(ExternalServiceError, match="denied"):
|
||||
client.send("message")
|
||||
|
||||
_, url, kwargs = session.calls[0]
|
||||
assert url.endswith("/token/sendMessage")
|
||||
assert kwargs["params"] == {"chat_id": "chat", "text": "message"}
|
||||
55
tests/test_config.py
Normal file
55
tests/test_config.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from blackout_notifier.config import Config, ConfigurationError
|
||||
|
||||
REQUIRED = {
|
||||
"BARGHEMAN_TOKEN": "bargheman",
|
||||
"EITAAYAR_TOKEN": "eitaayar",
|
||||
"CHAT_ID": "chat",
|
||||
"BILL_IDS": " 123,456,123 ,, ",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def do_not_load_project_dotenv(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("blackout_notifier.config.load_dotenv", lambda **_: None)
|
||||
|
||||
|
||||
def test_config_parses_and_deduplicates_bill_ids(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name, value in REQUIRED.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
|
||||
config = Config.from_env()
|
||||
|
||||
assert config.bill_ids == ("123", "456")
|
||||
assert str(config.state_file) == "state/outages.json"
|
||||
assert config.lookahead_days == 5
|
||||
|
||||
|
||||
def test_config_reports_all_missing_required_values(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name in REQUIRED:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
expected = "BARGHEMAN_TOKEN.*EITAAYAR_TOKEN.*CHAT_ID.*BILL_IDS"
|
||||
with pytest.raises(ConfigurationError, match=expected):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
def test_config_rejects_non_decimal_bill_ids(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name, value in REQUIRED.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
monkeypatch.setenv("BILL_IDS", "123,761/pe9314604429")
|
||||
|
||||
with pytest.raises(ConfigurationError, match="decimal IDs"):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
def test_config_validates_numeric_ranges(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name, value in REQUIRED.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
monkeypatch.setenv("LOOKAHEAD_DAYS", "0")
|
||||
|
||||
with pytest.raises(ConfigurationError, match="between 1 and 30"):
|
||||
Config.from_env()
|
||||
57
tests/test_models.py
Normal file
57
tests/test_models.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from blackout_notifier.models import TEHRAN, Outage, OutageDataError, jalali_query_window
|
||||
|
||||
|
||||
def test_jalali_query_window_uses_tehran_date() -> None:
|
||||
utc_near_midnight = datetime.fromisoformat("2026-07-16T20:31:00+00:00")
|
||||
|
||||
assert jalali_query_window(utc_near_midnight, 5) == ("1405/04/26", "1405/04/31")
|
||||
|
||||
|
||||
def test_outage_parses_api_payload() -> None:
|
||||
outage = Outage.from_api(
|
||||
{
|
||||
"outage_number": 404992999001,
|
||||
"outage_date": "1405/04/27",
|
||||
"outage_start_time": "09:00",
|
||||
"outage_stop_time": "11:00",
|
||||
"outage_address": " فیدر آزمایشی ",
|
||||
"reason_outage": "مدیریت بار",
|
||||
"is_planned": True,
|
||||
}
|
||||
)
|
||||
|
||||
assert outage.outage_number == "404992999001"
|
||||
assert outage.address == "فیدر آزمایشی"
|
||||
assert outage.start_datetime() == datetime(2026, 7, 18, 9, 0, tzinfo=TEHRAN)
|
||||
|
||||
|
||||
def test_outage_supports_cross_midnight_window() -> None:
|
||||
outage = Outage(
|
||||
outage_number="1",
|
||||
outage_date="1405/04/27",
|
||||
start_time="23:00",
|
||||
stop_time="01:00",
|
||||
address="",
|
||||
reason="",
|
||||
is_planned=True,
|
||||
)
|
||||
|
||||
assert outage.stop_datetime() - outage.start_datetime() == timedelta(hours=2)
|
||||
|
||||
|
||||
def test_outage_rejects_invalid_date() -> None:
|
||||
with pytest.raises(OutageDataError, match="invalid Jalali"):
|
||||
Outage.from_api(
|
||||
{
|
||||
"outage_number": "1",
|
||||
"outage_date": "not-a-date",
|
||||
"outage_start_time": "09:00",
|
||||
"outage_stop_time": "11:00",
|
||||
}
|
||||
)
|
||||
183
tests/test_service.py
Normal file
183
tests/test_service.py
Normal file
@@ -0,0 +1,183 @@
|
||||
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()
|
||||
28
tests/test_state.py
Normal file
28
tests/test_state.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from blackout_notifier.state import StateError, StateRepository, empty_state
|
||||
|
||||
|
||||
def test_state_round_trip_is_deterministic(tmp_path: Path) -> None:
|
||||
repository = StateRepository(tmp_path / "nested" / "outages.json")
|
||||
|
||||
repository.save(empty_state())
|
||||
first = repository.path.read_text(encoding="utf-8")
|
||||
repository.save(repository.load())
|
||||
|
||||
assert repository.load() == empty_state()
|
||||
assert repository.path.read_text(encoding="utf-8") == first
|
||||
|
||||
|
||||
def test_corrupt_state_fails_closed(tmp_path: Path) -> None:
|
||||
path = tmp_path / "outages.json"
|
||||
path.write_text("not json", encoding="utf-8")
|
||||
|
||||
with pytest.raises(StateError, match="Cannot read state"):
|
||||
StateRepository(path).load()
|
||||
|
||||
assert path.read_text(encoding="utf-8") == "not json"
|
||||
Reference in New Issue
Block a user