diff --git a/tests/test_services.py b/tests/test_services.py index 50b6885..dd22884 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -404,3 +404,283 @@ class TestTrackingService: assert len(tracked_room2) == 1 assert tracked_room1[0].text == "Room 1" assert tracked_room2[0].text == "Room 2" + + +class TestCategoryService: + """Unit tests for category management.""" + + def setup_method(self): + """Set up fresh storage and service for each test.""" + self.storage = MockRoomStorage() + self.service = BountyService(self.storage) + self.admin_username = "admin" + self._make_admin(-1001, self.admin_username) + + def _make_admin(self, room_id: int, username: str): + """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_usernames=[] + ) + if username not in (room_data.admin_usernames or []): + room_data.admin_usernames = room_data.admin_usernames or [] + room_data.admin_usernames.append(username) + self.storage.save(room_data) + + def _add_bounty(self, text="Test bounty"): + """Helper to add a bounty for category tests.""" + return self.service.add_bounty( + room_id=-1001, user_id=123, username=self.admin_username, text=text + ) + + # Category Management Tests + + def test_add_category_requires_admin(self): + """Test that add_category raises PermissionError for non-admin.""" + with pytest.raises(PermissionError, match="Only admins can add categories"): + self.service.add_category(-1001, "bug", "Bug Report", "nonadmin") + + def test_add_category_duplicate_slug_fails(self): + """Test that adding a duplicate category slug raises ValueError.""" + self.service.add_category(-1001, "bug", "Bug Report", self.admin_username) + with pytest.raises(ValueError, match="already exists"): + self.service.add_category(-1001, "bug", "Bug Report 2", self.admin_username) + + def test_add_category_invalid_slug_fails_uppercase(self): + """Test that uppercase slug raises ValueError.""" + with pytest.raises(ValueError, match="lowercase alphabetic only"): + self.service.add_category(-1001, "Bug", "Bug Report", self.admin_username) + + def test_add_category_invalid_slug_fails_with_numbers(self): + """Test that slug with numbers raises ValueError.""" + with pytest.raises(ValueError, match="lowercase alphabetic only"): + self.service.add_category(-1001, "bug1", "Bug 1", self.admin_username) + + def test_add_category_invalid_slug_fails_with_symbols(self): + """Test that slug with symbols raises ValueError.""" + with pytest.raises(ValueError, match="lowercase alphabetic only"): + self.service.add_category(-1001, "bug-fix", "Bug Fix", self.admin_username) + + def test_add_category_invalid_slug_fails_empty(self): + """Test that empty slug raises ValueError.""" + with pytest.raises(ValueError, match="lowercase alphabetic only"): + self.service.add_category(-1001, "", "Empty", self.admin_username) + + def test_add_category_valid(self): + """Test that valid category can be created.""" + category = self.service.add_category( + -1001, "bug", "Bug Report", self.admin_username + ) + assert category.id == "bug" + assert category.name == "Bug Report" + assert category.created_at > 0 + assert category.deleted_at is None + + def test_delete_category_soft_deletes(self): + """Test that delete_category performs soft delete.""" + self.service.add_category(-1001, "bug", "Bug Report", self.admin_username) + result = self.service.delete_category(-1001, "bug", self.admin_username) + assert result is True + + # Category should not be found via get_category + assert self.service.get_category(-1001, "bug") is None + + # But should still be in raw room data (soft delete) + room_data = self.storage.load(-1001) + for cat in room_data.categories: + if cat.id == "bug": + assert cat.deleted_at is not None + return + assert False, "Category should still exist in storage" + + def test_deleted_category_not_listed(self): + """Test that list_categories excludes soft-deleted categories.""" + self.service.add_category(-1001, "bug", "Bug Report", self.admin_username) + self.service.delete_category(-1001, "bug", self.admin_username) + + categories = self.service.list_categories(-1001) + assert len(categories) == 0 + assert not any(cat.id == "bug" for cat in categories) + + def test_list_categories_empty(self): + """Test that list_categories returns empty list for room with no categories.""" + categories = self.service.list_categories(-1001) + assert categories == [] + + def test_list_categories_returns_active(self): + """Test that list_categories returns only active categories.""" + self.service.add_category(-1001, "bug", "Bug Report", self.admin_username) + self.service.add_category(-1001, "feature", "Feature Request", self.admin_username) + self.service.add_category(-1001, "docs", "Documentation", self.admin_username) + self.service.delete_category(-1001, "bug", self.admin_username) + + categories = self.service.list_categories(-1001) + assert len(categories) == 2 + category_ids = [c.id for c in categories] + assert "feature" in category_ids + assert "docs" in category_ids + assert "bug" not in category_ids + + def test_get_category_not_found(self): + """Test that get_category returns None for non-existent category.""" + category = self.service.get_category(-1001, "nonexistent") + assert category is None + + def test_get_category_deleted_returns_none(self): + """Test that get_category returns None for soft-deleted category.""" + self.service.add_category(-1001, "bug", "Bug Report", self.admin_username) + self.service.delete_category(-1001, "bug", self.admin_username) + + category = self.service.get_category(-1001, "bug") + assert category is None + + def test_add_category_requires_admin_non_existent_room(self): + """Test that add_category works for non-existent room (creates it).""" + self._make_admin(-9999, self.admin_username) + category = self.service.add_category( + -9999, "bug", "Bug Report", self.admin_username + ) + assert category.id == "bug" + + # Category-to-Bounty Association Tests + + def test_add_category_to_bounty(self): + """Test adding category to a bounty.""" + self.service.add_category(-1001, "bug", "Bug Report", self.admin_username) + bounty = self._add_bounty() + + result = self.service.add_category_to_bounty( + -1001, bounty.id, "bug", self.admin_username + ) + assert result is True + + updated_bounty = self.service.get_bounty(-1001, bounty.id) + assert "bug" in updated_bounty.category_ids + + def test_add_duplicate_category_to_bounty_noop(self): + """Test that adding duplicate category returns False.""" + self.service.add_category(-1001, "bug", "Bug Report", self.admin_username) + bounty = self._add_bounty() + + self.service.add_category_to_bounty(-1001, bounty.id, "bug", self.admin_username) + result = self.service.add_category_to_bounty( + -1001, bounty.id, "bug", self.admin_username + ) + assert result is False + + def test_add_category_to_bounty_invalid_bounty(self): + """Test that adding category to non-existent bounty raises ValueError.""" + self.service.add_category(-1001, "bug", "Bug Report", self.admin_username) + + with pytest.raises(ValueError, match="Bounty not found"): + self.service.add_category_to_bounty(-1001, 999, "bug", self.admin_username) + + def test_add_category_to_bounty_invalid_category(self): + """Test that adding non-existent category raises ValueError.""" + bounty = self._add_bounty() + + with pytest.raises(ValueError, match="not found"): + self.service.add_category_to_bounty( + -1001, bounty.id, "nonexistent", self.admin_username + ) + + def test_remove_category_from_bounty(self): + """Test removing category from a bounty.""" + self.service.add_category(-1001, "bug", "Bug Report", self.admin_username) + bounty = self._add_bounty() + self.service.add_category_to_bounty(-1001, bounty.id, "bug", self.admin_username) + + result = self.service.remove_category_from_bounty( + -1001, bounty.id, "bug", self.admin_username + ) + assert result is True + + updated_bounty = self.service.get_bounty(-1001, bounty.id) + assert "bug" not in updated_bounty.category_ids + + def test_remove_category_not_on_bounty_returns_false(self): + """Test that removing category not on bounty returns False.""" + self.service.add_category(-1001, "bug", "Bug Report", self.admin_username) + bounty = self._add_bounty() + + result = self.service.remove_category_from_bounty( + -1001, bounty.id, "bug", self.admin_username + ) + assert result is False + + def test_update_bounty_categories_replace_all(self): + """Test that update_bounty_categories replaces all categories.""" + self.service.add_category(-1001, "bug", "Bug Report", self.admin_username) + self.service.add_category(-1001, "feature", "Feature Request", self.admin_username) + self.service.add_category(-1001, "docs", "Documentation", self.admin_username) + bounty = self._add_bounty() + + # Add initial categories + self.service.add_category_to_bounty(-1001, bounty.id, "bug", self.admin_username) + + # Replace with different categories + self.service.update_bounty_categories( + -1001, bounty.id, ["feature", "docs"], self.admin_username + ) + + updated_bounty = self.service.get_bounty(-1001, bounty.id) + assert updated_bounty.category_ids == ["feature", "docs"] + + def test_update_bounty_categories_clear_all(self): + """Test that update_bounty_categories can clear all categories.""" + self.service.add_category(-1001, "bug", "Bug Report", self.admin_username) + bounty = self._add_bounty() + self.service.add_category_to_bounty(-1001, bounty.id, "bug", self.admin_username) + + self.service.update_bounty_categories(-1001, bounty.id, [], self.admin_username) + + updated_bounty = self.service.get_bounty(-1001, bounty.id) + assert updated_bounty.category_ids == [] + + def test_update_bounty_categories_validates(self): + """Test that update_bounty_categories validates all slugs.""" + self.service.add_category(-1001, "bug", "Bug Report", self.admin_username) + bounty = self._add_bounty() + + with pytest.raises(ValueError, match="not found"): + self.service.update_bounty_categories( + -1001, bounty.id, ["bug", "nonexistent"], self.admin_username + ) + + def test_add_category_to_bounty_requires_admin(self): + """Test that adding category to bounty requires admin.""" + self.service.add_category(-1001, "bug", "Bug Report", self.admin_username) + bounty = self._add_bounty() + + with pytest.raises(PermissionError, match="Only admins"): + self.service.add_category_to_bounty( + -1001, bounty.id, "bug", "nonadmin" + ) + + def test_remove_category_from_bounty_requires_admin(self): + """Test that removing category from bounty requires admin.""" + self.service.add_category(-1001, "bug", "Bug Report", self.admin_username) + bounty = self._add_bounty() + self.service.add_category_to_bounty(-1001, bounty.id, "bug", self.admin_username) + + with pytest.raises(PermissionError, match="Only admins"): + self.service.remove_category_from_bounty( + -1001, bounty.id, "bug", "nonadmin" + ) + + def test_update_bounty_categories_requires_admin(self): + """Test that updating bounty categories requires admin.""" + bounty = self._add_bounty() + + with pytest.raises(PermissionError, match="Only admins"): + self.service.update_bounty_categories( + -1001, bounty.id, ["bug"], "nonadmin" + ) + + def test_delete_category_requires_admin(self): + """Test that deleting category requires admin.""" + self.service.add_category(-1001, "bug", "Bug Report", self.admin_username) + + with pytest.raises(PermissionError, match="Only admins"): + self.service.delete_category(-1001, "bug", "nonadmin")