diff --git a/core/__init__.py b/core/__init__.py index 2d99708..3722584 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -6,10 +6,16 @@ from core.models import ( RoomData, TrackingData, ) +from core.ports import ( + RoomStorage, + TrackingStorage, +) __all__ = [ "Bounty", "TrackedBounty", "RoomData", "TrackingData", + "RoomStorage", + "TrackingStorage", ] diff --git a/core/ports.py b/core/ports.py new file mode 100644 index 0000000..879be0e --- /dev/null +++ b/core/ports.py @@ -0,0 +1,65 @@ +"""Abstract storage interfaces (Ports) for JIGAIDO storage adapters.""" + +from typing import Protocol, runtime_checkable + +from core.models import Bounty, RoomData, TrackingData, TrackedBounty + + +@runtime_checkable +class RoomStorage(Protocol): + """Storage port for room bounties. + + A room is identified by room_id: + - Negative room_id: Telegram group (e.g., -1001) + - Positive room_id: DM/personal context (user's Telegram ID) + + This single port handles both group and personal bounties. + """ + + def load(self, room_id: int) -> RoomData | None: + """Load all data for a room. Returns None if room doesn't exist.""" + ... + + def save(self, room_data: RoomData) -> None: + """Save all data for a room.""" + ... + + def add_bounty(self, room_id: int, bounty: Bounty) -> None: + """Add a new bounty to a room. Creates room if it doesn't exist.""" + ... + + def update_bounty(self, room_id: int, bounty: Bounty) -> None: + """Update an existing bounty in a room.""" + ... + + def delete_bounty(self, room_id: int, bounty_id: int) -> None: + """Delete a bounty from a room.""" + ... + + def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None: + """Get a specific bounty from a room by ID.""" + ... + + +@runtime_checkable +class TrackingStorage(Protocol): + """Storage port for tracking data. + + Tracks which bounties a user is tracking in a specific room. + """ + + def load(self, room_id: int, user_id: int) -> TrackingData | None: + """Load tracking data for a user in a room. Returns None if not tracking anything.""" + ... + + def save(self, tracking_data: TrackingData) -> None: + """Save tracking data.""" + ... + + def track_bounty(self, room_id: int, user_id: int, tracked: TrackedBounty) -> None: + """Add a bounty to a user's tracking list. Creates tracking entry if needed.""" + ... + + def untrack_bounty(self, room_id: int, user_id: int, bounty_id: int) -> None: + """Remove a bounty from a user's tracking list.""" + ... diff --git a/tests/test_ports.py b/tests/test_ports.py new file mode 100644 index 0000000..3d67284 --- /dev/null +++ b/tests/test_ports.py @@ -0,0 +1,268 @@ +"""Tests for core/ports.py — storage interfaces.""" + +import pytest + +from core.models import Bounty, RoomData, TrackingData, TrackedBounty +from core.ports import RoomStorage, TrackingStorage + + +class SimpleRoomStorage: + """Minimal mock without ensure_room - tests if add_bounty works without it. + + This mock only has the basic CRUD methods. It does NOT implement ensure_room(). + If add_bounty() still works with this simple mock, then ensure_room() may not + be needed as a public Protocol method. + """ + + def __init__(self): + self._rooms: dict[int, RoomData] = {} + + def load(self, room_id: int) -> RoomData | None: + return self._rooms.get(room_id) + + def save(self, room_data: RoomData) -> None: + self._rooms[room_data.room_id] = room_data + + def add_bounty(self, room_id: int, bounty: Bounty) -> None: + if room_id not in self._rooms: + self._rooms[room_id] = RoomData(room_id=room_id, bounties=[], next_id=1) + self._rooms[room_id].bounties.append(bounty) + + def update_bounty(self, room_id: int, bounty: Bounty) -> None: + if room_id in self._rooms: + for i, b in enumerate(self._rooms[room_id].bounties): + if b.id == bounty.id: + self._rooms[room_id].bounties[i] = bounty + break + + def delete_bounty(self, room_id: int, bounty_id: int) -> None: + if room_id in self._rooms: + self._rooms[room_id].bounties = [ + b for b in self._rooms[room_id].bounties if b.id != bounty_id + ] + + def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None: + if room_id in self._rooms: + for b in self._rooms[room_id].bounties: + if b.id == bounty_id: + return b + return None + + +class SimpleTrackingStorage: + """Minimal mock without ensure_tracking - tests if track_bounty works without it. + + This mock only has the basic methods. It does NOT implement ensure_tracking(). + If track_bounty() still works with this simple mock, then ensure_tracking() may not + be needed as a public Protocol method. + """ + + def __init__(self): + self._tracking: dict[tuple[int, int], TrackingData] = {} + + def load(self, room_id: int, user_id: int) -> TrackingData | None: + return self._tracking.get((room_id, user_id)) + + def save(self, tracking_data: TrackingData) -> None: + self._tracking[(tracking_data.room_id, tracking_data.user_id)] = tracking_data + + def track_bounty(self, room_id: int, user_id: int, tracked: TrackedBounty) -> None: + key = (room_id, user_id) + if key not in self._tracking: + self._tracking[key] = TrackingData( + room_id=room_id, user_id=user_id, tracked=[] + ) + self._tracking[key].tracked.append(tracked) + + def untrack_bounty(self, room_id: int, user_id: int, bounty_id: int) -> None: + key = (room_id, user_id) + if key in self._tracking: + self._tracking[key].tracked = [ + t for t in self._tracking[key].tracked if t.bounty_id != bounty_id + ] + + +class MockRoomStorage: + """Mock implementation of RoomStorage for testing.""" + + def __init__(self): + self._rooms: dict[int, RoomData] = {} + + def load(self, room_id: int) -> RoomData | None: + return self._rooms.get(room_id) + + def save(self, room_data: RoomData) -> None: + self._rooms[room_data.room_id] = room_data + + def add_bounty(self, room_id: int, bounty: Bounty) -> None: + if room_id not in self._rooms: + self._rooms[room_id] = RoomData(room_id=room_id, bounties=[], next_id=1) + self._rooms[room_id].bounties.append(bounty) + + def update_bounty(self, room_id: int, bounty: Bounty) -> None: + if room_id in self._rooms: + for i, b in enumerate(self._rooms[room_id].bounties): + if b.id == bounty.id: + self._rooms[room_id].bounties[i] = bounty + break + + def delete_bounty(self, room_id: int, bounty_id: int) -> None: + if room_id in self._rooms: + self._rooms[room_id].bounties = [ + b for b in self._rooms[room_id].bounties if b.id != bounty_id + ] + + def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None: + if room_id in self._rooms: + for b in self._rooms[room_id].bounties: + if b.id == bounty_id: + return b + return None + + +class MockTrackingStorage: + """Mock implementation of TrackingStorage for testing.""" + + def __init__(self): + self._tracking: dict[tuple[int, int], TrackingData] = {} + + def load(self, room_id: int, user_id: int) -> TrackingData | None: + return self._tracking.get((room_id, user_id)) + + def save(self, tracking_data: TrackingData) -> None: + self._tracking[(tracking_data.room_id, tracking_data.user_id)] = tracking_data + + def track_bounty(self, room_id: int, user_id: int, tracked: TrackedBounty) -> None: + key = (room_id, user_id) + if key not in self._tracking: + self._tracking[key] = TrackingData( + room_id=room_id, user_id=user_id, tracked=[] + ) + self._tracking[key].tracked.append(tracked) + + def untrack_bounty(self, room_id: int, user_id: int, bounty_id: int) -> None: + key = (room_id, user_id) + if key in self._tracking: + self._tracking[key].tracked = [ + t for t in self._tracking[key].tracked if t.bounty_id != bounty_id + ] + + +class TestRoomStorage: + def test_simple_storage_without_ensure_room(self): + """Test that SimpleRoomStorage (no ensure_room) still works. + + This verifies that ensure_room() is NOT needed as a public Protocol method, + since add_bounty() can create the room internally without it. + """ + storage = SimpleRoomStorage() + bounty = Bounty( + id=1, + text="Test", + link=None, + due_date_ts=None, + created_at=0, + created_by_user_id=123, + ) + storage.add_bounty(-1001, bounty) + room = storage.load(-1001) + assert room is not None + assert len(room.bounties) == 1 + assert room.bounties[0].text == "Test" + assert room.next_id == 1 + + def test_mock_implements_room_storage_protocol(self): + storage: RoomStorage = MockRoomStorage() + assert isinstance(storage, RoomStorage) + + def test_add_bounty_creates_room_if_not_exists(self): + storage = MockRoomStorage() + bounty = Bounty( + id=1, + text="Test", + link=None, + due_date_ts=None, + created_at=0, + created_by_user_id=123, + ) + storage.add_bounty(-1001, bounty) + room = storage.load(-1001) + assert room is not None + assert len(room.bounties) == 1 + assert room.bounties[0].text == "Test" + + def test_update_bounty(self): + storage = MockRoomStorage() + bounty = Bounty( + id=1, + text="Original", + link=None, + due_date_ts=None, + created_at=0, + created_by_user_id=123, + ) + storage.add_bounty(-1001, bounty) + updated = Bounty( + id=1, + text="Updated", + link=None, + due_date_ts=None, + created_at=0, + created_by_user_id=123, + ) + storage.update_bounty(-1001, updated) + result = storage.get_bounty(-1001, 1) + assert result is not None + assert result.text == "Updated" + + def test_delete_bounty(self): + storage = MockRoomStorage() + bounty = Bounty( + id=1, + text="Test", + link=None, + due_date_ts=None, + created_at=0, + created_by_user_id=123, + ) + storage.add_bounty(-1001, bounty) + storage.delete_bounty(-1001, 1) + assert storage.get_bounty(-1001, 1) is None + + +class TestTrackingStorage: + def test_simple_storage_without_ensure_tracking(self): + """Test that SimpleTrackingStorage (no ensure_tracking) still works. + + This verifies that ensure_tracking() is NOT needed as a public Protocol method, + since track_bounty() can create the tracking entry internally without it. + """ + storage = SimpleTrackingStorage() + tracked = TrackedBounty(bounty_id=5, created_at=0) + storage.track_bounty(-1001, 123456, tracked) + result = storage.load(-1001, 123456) + assert result is not None + assert len(result.tracked) == 1 + assert result.tracked[0].bounty_id == 5 + + def test_mock_implements_tracking_storage_protocol(self): + storage: TrackingStorage = MockTrackingStorage() + assert isinstance(storage, TrackingStorage) + + def test_track_bounty_creates_tracking_if_not_exists(self): + storage = MockTrackingStorage() + tracked = TrackedBounty(bounty_id=5, created_at=0) + storage.track_bounty(-1001, 123456, tracked) + result = storage.load(-1001, 123456) + assert result is not None + assert len(result.tracked) == 1 + assert result.tracked[0].bounty_id == 5 + + def test_untrack_bounty(self): + storage = MockTrackingStorage() + tracked = TrackedBounty(bounty_id=5, created_at=0) + storage.track_bounty(-1001, 123456, tracked) + storage.untrack_bounty(-1001, 123456, 5) + result = storage.load(-1001, 123456) + assert result is not None + assert len(result.tracked) == 0