feat: add Kurigram property notification bot
This commit is contained in:
54
tests/test_bot_delivery.py
Normal file
54
tests/test_bot_delivery.py
Normal 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
41
tests/test_config.py
Normal 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
72
tests/test_consumer.py
Normal 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
34
tests/test_listings.py
Normal 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
21
tests/test_repository.py
Normal 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()
|
||||
Reference in New Issue
Block a user