Implemented the full clean rewrite for Gitea
All checks were successful
Test blackout notifier / test (push) Successful in 3m41s
Hourly blackout check / check (push) Successful in 8s

This commit is contained in:
Meghdad
2026-07-17 04:33:16 +03:30
parent d7f53ba234
commit 4218e4ac36
30 changed files with 1437 additions and 384 deletions

25
tests/conftest.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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"