feat(adapter): implement JSON file storage adapter for issue #9 #27

Merged
shoko merged 2 commits from fix/issue-9 into main 2026-04-03 14:24:06 +02:00
4 changed files with 498 additions and 0 deletions

5
adapters/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Storage adapters for JIGAIDO."""
from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage
__all__ = ["JsonFileRoomStorage", "JsonFileTrackingStorage"]

View File

@@ -0,0 +1,5 @@
"""Storage adapters for JIGAIDO."""
from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage
__all__ = ["JsonFileRoomStorage", "JsonFileTrackingStorage"]

View File

@@ -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/room/<room_id>.json
- Tracking: ~/.jigaido/data/tracking/<room_id>_<user_id>.json
han marked this conversation as resolved Outdated
Outdated
Review

can we store both of the data under data folder instead of separating them?

basically

rooms: ~/.jigaido/data/room/<room_id>.json
tracking: ~/.jigaido/data/tracking/<room_id>_<user_id>.json

this way we have both under rooms, and its easier to migrate as well

can we store both of the data under data folder instead of separating them? basically rooms: ~/.jigaido/data/room/<room_id>.json tracking: ~/.jigaido/data/tracking/<room_id>_<user_id>.json this way we have both under rooms, and its easier to migrate as well
"""
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/room/<room_id>.json
"""
def __init__(self, data_dir: Path | None = None):
if data_dir is None:
data_dir = Path.home() / ".jigaido" / "data" / "room"
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/data/tracking/<room_id>_<user_id>.json
"""
def __init__(self, tracking_dir: Path | None = None):
if tracking_dir is None:
tracking_dir = Path.home() / ".jigaido" / "data" / "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)

269
tests/test_json_file.py Normal file
View File

@@ -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