"""Tests for core/services.py — business logic services.""" import pytest from unittest.mock import MagicMock from core.models import Bounty, RoomData, TrackingData, TrackedBounty from core.services import BountyService, TrackingService from tests.test_ports import MockRoomStorage, MockTrackingStorage class TestBountyService: """Unit tests for BountyService.""" def setup_method(self): """Set up fresh storage and service for each test.""" self.storage = MockRoomStorage() self.service = BountyService(self.storage) self.admin_user_id = 123 self._make_admin(-1001, self.admin_user_id) def _make_admin(self, room_id: int, user_id: int): """Helper to set up a room with an admin user.""" room_data = self.storage.load(room_id) if room_data is None: room_data = RoomData( room_id=room_id, bounties=[], next_id=0, admin_user_ids=[] ) if user_id not in (room_data.admin_user_ids or []): room_data.admin_user_ids = room_data.admin_user_ids or [] room_data.admin_user_ids.append(user_id) self.storage.save(room_data) def test_add_bounty_creates_room_if_not_exists(self): """Test that add_bounty creates a new room if it doesn't exist.""" bounty = self.service.add_bounty( room_id=-1001, user_id=self.admin_user_id, text="Fix bug", link="https://github.com/issue/1", ) assert bounty.id == 1 assert bounty.text == "Fix bug" assert bounty.created_by_user_id == self.admin_user_id room = self.storage.load(-1001) assert room is not None assert len(room.bounties) == 1 def test_add_bounty_increments_id(self): """Test that add_bounty increments bounty ID for each new bounty.""" b1 = self.service.add_bounty( room_id=-1001, user_id=self.admin_user_id, text="First" ) b2 = self.service.add_bounty( room_id=-1001, user_id=self.admin_user_id, text="Second" ) b3 = self.service.add_bounty( room_id=-1001, user_id=self.admin_user_id, text="Third" ) assert b1.id == 1 assert b2.id == 2 assert b3.id == 3 def test_add_bounty_requires_admin(self): """Test that add_bounty raises PermissionError when non-admin tries to add.""" with pytest.raises(PermissionError, match="Only admins can add bounties"): self.service.add_bounty(room_id=-1001, user_id=999, text="Not admin") def test_list_bounties_empty_room(self): """Test list_bounties returns empty list for non-existent room.""" bounties = self.service.list_bounties(-1001) assert bounties == [] def test_list_bounties_returns_all_bounties(self): """Test list_bounties returns all bounties in a room.""" self.service.add_bounty(room_id=-1001, user_id=self.admin_user_id, text="First") self.service.add_bounty( room_id=-1001, user_id=self.admin_user_id, text="Second" ) # Add bounty to different room to verify isolation self._make_admin(-999, self.admin_user_id) self.service.add_bounty( room_id=-999, user_id=self.admin_user_id, text="Other room" ) bounties = self.service.list_bounties(-1001) assert len(bounties) == 2 assert all(b.text in ["First", "Second"] for b in bounties) def test_get_bounty_found(self): """Test get_bounty returns bounty when it exists.""" created = self.service.add_bounty( room_id=-1001, user_id=self.admin_user_id, text="Test" ) found = self.service.get_bounty(-1001, created.id) assert found is not None assert found.text == "Test" def test_get_bounty_not_found(self): """Test get_bounty returns None when bounty doesn't exist.""" found = self.service.get_bounty(-1001, 999) assert found is None def test_get_bounty_wrong_room(self): """Test get_bounty returns None when bounty is in different room.""" self.service.add_bounty(room_id=-1001, user_id=self.admin_user_id, text="Test") found = self.service.get_bounty(-999, 1) # room -999 doesn't have bounty 1 assert found is None def test_update_bounty_success(self): """Test update_bounty succeeds when admin updates their bounty.""" bounty = self.service.add_bounty( room_id=-1001, user_id=self.admin_user_id, text="Original" ) result = self.service.update_bounty( room_id=-1001, bounty_id=bounty.id, user_id=self.admin_user_id, text="Updated", ) assert result is True updated = self.service.get_bounty(-1001, bounty.id) assert updated.text == "Updated" def test_update_bounty_not_admin_raises_permission_error(self): """Test update_bounty raises PermissionError when non-admin tries to update.""" bounty = self.service.add_bounty( room_id=-1001, user_id=self.admin_user_id, text="Original" ) with pytest.raises(PermissionError, match="Only admins can edit bounties"): self.service.update_bounty( room_id=-1001, bounty_id=bounty.id, user_id=999, # different user, not admin text="Hacked", ) def test_update_bounty_not_found(self): """Test update_bounty returns False when bounty doesn't exist.""" result = self.service.update_bounty( room_id=-1001, bounty_id=999, user_id=self.admin_user_id, text="Updated", ) assert result is False def test_update_bounty_partial_update(self): """Test update_bounty only updates provided fields.""" bounty = self.service.add_bounty( room_id=-1001, user_id=self.admin_user_id, text="Original", link="https://original.link", ) self.service.update_bounty( room_id=-1001, bounty_id=bounty.id, user_id=self.admin_user_id, text="Updated only text", ) updated = self.service.get_bounty(-1001, bounty.id) assert updated.text == "Updated only text" assert updated.link == "https://original.link" # link unchanged def test_update_bounty_clear_link(self): """Test update_bounty can clear link.""" bounty = self.service.add_bounty( room_id=-1001, user_id=self.admin_user_id, text="Test", link="https://original.link", ) self.service.update_bounty( room_id=-1001, bounty_id=bounty.id, user_id=self.admin_user_id, clear_link=True, ) updated = self.service.get_bounty(-1001, bounty.id) assert updated.link is None def test_delete_bounty_success(self): """Test delete_bounty soft deletes when admin deletes their bounty.""" bounty = self.service.add_bounty( room_id=-1001, user_id=self.admin_user_id, text="To delete" ) result = self.service.delete_bounty(-1001, bounty.id, self.admin_user_id) assert result is True # Soft delete - bounty should not be found via get_bounty assert self.service.get_bounty(-1001, bounty.id) is None # But still exists in list_deleted_bounties deleted = self.service.list_deleted_bounties(-1001) assert len(deleted) == 1 assert deleted[0].id == bounty.id def test_delete_bounty_not_admin_raises_permission_error(self): """Test delete_bounty raises PermissionError when non-admin tries to delete.""" bounty = self.service.add_bounty( room_id=-1001, user_id=self.admin_user_id, text="To delete" ) with pytest.raises(PermissionError, match="Only admins can delete bounties"): self.service.delete_bounty( -1001, bounty.id, 999 ) # different user, not admin def test_delete_bounty_not_found(self): """Test delete_bounty returns False when bounty doesn't exist.""" result = self.service.delete_bounty(-1001, 999, self.admin_user_id) assert result is False class TestTrackingService: """Unit tests for TrackingService.""" def setup_method(self): """Set up fresh storage and service for each test.""" self.room_storage = MockRoomStorage() self.tracking_storage = MockTrackingStorage() self.service = TrackingService(self.tracking_storage, self.room_storage) self.admin_user_id = 123 self._make_admin(-1001, self.admin_user_id) def _make_admin(self, room_id: int, user_id: int): """Helper to set up a room with an admin user.""" room_data = self.room_storage.load(room_id) if room_data is None: room_data = RoomData( room_id=room_id, bounties=[], next_id=0, admin_user_ids=[] ) if user_id not in (room_data.admin_user_ids or []): room_data.admin_user_ids = room_data.admin_user_ids or [] room_data.admin_user_ids.append(user_id) self.room_storage.save(room_data) def _add_bounty(self, room_id=-1001, user_id=123, text="Test bounty"): """Helper to add a bounty for tracking tests.""" if self.room_storage.load(room_id) is None or user_id not in ( self.room_storage.load(room_id).admin_user_ids or [] ): self._make_admin(room_id, user_id) bounty_service = BountyService(self.room_storage) return bounty_service.add_bounty(room_id=room_id, user_id=user_id, text=text) def test_track_bounty_success(self): """Test track_bounty successfully tracks a bounty.""" bounty = self._add_bounty() result = self.service.track_bounty(-1001, 123456, bounty.id) assert result is True tracked = self.service.get_tracked_bounties(-1001, 123456) assert len(tracked) == 1 assert tracked[0].id == bounty.id def test_track_bounty_returns_false_if_already_tracking(self): """Test track_bounty returns False if bounty is already tracked.""" bounty = self._add_bounty() self.service.track_bounty(-1001, 123456, bounty.id) result = self.service.track_bounty(-1001, 123456, bounty.id) assert result is False def test_track_bounty_raises_value_error_if_bounty_not_found(self): """Test track_bounty raises ValueError if bounty doesn't exist.""" with pytest.raises(ValueError, match="Bounty not found"): self.service.track_bounty(-1001, 123456, 999) def test_track_bounty_different_users_can_track_same_bounty(self): """Test that different users can track the same bounty.""" bounty = self._add_bounty() self.service.track_bounty(-1001, 111, bounty.id) self.service.track_bounty(-1001, 222, bounty.id) tracked_by_111 = self.service.get_tracked_bounties(-1001, 111) tracked_by_222 = self.service.get_tracked_bounties(-1001, 222) assert len(tracked_by_111) == 1 assert len(tracked_by_222) == 1 assert tracked_by_111[0].id == bounty.id assert tracked_by_222[0].id == bounty.id def test_untrack_bounty_success(self): """Test untrack_bounty successfully untracks a bounty.""" bounty = self._add_bounty() self.service.track_bounty(-1001, 123456, bounty.id) result = self.service.untrack_bounty(-1001, 123456, bounty.id) assert result is True tracked = self.service.get_tracked_bounties(-1001, 123456) assert len(tracked) == 0 def test_untrack_bounty_returns_false_if_not_tracking(self): """Test untrack_bounty returns False if bounty was not being tracked.""" bounty = self._add_bounty() result = self.service.untrack_bounty(-1001, 123456, bounty.id) assert result is False def test_untrack_bounty_returns_false_if_tracking_different_bounty(self): """Test untrack_bounty returns False if tracking different bounty.""" b1 = self._add_bounty(text="Bounty 1") b2 = self._add_bounty(text="Bounty 2") self.service.track_bounty(-1001, 123456, b1.id) result = self.service.untrack_bounty(-1001, 123456, b2.id) assert result is False # b1 should still be tracked tracked = self.service.get_tracked_bounties(-1001, 123456) assert len(tracked) == 1 assert tracked[0].id == b1.id def test_get_tracked_bounties_empty(self): """Test get_tracked_bounties returns empty list when nothing tracked.""" tracked = self.service.get_tracked_bounties(-1001, 123456) assert tracked == [] def test_get_tracked_bounties_returns_tracked_bounties(self): """Test get_tracked_bounties returns all bounties tracked by user.""" b1 = self._add_bounty(text="First") b2 = self._add_bounty(text="Second") self.service.track_bounty(-1001, 123456, b1.id) self.service.track_bounty(-1001, 123456, b2.id) tracked = self.service.get_tracked_bounties(-1001, 123456) assert len(tracked) == 2 assert all(t.text in ["First", "Second"] for t in tracked) def test_get_tracked_bounties_ignores_deleted_bounties(self): """Test get_tracked_bounties ignores bounties that were deleted.""" bounty_service = BountyService(self.room_storage) bounty = bounty_service.add_bounty(room_id=-1001, user_id=123, text="To delete") self.service.track_bounty(-1001, 123456, bounty.id) # Delete the bounty bounty_service.delete_bounty(-1001, bounty.id, 123) tracked = self.service.get_tracked_bounties(-1001, 123456) assert len(tracked) == 0 # deleted bounty not returned def test_get_tracked_bounties_different_rooms_independent(self): """Test that tracking in different rooms is independent.""" b1 = self._add_bounty(room_id=-1001, text="Room 1") b2 = self._add_bounty(room_id=-999, text="Room 2") self.service.track_bounty(-1001, 123456, b1.id) self.service.track_bounty(-999, 123456, b2.id) tracked_room1 = self.service.get_tracked_bounties(-1001, 123456) tracked_room2 = self.service.get_tracked_bounties(-999, 123456) assert len(tracked_room1) == 1 assert len(tracked_room2) == 1 assert tracked_room1[0].text == "Room 1" assert tracked_room2[0].text == "Room 2"