diff --git a/adapters/__init__.py b/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapters/storage/__init__.py b/adapters/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapters/storage/json_file.py b/adapters/storage/json_file.py new file mode 100644 index 0000000..f2f8de0 --- /dev/null +++ b/adapters/storage/json_file.py @@ -0,0 +1,202 @@ +"""JSON file storage adapter for JIGAIDO. + +Implements RoomStorage and TrackingStorage ports using JSON file persistence. +Data stored at: +- Rooms: ~/.jigaido/data/.json +- Tracking: ~/.jigaido/tracking/_.json +""" + +import json +import os +from pathlib import Path + +from core.models import Bounty, RoomData, TrackingData, TrackedBounty +from core.ports import RoomStorage, TrackingStorage + + +class JsonFileRoomStorage: + """RoomStorage implementation using JSON files. + + Stores room data at ~/.jigaido/data/.json + """ + + def __init__(self, data_dir: Path | None = None): + if data_dir is None: + data_dir = Path.home() / ".jigaido" / "data" + self._data_dir = data_dir + self._data_dir.mkdir(parents=True, exist_ok=True) + + def _get_file_path(self, room_id: int) -> Path: + return self._data_dir / f"{room_id}.json" + + def load(self, room_id: int) -> RoomData | None: + """Load room data from JSON file. Returns None if not found.""" + file_path = self._get_file_path(room_id) + if not file_path.exists(): + return None + + with open(file_path, "r") as f: + data = json.load(f) + + bounties = [ + Bounty( + id=b["id"], + text=b.get("text"), + link=b.get("link"), + due_date_ts=b.get("due_date_ts"), + created_at=b["created_at"], + created_by_user_id=b["created_by_user_id"], + ) + for b in data.get("bounties", []) + ] + + return RoomData( + room_id=data["room_id"], + bounties=bounties, + next_id=data["next_id"], + ) + + def save(self, room_data: RoomData) -> None: + """Save room data to JSON file.""" + data = { + "room_id": room_data.room_id, + "next_id": room_data.next_id, + "bounties": [ + { + "id": b.id, + "text": b.text, + "link": b.link, + "due_date_ts": b.due_date_ts, + "created_at": b.created_at, + "created_by_user_id": b.created_by_user_id, + } + for b in room_data.bounties + ], + } + + with open(self._get_file_path(room_data.room_id), "w") as f: + json.dump(data, f) + + def add_bounty(self, room_id: int, bounty: Bounty) -> None: + """Add a bounty to a room, creating the room if necessary.""" + room_data = self.load(room_id) + if room_data is None: + room_data = RoomData(room_id=room_id, bounties=[], next_id=1) + + room_data.bounties.append(bounty) + if bounty.id >= room_data.next_id: + room_data.next_id = bounty.id + 1 + + self.save(room_data) + + def update_bounty(self, room_id: int, bounty: Bounty) -> None: + """Update an existing bounty in a room.""" + room_data = self.load(room_id) + if room_data is None: + return + + for i, b in enumerate(room_data.bounties): + if b.id == bounty.id: + room_data.bounties[i] = bounty + break + + self.save(room_data) + + def delete_bounty(self, room_id: int, bounty_id: int) -> None: + """Delete a bounty from a room.""" + room_data = self.load(room_id) + if room_data is None: + return + + room_data.bounties = [b for b in room_data.bounties if b.id != bounty_id] + self.save(room_data) + + def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None: + """Get a specific bounty from a room by ID.""" + room_data = self.load(room_id) + if room_data is None: + return None + + for b in room_data.bounties: + if b.id == bounty_id: + return b + + return None + + +class JsonFileTrackingStorage: + """TrackingStorage implementation using JSON files. + + Stores tracking data at ~/.jigaido/tracking/_.json + """ + + def __init__(self, tracking_dir: Path | None = None): + if tracking_dir is None: + tracking_dir = Path.home() / ".jigaido" / "tracking" + self._tracking_dir = tracking_dir + self._tracking_dir.mkdir(parents=True, exist_ok=True) + + def _get_file_path(self, room_id: int, user_id: int) -> Path: + return self._tracking_dir / f"{room_id}_{user_id}.json" + + def load(self, room_id: int, user_id: int) -> TrackingData | None: + """Load tracking data from JSON file. Returns None if not found.""" + file_path = self._get_file_path(room_id, user_id) + if not file_path.exists(): + return None + + with open(file_path, "r") as f: + data = json.load(f) + + tracked = [ + TrackedBounty( + bounty_id=t["bounty_id"], + created_at=t["created_at"], + ) + for t in data.get("tracked", []) + ] + + return TrackingData( + room_id=data["room_id"], + user_id=data["user_id"], + tracked=tracked, + ) + + def save(self, tracking_data: TrackingData) -> None: + """Save tracking data to JSON file.""" + data = { + "room_id": tracking_data.room_id, + "user_id": tracking_data.user_id, + "tracked": [ + { + "bounty_id": t.bounty_id, + "created_at": t.created_at, + } + for t in tracking_data.tracked + ], + } + + with open( + self._get_file_path(tracking_data.room_id, tracking_data.user_id), "w" + ) as f: + json.dump(data, f) + + def track_bounty(self, room_id: int, user_id: int, tracked: TrackedBounty) -> None: + """Add a bounty to a user's tracking list, creating the tracking entry if needed.""" + tracking_data = self.load(room_id, user_id) + if tracking_data is None: + tracking_data = TrackingData(room_id=room_id, user_id=user_id, tracked=[]) + + tracking_data.tracked.append(tracked) + self.save(tracking_data) + + def untrack_bounty(self, room_id: int, user_id: int, bounty_id: int) -> None: + """Remove a bounty from a user's tracking list.""" + tracking_data = self.load(room_id, user_id) + if tracking_data is None: + return + + tracking_data.tracked = [ + t for t in tracking_data.tracked if t.bounty_id != bounty_id + ] + self.save(tracking_data) diff --git a/tests/test_json_file_adapter.py b/tests/test_json_file_adapter.py new file mode 100644 index 0000000..068d2f8 --- /dev/null +++ b/tests/test_json_file_adapter.py @@ -0,0 +1,269 @@ +"""Tests for adapters/storage/json_file.py — JSON file storage adapter.""" + +import pytest +import tempfile +import shutil +from pathlib import Path + +from core.models import Bounty, RoomData, TrackingData, TrackedBounty +from core.ports import RoomStorage, TrackingStorage +from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage + + +class TestJsonFileRoomStorage: + """Tests for JsonFileRoomStorage adapter.""" + + @pytest.fixture + def temp_dir(self): + """Create a temporary directory for test files.""" + tmp = tempfile.mkdtemp() + yield Path(tmp) + shutil.rmtree(tmp) + + @pytest.fixture + def storage(self, temp_dir): + """Create a JsonFileRoomStorage instance with temp directory.""" + return JsonFileRoomStorage(data_dir=temp_dir) + + def test_implements_room_storage_protocol(self, storage): + """Verify JsonFileRoomStorage implements RoomStorage protocol.""" + assert isinstance(storage, RoomStorage) + + def test_load_returns_none_for_nonexistent_room(self, storage): + """Load returns None if room doesn't exist.""" + result = storage.load(-1001) + assert result is None + + def test_save_and_load_room(self, storage): + """Test saving and loading room data.""" + room = RoomData(room_id=-1001, bounties=[], next_id=1) + storage.save(room) + + loaded = storage.load(-1001) + assert loaded is not None + assert loaded.room_id == -1001 + assert loaded.next_id == 1 + assert loaded.bounties == [] + + def test_add_bounty_creates_room(self, storage): + """Test add_bounty creates room if it doesn't exist.""" + bounty = Bounty( + id=1, + text="Test bounty", + link=None, + due_date_ts=None, + created_at=1000, + 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 bounty" + assert room.next_id == 2 + + def test_add_bounty_appends_to_existing_room(self, storage): + """Test add_bounty appends to existing room.""" + bounty1 = Bounty( + id=1, + text="First", + link=None, + due_date_ts=None, + created_at=1000, + created_by_user_id=123, + ) + bounty2 = Bounty( + id=2, + text="Second", + link=None, + due_date_ts=None, + created_at=1001, + created_by_user_id=123, + ) + storage.add_bounty(-1001, bounty1) + storage.add_bounty(-1001, bounty2) + + room = storage.load(-1001) + assert len(room.bounties) == 2 + assert room.next_id == 3 + + def test_update_bounty(self, storage): + """Test updating an existing bounty.""" + bounty = Bounty( + id=1, + text="Original", + link=None, + due_date_ts=None, + created_at=1000, + created_by_user_id=123, + ) + storage.add_bounty(-1001, bounty) + + updated = Bounty( + id=1, + text="Updated", + link="https://example.com", + due_date_ts=2000, + created_at=1000, + 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" + assert result.link == "https://example.com" + + def test_delete_bounty(self, storage): + """Test deleting a bounty.""" + bounty = Bounty( + id=1, + text="To delete", + link=None, + due_date_ts=None, + created_at=1000, + created_by_user_id=123, + ) + storage.add_bounty(-1001, bounty) + storage.delete_bounty(-1001, 1) + + result = storage.get_bounty(-1001, 1) + assert result is None + + def test_get_bounty_returns_none_for_nonexistent(self, storage): + """Test get_bounty returns None for non-existent bounty.""" + result = storage.get_bounty(-1001, 999) + assert result is None + + def test_update_bounty_for_nonexistent_room(self, storage): + """Test update_bounty does nothing for non-existent room.""" + bounty = Bounty( + id=1, + text="Test", + link=None, + due_date_ts=None, + created_at=1000, + created_by_user_id=123, + ) + result = storage.update_bounty(-1001, bounty) + # Should not raise, should just return + + +class TestJsonFileTrackingStorage: + """Tests for JsonFileTrackingStorage adapter.""" + + @pytest.fixture + def temp_dir(self): + """Create a temporary directory for test files.""" + tmp = tempfile.mkdtemp() + yield Path(tmp) + shutil.rmtree(tmp) + + @pytest.fixture + def storage(self, temp_dir): + """Create a JsonFileTrackingStorage instance with temp directory.""" + return JsonFileTrackingStorage(tracking_dir=temp_dir) + + def test_implements_tracking_storage_protocol(self, storage): + """Verify JsonFileTrackingStorage implements TrackingStorage protocol.""" + assert isinstance(storage, TrackingStorage) + + def test_load_returns_none_for_nonexistent_tracking(self, storage): + """Load returns None if tracking doesn't exist.""" + result = storage.load(-1001, 123456) + assert result is None + + def test_save_and_load_tracking(self, storage): + """Test saving and loading tracking data.""" + tracking = TrackingData( + room_id=-1001, + user_id=123456, + tracked=[ + TrackedBounty(bounty_id=1, created_at=1000), + ], + ) + storage.save(tracking) + + loaded = storage.load(-1001, 123456) + assert loaded is not None + assert loaded.room_id == -1001 + assert loaded.user_id == 123456 + assert len(loaded.tracked) == 1 + + def test_track_bounty_creates_tracking(self, storage): + """Test track_bounty creates tracking if it doesn't exist.""" + tracked = TrackedBounty(bounty_id=5, created_at=1000) + 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_track_bounty_appends_to_existing(self, storage): + """Test track_bounty appends to existing tracking.""" + tracked1 = TrackedBounty(bounty_id=1, created_at=1000) + tracked2 = TrackedBounty(bounty_id=2, created_at=1001) + storage.track_bounty(-1001, 123456, tracked1) + storage.track_bounty(-1001, 123456, tracked2) + + result = storage.load(-1001, 123456) + assert len(result.tracked) == 2 + + def test_untrack_bounty(self, storage): + """Test untracking a bounty.""" + tracked = TrackedBounty(bounty_id=5, created_at=1000) + storage.track_bounty(-1001, 123456, tracked) + storage.untrack_bounty(-1001, 123456, 5) + + result = storage.load(-1001, 123456) + assert len(result.tracked) == 0 + + def test_untrack_nonexistent_does_not_raise(self, storage): + """Test untracking non-existent bounty doesn't raise.""" + # Should not raise + storage.untrack_bounty(-1001, 123456, 999) + + def test_tracking_persists_across_storage_instances(self, temp_dir): + """Test that tracking data persists when using different storage instances.""" + storage1 = JsonFileTrackingStorage(tracking_dir=temp_dir) + tracked = TrackedBounty(bounty_id=5, created_at=1000) + storage1.track_bounty(-1001, 123456, tracked) + + # Create a new storage instance pointing to the same directory + storage2 = JsonFileTrackingStorage(tracking_dir=temp_dir) + result = storage2.load(-1001, 123456) + assert result is not None + assert len(result.tracked) == 1 + + +class TestJsonFileRoomStoragePersistence: + """Test that room data persists across storage instances.""" + + @pytest.fixture + def temp_dir(self): + """Create a temporary directory for test files.""" + tmp = tempfile.mkdtemp() + yield Path(tmp) + shutil.rmtree(tmp) + + def test_room_persists_across_storage_instances(self, temp_dir): + """Test room data persists when using different storage instances.""" + storage1 = JsonFileRoomStorage(data_dir=temp_dir) + bounty = Bounty( + id=1, + text="Persisted bounty", + link=None, + due_date_ts=None, + created_at=1000, + created_by_user_id=123, + ) + storage1.add_bounty(-1001, bounty) + + # Create a new storage instance pointing to the same directory + storage2 = JsonFileRoomStorage(data_dir=temp_dir) + room = storage2.load(-1001) + assert room is not None + assert len(room.bounties) == 1 + assert room.bounties[0].text == "Persisted bounty"