Merge feat/issue-6-storage-ports: JSON file storage adapter for issue #9
This commit is contained in:
0
adapters/__init__.py
Normal file
0
adapters/__init__.py
Normal file
0
adapters/storage/__init__.py
Normal file
0
adapters/storage/__init__.py
Normal file
202
adapters/storage/json_file.py
Normal file
202
adapters/storage/json_file.py
Normal file
@@ -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/<room_id>.json
|
||||||
|
- Tracking: ~/.jigaido/tracking/<room_id>_<user_id>.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/<room_id>.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/<room_id>_<user_id>.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)
|
||||||
269
tests/test_json_file_adapter.py
Normal file
269
tests/test_json_file_adapter.py
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user