feat: add multi-chat routing and bill-specific dashboard countdown
This commit is contained in:
@@ -70,10 +70,10 @@ def test_saapa_rejects_oversized_or_duplicate_snapshot() -> None:
|
||||
|
||||
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]
|
||||
client = EitaaClient("token", timeout=2, session=session) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(ExternalServiceError, match="denied"):
|
||||
client.send("message")
|
||||
client.send("chat", "message")
|
||||
|
||||
_, url, kwargs = session.calls[0]
|
||||
assert url.endswith("/token/sendMessage")
|
||||
|
||||
@@ -7,8 +7,7 @@ from blackout_notifier.config import Config, ConfigurationError
|
||||
REQUIRED = {
|
||||
"BARGHEMAN_TOKEN": "bargheman",
|
||||
"EITAAYAR_TOKEN": "eitaayar",
|
||||
"CHAT_ID": "chat",
|
||||
"BILL_IDS": " 123,456,123 ,, ",
|
||||
"BILL_CHAT_MAP": " 123:chat-a,456:chat-b,123:chat-c,123:chat-a ,, ",
|
||||
}
|
||||
|
||||
|
||||
@@ -17,13 +16,17 @@ 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:
|
||||
def test_config_parses_and_deduplicates_bill_routes(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 dict(config.bill_chat_ids) == {
|
||||
"123": ("chat-a", "chat-c"),
|
||||
"456": ("chat-b",),
|
||||
}
|
||||
assert str(config.state_file) == "state/outages.json"
|
||||
assert config.lookahead_days == 5
|
||||
|
||||
@@ -40,12 +43,39 @@ def test_config_reports_all_missing_required_values(monkeypatch: pytest.MonkeyPa
|
||||
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")
|
||||
monkeypatch.setenv("BILL_CHAT_MAP", "123:chat-a,761/pe9314604429:chat-b")
|
||||
|
||||
with pytest.raises(ConfigurationError, match="decimal IDs"):
|
||||
with pytest.raises(ConfigurationError, match="bill IDs must be decimal"):
|
||||
Config.from_env()
|
||||
|
||||
|
||||
def test_config_rejects_malformed_routes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
for name, value in REQUIRED.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
|
||||
monkeypatch.setenv("BILL_CHAT_MAP", "123")
|
||||
with pytest.raises(ConfigurationError, match="BILL_ID:CHAT_ID"):
|
||||
Config.from_env()
|
||||
|
||||
def test_legacy_single_chat_configuration_is_still_supported(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("BARGHEMAN_TOKEN", "bargheman")
|
||||
monkeypatch.setenv("EITAAYAR_TOKEN", "eitaayar")
|
||||
monkeypatch.setenv("CHAT_ID", "legacy-chat")
|
||||
monkeypatch.setenv("BILL_IDS", "123,456,123")
|
||||
monkeypatch.delenv("BILL_CHAT_MAP", raising=False)
|
||||
|
||||
config = Config.from_env()
|
||||
|
||||
assert dict(config.bill_chat_ids) == {
|
||||
"123": ("legacy-chat",),
|
||||
"456": ("legacy-chat",),
|
||||
}
|
||||
|
||||
|
||||
def test_config_validates_numeric_ranges(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name, value in REQUIRED.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
|
||||
@@ -142,6 +142,29 @@ assert_contains "${SCHEDULE[1]}" '"outage_number":"2"' \
|
||||
assert_contains "${SCHEDULE[2]}" '"outage_number":"5"' \
|
||||
"later events should remain in the schedule"
|
||||
|
||||
build_schedule "$FIXTURE" "$NOW_EPOCH" "222"
|
||||
assert_equal "3" "${#SCHEDULE[@]}" \
|
||||
"selecting a countdown bill should not filter the schedule"
|
||||
assert_equal "UPCOMING" "${HERO%%$'\t'*}" \
|
||||
"the countdown should ignore another bill's ongoing outage"
|
||||
assert_contains "$HERO" '"bill_id":"222"' \
|
||||
"the countdown should use only the selected bill"
|
||||
SELECTED_OUTPUT="$(NO_COLOR=1 COLUMNS=80 render_dashboard "$NOW_EPOCH" 80 "222")"
|
||||
assert_contains "$SELECTED_OUTPUT" "COUNTDOWN FOR BILL 222" \
|
||||
"the selected countdown bill should be labeled"
|
||||
assert_contains "$SELECTED_OUTPUT" "OUTAGE SCHEDULE (3)" \
|
||||
"the selected countdown bill should not hide other bills from the list"
|
||||
|
||||
build_schedule "$FIXTURE" "$NOW_EPOCH" "333"
|
||||
assert_equal "" "$HERO" \
|
||||
"a bill without upcoming outages should not borrow another bill's countdown"
|
||||
NO_HERO_OUTPUT="$(NO_COLOR=1 COLUMNS=80 render_dashboard "$NOW_EPOCH" 80 "333")"
|
||||
assert_contains "$NO_HERO_OUTPUT" "NO CURRENT OR UPCOMING OUTAGE" \
|
||||
"a selected bill without outages should have a clear countdown status"
|
||||
assert_contains "$NO_HERO_OUTPUT" "OUTAGE SCHEDULE (3)" \
|
||||
"all bills should remain listed when the selected bill has no outage"
|
||||
|
||||
build_schedule "$FIXTURE" "$NOW_EPOCH"
|
||||
OUTPUT="$(NO_COLOR=1 COLUMNS=80 render_dashboard "$NOW_EPOCH")"
|
||||
if [[ "$OUTPUT" == *"POWER RETURNS IN"* || "$OUTPUT" == *"NEXT OUTAGE IN"* ]]; then
|
||||
printf 'FAIL: countdown output contains a redundant heading\n' >&2
|
||||
@@ -219,4 +242,9 @@ if (
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if main --bill-id invalid >/dev/null 2>&1; then
|
||||
printf 'FAIL: a non-decimal countdown bill ID was accepted\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Bash CLI tests passed\n'
|
||||
|
||||
@@ -26,26 +26,26 @@ class FakeSaapa:
|
||||
|
||||
class FakeNotifier:
|
||||
def __init__(self, *, fail: bool = False, fail_on_call: int | None = None) -> None:
|
||||
self.messages: list[str] = []
|
||||
self.messages: list[tuple[str, str]] = []
|
||||
self.fail = fail
|
||||
self.fail_on_call = fail_on_call
|
||||
self.calls = 0
|
||||
|
||||
def send(self, text: str) -> None:
|
||||
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(text)
|
||||
self.messages.append((chat_id, text))
|
||||
|
||||
|
||||
def _service(
|
||||
path: Path,
|
||||
saapa: FakeSaapa,
|
||||
notifier: FakeNotifier,
|
||||
bill_ids: tuple[str, ...] = ("123",),
|
||||
bill_chat_ids: Mapping[str, tuple[str, ...]] | None = None,
|
||||
) -> OutageService:
|
||||
return OutageService(
|
||||
bill_ids=bill_ids,
|
||||
bill_chat_ids=bill_chat_ids or {"123": ("chat-a",)},
|
||||
lookahead_days=5,
|
||||
saapa=saapa, # type: ignore[arg-type]
|
||||
notifier=notifier,
|
||||
@@ -65,7 +65,7 @@ def test_first_run_sends_upcoming_once(tmp_path: Path, now: datetime, outage: Ou
|
||||
|
||||
assert first.sent == 1
|
||||
assert first.state_changed is True
|
||||
assert "خاموشی جدید" in notifier.messages[0]
|
||||
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
|
||||
@@ -82,9 +82,9 @@ def test_changed_outage_sends_field_diff(tmp_path: Path, now: datetime, outage:
|
||||
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]
|
||||
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(
|
||||
@@ -103,12 +103,12 @@ def test_cancellation_requires_two_successful_omissions_and_can_reactivate(
|
||||
|
||||
assert first_missing.sent == 0
|
||||
assert second_missing.sent == 1
|
||||
assert "دو بررسی پیاپی" in notifier.messages[-1]
|
||||
assert "دو بررسی پیاپی" in notifier.messages[-1][1]
|
||||
|
||||
saapa.snapshots["123"] = [outage]
|
||||
restored = service.run(now)
|
||||
assert restored.sent == 1
|
||||
assert "بازگشت برنامه" in notifier.messages[-1]
|
||||
assert "بازگشت برنامه" in notifier.messages[-1][1]
|
||||
|
||||
|
||||
def test_failed_notification_does_not_advance_state(
|
||||
@@ -130,13 +130,80 @@ def test_fetch_failure_for_one_bill_does_not_block_another(
|
||||
) -> None:
|
||||
saapa = FakeSaapa({"123": ExternalServiceError("SAAPA failed for 123"), "456": [outage]})
|
||||
notifier = FakeNotifier()
|
||||
service = _service(tmp_path / "state.json", saapa, notifier, ("123", "456"))
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user