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:
shokollm
2026-04-03 11:38:42 +00:00
parent e79fbaddc5
commit d889d0e8ab
2 changed files with 275 additions and 6 deletions

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