feat: add Kurigram property notification bot

This commit is contained in:
Azimkin
2026-09-22 17:25:03 +02:00
commit bbaa093a85
17 changed files with 1038 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
from pathlib import Path
import pytest
from property_bot.bot import TelegramPropertyBot
from property_bot.config import AppConfig, PropertyApiConfig, TelegramConfig
from property_bot.consumer import ListingEventConsumer
from property_bot.listings import Listing
from property_bot.repository import SubscriptionRepository
class FakeClient:
def __init__(self, fail_photo: bool = False) -> None:
self.fail_photo = fail_photo
self.photos: list[tuple[int, str, str]] = []
self.messages: list[tuple[int, str]] = []
async def send_photo(self, chat_id: int, photo: str, caption: str) -> None:
if self.fail_photo:
raise RuntimeError("media unavailable")
self.photos.append((chat_id, photo, caption))
async def send_message(self, chat_id: int, text: str, **_: object) -> None:
self.messages.append((chat_id, text))
def app_config(path: Path) -> AppConfig:
return AppConfig(
telegram=TelegramConfig(1, "hash", "token"),
admin_user_ids=frozenset(),
property_api=PropertyApiConfig("ws://localhost:3000/ws", 1, 1, 2),
database_path=path,
)
def listing() -> Listing:
return Listing("id", "https://example.test", "Title", 1, "PLN", None, None, None, None, None, None, ("https://example.test/image.jpg",))
@pytest.mark.asyncio
async def test_photo_failure_falls_back_to_text_and_keeps_other_recipients(tmp_path: Path) -> None:
repository = SubscriptionRepository(tmp_path / "db.sqlite3")
await repository.open()
try:
await repository.activate(1, 1)
await repository.activate(2, 2)
client = FakeClient(fail_photo=True)
consumer = ListingEventConsumer(app_config(tmp_path / "db.sqlite3").property_api, lambda _: None) # type: ignore[arg-type]
bot = TelegramPropertyBot(client, app_config(tmp_path / "db.sqlite3"), repository, consumer) # type: ignore[arg-type]
await bot.notify_listing(listing())
assert [chat_id for chat_id, _ in client.messages] == [1, 2]
assert client.photos == []
finally:
await repository.close()

41
tests/test_config.py Normal file
View File

@@ -0,0 +1,41 @@
from pathlib import Path
import pytest
from property_bot.config import load_config
def write_config(path: Path, websocket_url: str = "ws://127.0.0.1:3000/ws") -> None:
path.write_text(
f'''[telegram]
api_id = 1
api_hash = "hash"
bot_token = "token"
[admins]
user_ids = [10, 20]
[property_api]
websocket_url = "{websocket_url}"
connect_timeout_seconds = 15
reconnect_initial_delay_seconds = 1
reconnect_max_delay_seconds = 30
[database]
path = "data/bot.sqlite3"
''',
encoding="utf-8",
)
def test_loads_valid_toml_config(tmp_path: Path) -> None:
path = tmp_path / "config.toml"
write_config(path)
config = load_config(path)
assert config.telegram.api_id == 1
assert config.admin_user_ids == frozenset({10, 20})
assert config.property_api.websocket_url == "ws://127.0.0.1:3000/ws"
def test_rejects_non_websocket_url(tmp_path: Path) -> None:
path = tmp_path / "config.toml"
write_config(path, "http://127.0.0.1:3000/ws")
with pytest.raises(ValueError, match="ws://"):
load_config(path)

72
tests/test_consumer.py Normal file
View File

@@ -0,0 +1,72 @@
import json
import asyncio
import pytest
from websockets.asyncio.server import serve
from property_bot.config import PropertyApiConfig
from property_bot.consumer import ListingEventConsumer
@pytest.mark.asyncio
async def test_consumer_ignores_service_ready_and_forwards_listing() -> None:
received = []
async def sink(listing: object) -> None:
received.append(listing)
consumer = ListingEventConsumer(
PropertyApiConfig("ws://localhost:3000/ws", 1, 1, 2), sink # type: ignore[arg-type]
)
assert await consumer.handle_message(json.dumps({"type": "service.ready"})) is False
assert (
await consumer.handle_message(
json.dumps(
{
"type": "listing.new",
"listing": {
"externalId": "abc",
"url": "https://example.test/a",
"title": "A",
"images": [],
},
}
)
)
) is True
assert len(received) == 1
assert consumer.status.last_event_at is not None
@pytest.mark.asyncio
async def test_consumer_reads_listing_from_websocket_server(unused_tcp_port: int) -> None:
received = asyncio.Event()
async def sink(_: object) -> None:
received.set()
async def handler(websocket: object) -> None:
await websocket.send( # type: ignore[attr-defined]
json.dumps(
{
"type": "listing.new",
"listing": {
"externalId": "from-socket",
"url": "https://example.test/a",
"title": "From socket",
},
}
)
)
async with serve(handler, "127.0.0.1", unused_tcp_port):
consumer = ListingEventConsumer(
PropertyApiConfig(f"ws://127.0.0.1:{unused_tcp_port}", 1, 0.01, 0.02), sink # type: ignore[arg-type]
)
task = asyncio.create_task(consumer.run())
try:
await asyncio.wait_for(received.wait(), timeout=2)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task

34
tests/test_listings.py Normal file
View File

@@ -0,0 +1,34 @@
from property_bot.listings import format_listing, listing_from_event
def event() -> dict[str, object]:
return {
"type": "listing.new",
"listing": {
"externalId": "abc",
"url": "https://example.test/listing",
"title": "Просторная квартира",
"price": 4500,
"currency": "PLN",
"additionalFees": 700,
"area": 62.5,
"rooms": 3,
"location": {"city": "Warszawa", "district": "Wola", "street": "Prosta"},
"images": ["https://example.test/image.jpg"],
},
}
def test_parses_and_formats_listing() -> None:
listing = listing_from_event(event())
assert listing is not None
assert listing.images == ("https://example.test/image.jpg",)
text = format_listing(listing)
assert "4500 PLN" in text
assert "Warszawa, Wola, Prosta" in text
assert "https://example.test/listing" in text
def test_ignores_ready_and_incomplete_events() -> None:
assert listing_from_event({"type": "service.ready"}) is None
assert listing_from_event({"type": "listing.new", "listing": {}}) is None

21
tests/test_repository.py Normal file
View File

@@ -0,0 +1,21 @@
from pathlib import Path
import pytest
from property_bot.repository import SubscriptionRepository
@pytest.mark.asyncio
async def test_subscription_lifecycle(tmp_path: Path) -> None:
repository = SubscriptionRepository(tmp_path / "subscriptions.sqlite3")
await repository.open()
try:
await repository.activate(100, 200)
assert await repository.active_chat_ids() == [100]
assert (await repository.get(100)).is_active is True # type: ignore[union-attr]
assert await repository.deactivate(100) is True
assert await repository.active_chat_ids() == []
await repository.activate(100, 201)
assert (await repository.get(100)).user_id == 201 # type: ignore[union-attr]
finally:
await repository.close()