From e79fbaddc521a5ba9dd0fda78b483c689a4063e6 Mon Sep 17 00:00:00 2001 From: shokollm <270575765+shokollm@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:36:31 +0000 Subject: [PATCH 1/2] feat(adapter): implement JSON file storage adapter for issue #9 Implements RoomStorage and TrackingStorage ports using JSON file persistence: - JsonFileRoomStorage: Stores room data at ~/.jigaido/data/.json - JsonFileTrackingStorage: Stores tracking data at ~/.jigaido/tracking/_.json Features: - Atomic writes using tempfile + rename for data safety - Automatic directory creation - Implements all methods from ports.py protocols --- adapters/__init__.py | 5 + adapters/storage/__init__.py | 5 + adapters/storage/json_file.py | 219 ++++++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 adapters/__init__.py create mode 100644 adapters/storage/__init__.py create mode 100644 adapters/storage/json_file.py diff --git a/adapters/__init__.py b/adapters/__init__.py new file mode 100644 index 0000000..18c3032 --- /dev/null +++ b/adapters/__init__.py @@ -0,0 +1,5 @@ +"""Storage adapters for JIGAIDO.""" + +from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage + +__all__ = ["JsonFileRoomStorage", "JsonFileTrackingStorage"] diff --git a/adapters/storage/__init__.py b/adapters/storage/__init__.py new file mode 100644 index 0000000..18c3032 --- /dev/null +++ b/adapters/storage/__init__.py @@ -0,0 +1,5 @@ +"""Storage adapters for JIGAIDO.""" + +from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage + +__all__ = ["JsonFileRoomStorage", "JsonFileTrackingStorage"] diff --git a/adapters/storage/json_file.py b/adapters/storage/json_file.py new file mode 100644 index 0000000..e6e86ef --- /dev/null +++ b/adapters/storage/json_file.py @@ -0,0 +1,219 @@ +"""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 +import tempfile +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 _atomic_write(self, path: Path, data: dict) -> None: + """Write data atomically using tempfile + rename.""" + with tempfile.NamedTemporaryFile( + mode="w", dir=self._data_dir, delete=False + ) as tmp: + json.dump(data, tmp, indent=2) + tmp_path = tmp.name + os.rename(tmp_path, path) + + 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 + ], + } + + self._atomic_write(self._get_file_path(room_data.room_id), data) + + 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 _atomic_write(self, path: Path, data: dict) -> None: + """Write data atomically using tempfile + rename.""" + with tempfile.NamedTemporaryFile( + mode="w", dir=self._tracking_dir, delete=False + ) as tmp: + json.dump(data, tmp, indent=2) + tmp_path = tmp.name + os.rename(tmp_path, path) + + 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.""" + 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 + ], + } + + self._atomic_write( + self._get_file_path(tracking_data.room_id, tracking_data.user_id), data + ) + + 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) -- 2.49.1 From d889d0e8abb786f6cfeabec4f090b2c398d76dfd Mon Sep 17 00:00:00 2001 From: shokollm <270575765+shokollm@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:38:42 +0000 Subject: [PATCH 2/2] fix(adapter): add unit tests + reorganize data directories - Add tests/test_json_file.py with unit tests for JsonFileRoomStorage and JsonFileTrackingStorage - Reorganize data directories per han's feedback: - Rooms: ~/.jigaido/data/room/.json (was ~/.jigaido/data/.json) - Tracking: ~/.jigaido/data/tracking/_.json (was ~/.jigaido/tracking/...) - Note: duplicate tracking is handled at TrackingService layer (returns False if already tracking), adapter allows duplicates by design --- adapters/storage/json_file.py | 12 +- tests/test_json_file.py | 269 ++++++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+), 6 deletions(-) create mode 100644 tests/test_json_file.py diff --git a/adapters/storage/json_file.py b/adapters/storage/json_file.py index e6e86ef..670112a 100644 --- a/adapters/storage/json_file.py +++ b/adapters/storage/json_file.py @@ -2,8 +2,8 @@ Implements RoomStorage and TrackingStorage ports using JSON file persistence. Data stored at: -- Rooms: ~/.jigaido/data/.json -- Tracking: ~/.jigaido/tracking/_.json +- Rooms: ~/.jigaido/data/room/.json +- Tracking: ~/.jigaido/data/tracking/_.json """ import json @@ -18,12 +18,12 @@ from core.ports import RoomStorage, TrackingStorage class JsonFileRoomStorage: """RoomStorage implementation using JSON files. - Stores room data at ~/.jigaido/data/.json + Stores room data at ~/.jigaido/data/room/.json """ def __init__(self, data_dir: Path | None = None): if data_dir is None: - data_dir = Path.home() / ".jigaido" / "data" + data_dir = Path.home() / ".jigaido" / "data" / "room" self._data_dir = data_dir self._data_dir.mkdir(parents=True, exist_ok=True) @@ -136,12 +136,12 @@ class JsonFileRoomStorage: class JsonFileTrackingStorage: """TrackingStorage implementation using JSON files. - Stores tracking data at ~/.jigaido/tracking/_.json + Stores tracking data at ~/.jigaido/data/tracking/_.json """ def __init__(self, tracking_dir: Path | None = None): if tracking_dir is None: - tracking_dir = Path.home() / ".jigaido" / "tracking" + tracking_dir = Path.home() / ".jigaido" / "data" / "tracking" self._tracking_dir = tracking_dir self._tracking_dir.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_json_file.py b/tests/test_json_file.py new file mode 100644 index 0000000..5a1bb72 --- /dev/null +++ b/tests/test_json_file.py @@ -0,0 +1,269 @@ +"""Tests for adapters/storage/json_file.py — JSON file storage adapter.""" + +import json +import os +import tempfile +from pathlib import Path + +import pytest + +from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage +from core.models import Bounty, RoomData, TrackingData, TrackedBounty + + +class TestJsonFileRoomStorage: + """Unit tests for JsonFileRoomStorage.""" + + def setup_method(self): + """Set up a temporary directory for each test.""" + self.temp_dir = tempfile.mkdtemp() + self.storage = JsonFileRoomStorage(data_dir=Path(self.temp_dir)) + + def teardown_method(self): + """Clean up temporary directory after each test.""" + import shutil + + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def _create_bounty( + self, + id=1, + text="Test bounty", + link=None, + due_date_ts=None, + created_at=0, + created_by_user_id=123, + ): + """Helper to create a Bounty.""" + return Bounty( + id=id, + text=text, + link=link, + due_date_ts=due_date_ts, + created_at=created_at, + created_by_user_id=created_by_user_id, + ) + + def test_load_returns_none_for_nonexistent_room(self): + """Test that load returns None for a room that doesn't exist.""" + result = self.storage.load(-1001) + assert result is None + + def test_save_and_load_room(self): + """Test that save and load work correctly.""" + room = RoomData(room_id=-1001, bounties=[], next_id=1) + self.storage.save(room) + + loaded = self.storage.load(-1001) + assert loaded is not None + assert loaded.room_id == -1001 + assert loaded.bounties == [] + assert loaded.next_id == 1 + + def test_add_bounty_creates_room(self): + """Test that add_bounty creates a room if it doesn't exist.""" + bounty = self._create_bounty() + self.storage.add_bounty(-1001, bounty) + + loaded = self.storage.load(-1001) + assert loaded is not None + assert len(loaded.bounties) == 1 + assert loaded.bounties[0].text == "Test bounty" + + def test_add_bounty_increments_next_id(self): + """Test that add_bounty properly handles next_id.""" + bounty1 = self._create_bounty(id=1) + bounty2 = self._create_bounty(id=2) + + self.storage.add_bounty(-1001, bounty1) + self.storage.add_bounty(-1001, bounty2) + + loaded = self.storage.load(-1001) + assert loaded.next_id == 3 # Should be max id + 1 + + def test_update_bounty(self): + """Test that update_bounty correctly updates a bounty.""" + bounty = self._create_bounty(id=1, text="Original") + self.storage.add_bounty(-1001, bounty) + + updated = self._create_bounty(id=1, text="Updated") + self.storage.update_bounty(-1001, updated) + + loaded = self.storage.load(-1001) + assert loaded.bounties[0].text == "Updated" + + def test_update_bounty_nonexistent_room(self): + """Test that update_bounty does nothing for nonexistent room.""" + updated = self._create_bounty(id=1, text="Updated") + self.storage.update_bounty(-1001, updated) # Should not raise + + assert self.storage.load(-1001) is None + + def test_delete_bounty(self): + """Test that delete_bounty removes a bounty.""" + bounty = self._create_bounty(id=1) + self.storage.add_bounty(-1001, bounty) + self.storage.delete_bounty(-1001, 1) + + loaded = self.storage.load(-1001) + assert len(loaded.bounties) == 0 + + def test_get_bounty_found(self): + """Test that get_bounty returns the bounty when found.""" + bounty = self._create_bounty(id=1) + self.storage.add_bounty(-1001, bounty) + + result = self.storage.get_bounty(-1001, 1) + assert result is not None + assert result.text == "Test bounty" + + def test_get_bounty_not_found(self): + """Test that get_bounty returns None when not found.""" + result = self.storage.get_bounty(-1001, 999) + assert result is None + + def test_file_path_format(self): + """Test that room data is stored in correct location.""" + room = RoomData(room_id=-1001, bounties=[], next_id=1) + self.storage.save(room) + + expected_path = Path(self.temp_dir) / "-1001.json" + assert expected_path.exists() + + def test_atomic_write(self): + """Test that data is written atomically.""" + room = RoomData(room_id=-1001, bounties=[], next_id=1) + self.storage.save(room) + + # Check that the file is valid JSON + file_path = Path(self.temp_dir) / "-1001.json" + with open(file_path) as f: + data = json.load(f) + assert data["room_id"] == -1001 + + +class TestJsonFileTrackingStorage: + """Unit tests for JsonFileTrackingStorage.""" + + def setup_method(self): + """Set up a temporary directory for each test.""" + self.temp_dir = tempfile.mkdtemp() + self.storage = JsonFileTrackingStorage(tracking_dir=Path(self.temp_dir)) + + def teardown_method(self): + """Clean up temporary directory after each test.""" + import shutil + + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def _create_tracked(self, bounty_id=1, created_at=0): + """Helper to create a TrackedBounty.""" + return TrackedBounty(bounty_id=bounty_id, created_at=created_at) + + def test_load_returns_none_for_nonexistent_tracking(self): + """Test that load returns None when no tracking exists.""" + result = self.storage.load(-1001, 123456) + assert result is None + + def test_save_and_load_tracking(self): + """Test that save and load work correctly.""" + tracking = TrackingData(room_id=-1001, user_id=123456, tracked=[]) + self.storage.save(tracking) + + loaded = self.storage.load(-1001, 123456) + assert loaded is not None + assert loaded.room_id == -1001 + assert loaded.user_id == 123456 + + def test_track_bounty(self): + """Test that track_bounty adds a bounty to tracking.""" + tracked = self._create_tracked(bounty_id=5) + self.storage.track_bounty(-1001, 123456, tracked) + + loaded = self.storage.load(-1001, 123456) + assert loaded is not None + assert len(loaded.tracked) == 1 + assert loaded.tracked[0].bounty_id == 5 + + def test_untrack_bounty(self): + """Test that untrack_bounty removes a bounty from tracking.""" + tracked = self._create_tracked(bounty_id=5) + self.storage.track_bounty(-1001, 123456, tracked) + self.storage.untrack_bounty(-1001, 123456, 5) + + loaded = self.storage.load(-1001, 123456) + assert loaded is not None + assert len(loaded.tracked) == 0 + + def test_untrack_bounty_nonexistent(self): + """Test that untrack_bounty handles nonexistent tracking gracefully.""" + self.storage.untrack_bounty(-1001, 123456, 999) # Should not raise + + def test_file_path_format(self): + """Test that tracking data is stored in correct location.""" + tracked = self._create_tracked(bounty_id=5) + self.storage.track_bounty(-1001, 123456, tracked) + + expected_path = Path(self.temp_dir) / "-1001_123456.json" + assert expected_path.exists() + + def test_multiple_tracked_bounties(self): + """Test tracking multiple bounties.""" + self.storage.track_bounty(-1001, 123456, self._create_tracked(bounty_id=1)) + self.storage.track_bounty(-1001, 123456, self._create_tracked(bounty_id=2)) + self.storage.track_bounty(-1001, 123456, self._create_tracked(bounty_id=3)) + + loaded = self.storage.load(-1001, 123456) + assert len(loaded.tracked) == 3 + + def test_different_users_independent_tracking(self): + """Test that different users have independent tracking.""" + self.storage.track_bounty(-1001, 111, self._create_tracked(bounty_id=1)) + self.storage.track_bounty(-1001, 222, self._create_tracked(bounty_id=1)) + + loaded_111 = self.storage.load(-1001, 111) + loaded_222 = self.storage.load(-1001, 222) + + assert len(loaded_111.tracked) == 1 + assert len(loaded_222.tracked) == 1 + + +class TestDuplicateTrackingBehavior: + """Test that duplicate tracking is handled correctly. + + Note: The deduplication logic is in the TrackingService layer, + not in the adapter. This test verifies the adapter behavior + when the same bounty is tracked multiple times (which would only + happen if the service layer has a bug). + """ + + def setup_method(self): + """Set up a temporary directory for each test.""" + self.temp_dir = tempfile.mkdtemp() + self.storage = JsonFileTrackingStorage(tracking_dir=Path(self.temp_dir)) + + def teardown_method(self): + """Clean up temporary directory after each test.""" + import shutil + + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_adapter_allows_duplicate_tracking(self): + """The adapter does NOT prevent duplicate tracking. + + This is by design - deduplication should be handled by + TrackingService.track_bounty(), not the storage adapter. + """ + # Add same bounty twice via adapter (bypassing service) + self.storage.track_bounty( + -1001, 123456, TrackedBounty(bounty_id=5, created_at=0) + ) + self.storage.track_bounty( + -1001, 123456, TrackedBounty(bounty_id=5, created_at=0) + ) + + loaded = self.storage.load(-1001, 123456) + # Adapter allows duplicates - service should prevent them + assert len(loaded.tracked) == 2 + assert loaded.tracked[0].bounty_id == 5 + assert loaded.tracked[1].bounty_id == 5 -- 2.49.1