diff --git a/backend/core/src/core/services/nft.py b/backend/core/src/core/services/nft.py index 4c9dd70a..cd2351b9 100644 --- a/backend/core/src/core/services/nft.py +++ b/backend/core/src/core/services/nft.py @@ -151,8 +151,14 @@ def get_all( def bulk_create_or_update( self, nft_items: NftItems, whitelist_collection_addresses: list[str] - ) -> list[NftItem]: + ) -> tuple[list[NftItem], set[str]]: + """ + Returns the persisted NFTs and the set of addresses that lost + ownership in this batch so callers can re-queue eligibility checks + for them. + """ created_or_updated_nfts = [] + previous_owners: set[str] = set() for nft_item in nft_items.nft_items: if ( not nft_item.collection @@ -161,9 +167,18 @@ def bulk_create_or_update( ): continue - created_or_updated_nfts.append(self.create_or_update(nft_item)) + try: + existing = self.get(address=nft_item.address.to_raw()) + if existing.owner_address != nft_item.owner.address.to_raw(): + previous_owners.add(existing.owner_address) + created_or_updated_nfts.append(self._update(nft_item, existing)) + except NoResultFound: + logger.info( + f"No NFT Item for address {nft_item.address!r} found. Creating new NFT Item." + ) + created_or_updated_nfts.append(self._create(nft_item)) self.db_session.flush() - return created_or_updated_nfts + return created_or_updated_nfts, previous_owners def count(self) -> int: return self.db_session.query(NftItem).count() diff --git a/backend/indexer_blockchain/tasks.py b/backend/indexer_blockchain/tasks.py index 2ea1695d..c935bb72 100644 --- a/backend/indexer_blockchain/tasks.py +++ b/backend/indexer_blockchain/tasks.py @@ -92,7 +92,9 @@ def fetch_wallet_details(address: str) -> None: jetton_wallet_service.delete_missing(address, active_jetton_wallets) nft_service = NftItemService(db_session) - nft_service.bulk_create_or_update(nft_items, whitelist_collection_addresses) + _, evicted_owners = nft_service.bulk_create_or_update( + nft_items, whitelist_collection_addresses + ) active_nft_items = [item.address.to_raw() for item in nft_items.nft_items] nft_service.delete_missing(address, active_nft_items) @@ -100,6 +102,8 @@ def fetch_wallet_details(address: str) -> None: logger.info(f"NFT items for {address!r} updated.") redis_service = RedisService() redis_service.add_to_set(UPDATED_WALLETS_SET_NAME, address) + for evicted_owner in evicted_owners - {address}: + redis_service.add_to_set(UPDATED_WALLETS_SET_NAME, evicted_owner) @app.task( diff --git a/backend/tests/unit/core/services/test_nft_item_service.py b/backend/tests/unit/core/services/test_nft_item_service.py new file mode 100644 index 00000000..d1c7e8f2 --- /dev/null +++ b/backend/tests/unit/core/services/test_nft_item_service.py @@ -0,0 +1,122 @@ +import pytest +from pytest_mock import MockerFixture +from sqlalchemy.orm import Session + +from core.services.nft import NftItemService +from tests.factories.nft import NFTCollectionFactory, NftItemFactory +from tests.factories.wallet import UserWalletFactory + + +def _build_tonapi_nft_item( + mocker: MockerFixture, + *, + address: str, + owner_address: str, + collection_address: str, +): + nft_item = mocker.MagicMock() + nft_item.address.to_raw.return_value = address + nft_item.owner.address.to_raw.return_value = owner_address + nft_item.collection.address.to_raw.return_value = collection_address + return nft_item + + +def _build_tonapi_nft_items(items): + container = type("NftItemsContainer", (), {})() + container.nft_items = items + return container + + +@pytest.fixture(autouse=True) +def _patch_metadata_dto(mocker: MockerFixture): + mocker.patch( + "core.services.nft.NftItemMetadataDTO.from_nft_item", + return_value=None, + ) + + +def test_bulk_create_or_update_returns_previous_owner_on_ownership_change( + db_session: Session, mocker: MockerFixture +) -> None: + previous_wallet = UserWalletFactory.with_session(db_session).create() + new_wallet = UserWalletFactory.with_session(db_session).create() + nft = NftItemFactory.with_session(db_session).create( + owner_address=previous_wallet.address + ) + tonapi_nft = _build_tonapi_nft_item( + mocker, + address=nft.address, + owner_address=new_wallet.address, + collection_address=nft.collection_address, + ) + + persisted, evicted_owners = NftItemService(db_session).bulk_create_or_update( + _build_tonapi_nft_items([tonapi_nft]), + whitelist_collection_addresses=[nft.collection_address], + ) + + assert len(persisted) == 1 + assert persisted[0].owner_address == new_wallet.address + assert evicted_owners == {previous_wallet.address} + + +def test_bulk_create_or_update_returns_empty_set_when_owner_unchanged( + db_session: Session, mocker: MockerFixture +) -> None: + wallet = UserWalletFactory.with_session(db_session).create() + nft = NftItemFactory.with_session(db_session).create(owner_address=wallet.address) + tonapi_nft = _build_tonapi_nft_item( + mocker, + address=nft.address, + owner_address=wallet.address, + collection_address=nft.collection_address, + ) + + _, evicted_owners = NftItemService(db_session).bulk_create_or_update( + _build_tonapi_nft_items([tonapi_nft]), + whitelist_collection_addresses=[nft.collection_address], + ) + + assert evicted_owners == set() + + +def test_bulk_create_or_update_returns_empty_set_for_brand_new_nft( + db_session: Session, mocker: MockerFixture +) -> None: + wallet = UserWalletFactory.with_session(db_session).create() + collection = NFTCollectionFactory.with_session(db_session).create() + tonapi_nft = _build_tonapi_nft_item( + mocker, + address="0:brand_new_nft_address_used_only_for_this_test_no_collisions_xx", + owner_address=wallet.address, + collection_address=collection.address, + ) + + persisted, evicted_owners = NftItemService(db_session).bulk_create_or_update( + _build_tonapi_nft_items([tonapi_nft]), + whitelist_collection_addresses=[collection.address], + ) + + assert len(persisted) == 1 + assert evicted_owners == set() + + +def test_bulk_create_or_update_skips_non_whitelisted_collections( + db_session: Session, mocker: MockerFixture +) -> None: + wallet = UserWalletFactory.with_session(db_session).create() + collection = NFTCollectionFactory.with_session(db_session).create() + tonapi_nft = _build_tonapi_nft_item( + mocker, + address="0:nonwhitelisted_collection_nft_used_only_for_this_unit_test_xx", + owner_address=wallet.address, + collection_address=collection.address, + ) + + persisted, evicted_owners = NftItemService(db_session).bulk_create_or_update( + _build_tonapi_nft_items([tonapi_nft]), + whitelist_collection_addresses=["0:some_other_whitelisted_collection_address"], + ) + + assert persisted == [] + assert evicted_owners == set()