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/<room_id>.json (was ~/.jigaido/data/<room_id>.json) - Tracking: ~/.jigaido/data/tracking/<room_id>_<user_id>.json (was ~/.jigaido/tracking/...) - Note: duplicate tracking is handled at TrackingService layer (returns False if already tracking), adapter allows duplicates by design
This commit is contained in:
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Implements RoomStorage and TrackingStorage ports using JSON file persistence.
|
Implements RoomStorage and TrackingStorage ports using JSON file persistence.
|
||||||
Data stored at:
|
Data stored at:
|
||||||
- Rooms: ~/.jigaido/data/<room_id>.json
|
- Rooms: ~/.jigaido/data/room/<room_id>.json
|
||||||
- Tracking: ~/.jigaido/tracking/<room_id>_<user_id>.json
|
- Tracking: ~/.jigaido/data/tracking/<room_id>_<user_id>.json
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -18,12 +18,12 @@ from core.ports import RoomStorage, TrackingStorage
|
|||||||
class JsonFileRoomStorage:
|
class JsonFileRoomStorage:
|
||||||
"""RoomStorage implementation using JSON files.
|
"""RoomStorage implementation using JSON files.
|
||||||
|
|
||||||
Stores room data at ~/.jigaido/data/<room_id>.json
|
Stores room data at ~/.jigaido/data/room/<room_id>.json
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, data_dir: Path | None = None):
|
def __init__(self, data_dir: Path | None = None):
|
||||||
if data_dir is 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 = data_dir
|
||||||
self._data_dir.mkdir(parents=True, exist_ok=True)
|
self._data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -136,12 +136,12 @@ class JsonFileRoomStorage:
|
|||||||
class JsonFileTrackingStorage:
|
class JsonFileTrackingStorage:
|
||||||
"""TrackingStorage implementation using JSON files.
|
"""TrackingStorage implementation using JSON files.
|
||||||
|
|
||||||
Stores tracking data at ~/.jigaido/tracking/<room_id>_<user_id>.json
|
Stores tracking data at ~/.jigaido/data/tracking/<room_id>_<user_id>.json
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, tracking_dir: Path | None = None):
|
def __init__(self, tracking_dir: Path | None = None):
|
||||||
if tracking_dir is 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 = tracking_dir
|
||||||
self._tracking_dir.mkdir(parents=True, exist_ok=True)
|
self._tracking_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
269
tests/test_json_file.py
Normal file
269
tests/test_json_file.py
Normal 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
|
||||||
Reference in New Issue
Block a user