diff --git a/backend/core/src/core/actions/chat/base.py b/backend/core/src/core/actions/chat/base.py index 2af1b8a2..e9d68234 100644 --- a/backend/core/src/core/actions/chat/base.py +++ b/backend/core/src/core/actions/chat/base.py @@ -107,19 +107,19 @@ def remove_group_if_empty(self, group_id: int) -> None: to be removed. """ try: - group_removed = self.telegram_chat_rule_group_service.delete( - chat_id=self.chat.id, group_id=group_id - ) - if group_removed: - logger.info( - f"Deleted rule group {group_id!r} for chat {self.chat.id!r} as it had no rules left." - ) - else: - logger.warning( - f"Group {group_id!r} was not deleted as it was not found." + with self.db_session.begin_nested(): + group_removed = self.telegram_chat_rule_group_service.delete( + chat_id=self.chat.id, group_id=group_id ) + if group_removed: + logger.info( + f"Deleted rule group {group_id!r} for chat {self.chat.id!r} as it had no rules left." + ) + else: + logger.warning( + f"Group {group_id!r} was not deleted as it was not found." + ) except IntegrityError: - self.db_session.rollback() logger.debug(f"Group {group_id!r} is not empty") return None diff --git a/backend/core/src/core/actions/chat/rule/blockchain.py b/backend/core/src/core/actions/chat/rule/blockchain.py index ec78e035..cd6f4504 100644 --- a/backend/core/src/core/actions/chat/rule/blockchain.py +++ b/backend/core/src/core/actions/chat/rule/blockchain.py @@ -619,7 +619,7 @@ def update( self.refresh_chat_floor_price() return ChatEligibilityRuleDTO.from_toncoin_rule(updated_rule) - def delete(self, rule_id: int) -> None: + async def delete(self, rule_id: int) -> None: try: group_id = self.telegram_chat_toncoin_service.get( rule_id, chat_id=self.chat.id @@ -631,6 +631,5 @@ def delete(self, rule_id: int) -> None: ) self.telegram_chat_toncoin_service.delete(rule_id, chat_id=self.chat.id) logger.info(f"Deleted chat TON rule {rule_id!r}") - self.refresh_chat_floor_price() self.remove_group_if_empty(group_id=group_id) diff --git a/backend/tests/factories/nft.py b/backend/tests/factories/nft.py new file mode 100644 index 00000000..82004c4b --- /dev/null +++ b/backend/tests/factories/nft.py @@ -0,0 +1,14 @@ +import factory +from core.models.blockchain import NFTCollection +from tests.factories.base import BaseSQLAlchemyModelFactory + + +class NFTCollectionFactory(BaseSQLAlchemyModelFactory): + class Meta: + model = NFTCollection + sqlalchemy_session_persistence = "flush" + + address = factory.Faker("pystr", min_chars=65, max_chars=65, prefix="0:") + name = factory.Faker("word") + description = factory.Faker("text") + is_enabled = True diff --git a/backend/tests/factories/rule/base.py b/backend/tests/factories/rule/base.py index 81a784e3..6f9f2ccd 100644 --- a/backend/tests/factories/rule/base.py +++ b/backend/tests/factories/rule/base.py @@ -8,7 +8,6 @@ class Meta: abstract = True sqlalchemy_session_persistence = "flush" - id = factory.Sequence(lambda n: n + 1) group_id = factory.SelfAttribute("group.id") group = factory.SubFactory( "tests.factories.rule.group.TelegramChatRuleGroupFactory" diff --git a/backend/tests/factories/rule/blockchain.py b/backend/tests/factories/rule/blockchain.py new file mode 100644 index 00000000..52d68510 --- /dev/null +++ b/backend/tests/factories/rule/blockchain.py @@ -0,0 +1,41 @@ +import factory + +from core.models.rule import ( + TelegramChatJetton, + TelegramChatNFTCollection, + TelegramChatToncoin, +) +from tests.factories.jetton import JettonFactory +from tests.factories.nft import NFTCollectionFactory +from tests.factories.rule.base import ( + TelegramChatRuleBaseFactory, + TelegramChatThresholdRuleMixin, +) + + +class TelegramChatJettonRuleFactory( + TelegramChatRuleBaseFactory, TelegramChatThresholdRuleMixin +): + class Meta: + model = TelegramChatJetton + + address = factory.SelfAttribute("jetton.address") + jetton = factory.SubFactory(JettonFactory) + + +class TelegramChatNFTCollectionRuleFactory( + TelegramChatRuleBaseFactory, TelegramChatThresholdRuleMixin +): + class Meta: + model = TelegramChatNFTCollection + + address = factory.SelfAttribute("nft_collection.address") + nft_collection = factory.SubFactory(NFTCollectionFactory) + asset = None + + +class TelegramChatToncoinRuleFactory( + TelegramChatRuleBaseFactory, TelegramChatThresholdRuleMixin +): + class Meta: + model = TelegramChatToncoin diff --git a/backend/tests/unit/core/actions/chat/rule/test_blockchain_delete.py b/backend/tests/unit/core/actions/chat/rule/test_blockchain_delete.py new file mode 100644 index 00000000..aae80a65 --- /dev/null +++ b/backend/tests/unit/core/actions/chat/rule/test_blockchain_delete.py @@ -0,0 +1,124 @@ +import pytest +from sqlalchemy.orm import Session + +from core.actions.chat.base import ManagedChatBaseAction +from core.actions.chat.rule.blockchain import ( + TelegramChatJettonAction, + TelegramChatNFTCollectionAction, + TelegramChatToncoinAction, +) +from core.models.rule import ( + TelegramChatJetton, + TelegramChatNFTCollection, + TelegramChatToncoin, + TelegramChatRuleGroup, + TelegramChatRuleBase, +) +from tests.factories.rule.base import TelegramChatRuleBaseFactory +from tests.factories.rule.group import TelegramChatRuleGroupFactory +from tests.factories.rule.blockchain import ( + TelegramChatJettonRuleFactory, + TelegramChatNFTCollectionRuleFactory, + TelegramChatToncoinRuleFactory, +) +from tests.factories import UserFactory +from tests.fixtures.action import ChatManageActionFactory + + +@pytest.mark.parametrize( + ("action_cls", "factory_cls", "model_cls"), + [ + (TelegramChatJettonAction, TelegramChatJettonRuleFactory, TelegramChatJetton), + ( + TelegramChatNFTCollectionAction, + TelegramChatNFTCollectionRuleFactory, + TelegramChatNFTCollection, + ), + ( + TelegramChatToncoinAction, + TelegramChatToncoinRuleFactory, + TelegramChatToncoin, + ), + ], +) +@pytest.mark.asyncio +async def test_delete_rule__last_in_group__group_removed( + db_session: Session, + mocked_managed_chat_action_factory: ChatManageActionFactory, + action_cls: type[ManagedChatBaseAction], + factory_cls: type[TelegramChatRuleBaseFactory], + model_cls: type[TelegramChatRuleBase], +) -> None: + group = TelegramChatRuleGroupFactory.with_session(db_session).create() + rule = factory_cls.with_session(db_session).create(group=group, chat=group.chat) + requestor = UserFactory.with_session(db_session).create() + + action = mocked_managed_chat_action_factory( + action_cls=action_cls, + db_session=db_session, + chat_slug=rule.chat.slug, + requestor=requestor, + ) + + await action.delete(rule_id=rule.id) + + assert db_session.query(model_cls).first() is None, "The rule should be deleted." + assert ( + db_session.query(TelegramChatRuleGroup).filter_by(id=group.id).first() is None + ), "The group should be deleted." + + +@pytest.mark.parametrize( + ("action_cls", "factory_cls", "model_cls"), + [ + (TelegramChatJettonAction, TelegramChatJettonRuleFactory, TelegramChatJetton), + ( + TelegramChatNFTCollectionAction, + TelegramChatNFTCollectionRuleFactory, + TelegramChatNFTCollection, + ), + ( + TelegramChatToncoinAction, + TelegramChatToncoinRuleFactory, + TelegramChatToncoin, + ), + ], +) +@pytest.mark.asyncio +async def test_delete_rule__other_rules_exist__group_retained( + db_session: Session, + mocked_managed_chat_action_factory: ChatManageActionFactory, + action_cls: type[ManagedChatBaseAction], + factory_cls: type[TelegramChatRuleBaseFactory], + model_cls: type[TelegramChatRuleBase], +) -> None: + group = TelegramChatRuleGroupFactory.with_session(db_session).create() + group_id = ( + group.id + ) # Store ID to avoid accessing stale object after commit/rollback + rule_to_delete = factory_cls.with_session(db_session).create( + group=group, chat=group.chat + ) + rule_to_delete_id = rule_to_delete.id + # Create another rule in the same group + factory_cls.with_session(db_session).create(group=group, chat=group.chat) + + requestor = UserFactory.with_session(db_session).create() + + action = mocked_managed_chat_action_factory( + action_cls=action_cls, + db_session=db_session, + chat_slug=group.chat.slug, + requestor=requestor, + ) + + await action.delete(rule_id=rule_to_delete_id) + + assert ( + db_session.query(model_cls).filter_by(id=rule_to_delete_id).first() is None + ), "The specific rule should be deleted." + assert ( + db_session.query(TelegramChatRuleGroup).filter_by(id=group_id).first() + is not None + ), "The group should NOT be deleted." + assert db_session.query(model_cls).count() == 1, "One rule should remain." diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c35ec060..a335cd18 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -26,7 +26,7 @@ services: volumes: - ./backend/api:/app/api - ./backend/core:/app/core - command: [ "uvicorn", "api.app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--reload" ] + command: [ "uvicorn", "api.app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--reload", "--log-level=info" ] nginx: