diff --git a/Sources/BraveWallet/Crypto/Stores/NFTStore.swift b/Sources/BraveWallet/Crypto/Stores/NFTStore.swift index d9a4bf99f64..a46528530ab 100644 --- a/Sources/BraveWallet/Crypto/Stores/NFTStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/NFTStore.swift @@ -385,6 +385,9 @@ public class NFTStore: ObservableObject, WalletObserverStore { isHidingUnownedNFTs: filters.isHidingUnownedNFTs, selectedAccounts: selectedAccounts ) + .optionallySort(shouldSort: true) { first, second in + first.token.symbol < second.token.symbol + } case let .network(network): guard let networkAssets = allUserAssets .first(where: { $0.network.chainId == network.chainId && $0.network.coin == network.coin }) else { @@ -405,13 +408,17 @@ public class NFTStore: ObservableObject, WalletObserverStore { isHidingUnownedNFTs: filters.isHidingUnownedNFTs, selectedAccounts: selectedAccounts ) + .optionallySort(shouldSort: true) { first, second in + first.token.symbol < second.token.symbol + } case let .account(account): return allUserAssets .filter { $0.network.coin == account.coin && $0.network.supportedKeyrings.contains(account.accountId.keyringId.rawValue as NSNumber) } .flatMap { networkAssets in networkAssets.tokens.compactMap { token in - guard token.isErc721 || token.isNft else { return nil } + // we need to exclude any NFT that THIS account does not own (balance is not 1) + guard token.isErc721 || token.isNft, let balance = nftBalancesCache[token.id]?[account.address], balance > 0 else { return nil } return NFTAssetViewModel( groupType: groupType, token: token, @@ -422,9 +429,12 @@ public class NFTStore: ObservableObject, WalletObserverStore { } } .optionallyFilterUnownedNFTs( - isHidingUnownedNFTs: true, // by default hiding unowned NFTs when `Group By` is `Accounts` + isHidingUnownedNFTs: true, selectedAccounts: selectedAccounts ) + .optionallySort(shouldSort: true) { first, second in + first.token.symbol < second.token.symbol + } } } diff --git a/Tests/BraveWalletTests/NFTStoreTests.swift b/Tests/BraveWalletTests/NFTStoreTests.swift index f6e1fa93c18..74b505846fb 100644 --- a/Tests/BraveWalletTests/NFTStoreTests.swift +++ b/Tests/BraveWalletTests/NFTStoreTests.swift @@ -163,12 +163,14 @@ class NFTStoreTests: XCTestCase { resetFilters() } private func resetFilters() { + Preferences.Wallet.groupByFilter.reset() Preferences.Wallet.isHidingUnownedNFTsFilter.reset() Preferences.Wallet.isShowingNFTNetworkLogoFilter.reset() Preferences.Wallet.nonSelectedAccountsFilter.reset() Preferences.Wallet.nonSelectedNetworksFilter.reset() } + // MARK: Group By `None` func testUpdate() async { let mockEthUserAssets: [BraveWallet.BlockchainToken] = [ .previewToken.copy(asVisibleAsset: true), @@ -196,33 +198,35 @@ class NFTStoreTests: XCTestCase { ipfsApi: TestIpfsAPI(), userAssetManager: mockAssetManager ) - // test that `update()` will assign new value to `userNFTs` publisher let userVisibleNFTsException = expectation(description: "update-userVisibleNFTs") - XCTAssertTrue(store.userNFTs.isEmpty) // Initial state - store.$userNFTs + XCTAssertTrue(store.userNFTGroups.isEmpty) // Initial state + store.$userNFTGroups .dropFirst() .collect(3) - .sink { userNFTs in + .sink { userNFTGroups in defer { userVisibleNFTsException.fulfill() } - XCTAssertEqual(userNFTs.count, 3) // empty nfts, populated w/ balance nfts, populated w/ metadata - guard let lastUpdatedUserNFTs = userNFTs.last else { + XCTAssertEqual(userNFTGroups.count, 3) // empty nfts, populated w/ balance nfts, populated w/ metadata + guard let lastUpdatedUserNFTGroups = userNFTGroups.last else { + XCTFail("Unexpected test result") + return + } + XCTAssertEqual(lastUpdatedUserNFTGroups.count, 1) + guard let visibleNFTs = lastUpdatedUserNFTGroups.first?.assets.filter(\.token.visible) else { XCTFail("Unexpected test result") return } - let visibleNFTs = lastUpdatedUserNFTs.filter(\.token.visible) XCTAssertEqual(visibleNFTs.count, 3) - XCTAssertEqual(visibleNFTs[safe: 0]?.token.symbol, mockEthUserAssets[safe: 2]?.symbol) - XCTAssertEqual(visibleNFTs[safe: 0]?.nftMetadata?.imageURLString, self.mockERC721Metadata.imageURLString) - XCTAssertEqual(visibleNFTs[safe: 0]?.nftMetadata?.name, self.mockERC721Metadata.name) - XCTAssertEqual(visibleNFTs[safe: 0]?.nftMetadata?.description, self.mockERC721Metadata.description) - XCTAssertEqual(visibleNFTs[safe: 1]?.token.symbol, mockEthUserAssets[safe: 3]?.symbol) - XCTAssertNil(visibleNFTs[safe: 1]?.nftMetadata) - - XCTAssertEqual(visibleNFTs[safe: 2]?.token.symbol, mockSolUserAssets[safe: 2]?.symbol) - XCTAssertEqual(visibleNFTs[safe: 2]?.nftMetadata?.imageURLString, self.mockSolMetadata.imageURLString) - XCTAssertEqual(visibleNFTs[safe: 2]?.nftMetadata?.name, self.mockSolMetadata.name) - XCTAssertEqual(visibleNFTs[safe: 2]?.nftMetadata?.description, self.mockSolMetadata.description) + XCTAssertEqual(visibleNFTs[safe: 0]?.token.symbol, mockSolUserAssets[safe: 2]?.symbol) + XCTAssertEqual(visibleNFTs[safe: 0]?.nftMetadata?.imageURLString, self.mockSolMetadata.imageURLString) + XCTAssertEqual(visibleNFTs[safe: 0]?.nftMetadata?.name, self.mockSolMetadata.name) + XCTAssertEqual(visibleNFTs[safe: 0]?.nftMetadata?.description, self.mockSolMetadata.description) + XCTAssertEqual(visibleNFTs[safe: 1]?.token.symbol, mockEthUserAssets[safe: 2]?.symbol) + XCTAssertEqual(visibleNFTs[safe: 1]?.nftMetadata?.imageURLString, self.mockERC721Metadata.imageURLString) + XCTAssertEqual(visibleNFTs[safe: 1]?.nftMetadata?.name, self.mockERC721Metadata.name) + XCTAssertEqual(visibleNFTs[safe: 1]?.nftMetadata?.description, self.mockERC721Metadata.description) + XCTAssertEqual(visibleNFTs[safe: 2]?.token.symbol, mockEthUserAssets[safe: 3]?.symbol) + XCTAssertNil(visibleNFTs[safe: 2]?.nftMetadata) }.store(in: &cancellables) store.update() @@ -233,17 +237,22 @@ class NFTStoreTests: XCTestCase { // MARK: Network Filter Test let networksExpectation = expectation(description: "update-networks") - store.$userNFTs + store.$userNFTGroups .dropFirst() .collect(2) - .sink { userNFTs in + .sink { userNFTGroups in defer { networksExpectation.fulfill() } - XCTAssertEqual(userNFTs.count, 2) // empty nfts, populated nfts - guard let lastUpdatedUserNFTs = userNFTs.last else { + XCTAssertEqual(userNFTGroups.count, 2) // empty nfts, populated nfts + guard let lastUpdatedUserNFTGroups = userNFTGroups.last else { XCTFail("Unexpected test result") return } - let visibleNFTs = lastUpdatedUserNFTs.filter(\.token.visible) + XCTAssertEqual(lastUpdatedUserNFTGroups.count, 1) + guard let visibleNFTs = lastUpdatedUserNFTGroups.first?.assets.filter(\.token.visible) else { + XCTFail("Unexpected test result") + return + } + XCTAssertEqual(visibleNFTs.count, 2) XCTAssertEqual(visibleNFTs[safe: 0]?.token.symbol, mockEthUserAssets[safe: 2]?.symbol) XCTAssertEqual(visibleNFTs[safe: 1]?.token.symbol, mockEthUserAssets[safe: 3]?.symbol) @@ -265,20 +274,24 @@ class NFTStoreTests: XCTestCase { // MARK: Hiding Unowned Filter Test let hidingUnownedExpectation = expectation(description: "update-hidingUnowned") - store.$userNFTs + store.$userNFTGroups .dropFirst() .collect(3) - .sink { userNFTs in + .sink { userNFTGroups in defer { hidingUnownedExpectation.fulfill() } - XCTAssertEqual(userNFTs.count, 3) // empty nfts, populated w/ balance nfts, populated w/ metadata - guard let lastUpdatedUserNFTs = userNFTs.last else { + XCTAssertEqual(userNFTGroups.count, 3) // empty nfts, populated w/ balance nfts, populated w/ metadata + guard let lastUpdatedUserNFTGroups = userNFTGroups.last else { + XCTFail("Unexpected test result") + return + } + XCTAssertEqual(lastUpdatedUserNFTGroups.count, 1) + guard let visibleNFTs = lastUpdatedUserNFTGroups.first?.assets.filter(\.token.visible) else { XCTFail("Unexpected test result") return } - let visibleNFTs = lastUpdatedUserNFTs.filter(\.token.visible) XCTAssertEqual(visibleNFTs.count, 2) - XCTAssertEqual(visibleNFTs[safe: 0]?.token.symbol, mockEthUserAssets[safe: 2]?.symbol) - XCTAssertEqual(visibleNFTs[safe: 1]?.token.symbol, mockSolUserAssets[safe: 2]?.symbol) + XCTAssertEqual(visibleNFTs[safe: 0]?.token.symbol, mockSolUserAssets[safe: 2]?.symbol) + XCTAssertEqual(visibleNFTs[safe: 1]?.token.symbol, mockEthUserAssets[safe: 2]?.symbol) }.store(in: &cancellables) store.saveFilters(.init( groupBy: defaultFilters.groupBy, @@ -294,17 +307,21 @@ class NFTStoreTests: XCTestCase { // MARK: Accounts Filter Test let accountsExpectation = expectation(description: "update-accounts") - store.$userNFTs + store.$userNFTGroups .dropFirst() .collect(2) - .sink { userNFTs in + .sink { userNFTGroups in defer { accountsExpectation.fulfill() } - XCTAssertEqual(userNFTs.count, 2) // empty nfts, populated nfts - guard let lastUpdatedNFTs = userNFTs.last else { + XCTAssertEqual(userNFTGroups.count, 2) // empty nfts, populated nfts + guard let lastUpdatedNFTGroups = userNFTGroups.last else { + XCTFail("Unexpected test result") + return + } + XCTAssertEqual(lastUpdatedNFTGroups.count, 1) + guard let visibleNFTs = lastUpdatedNFTGroups.first?.assets.filter(\.token.visible) else { XCTFail("Unexpected test result") return } - let visibleNFTs = lastUpdatedNFTs.filter(\.token.visible) XCTAssertEqual(visibleNFTs.count, 1) XCTAssertEqual(visibleNFTs[safe: 0]?.token.symbol, mockEthUserAssets[safe: 2]?.symbol) }.store(in: &cancellables) @@ -323,6 +340,7 @@ class NFTStoreTests: XCTestCase { cancellables.removeAll() } + // MARK: Group By `None` func testUpdateForInvisibleGroup() async { let mockEthUserAssets: [BraveWallet.BlockchainToken] = [ .previewToken.copy(asVisibleAsset: true), @@ -361,19 +379,28 @@ class NFTStoreTests: XCTestCase { userAssetManager: mockAssetManager ) + // MARK: Group By: None // test that `update()` will assign new value to `userInvisibleNFTs` publisher let userHiddenNFTsException = expectation(description: "update-userInvisibleNFTs") - store.$userNFTs + store.$userNFTGroups .dropFirst() .collect(3) - .sink { userNFTs in + .sink { userNFTGroups in defer { userHiddenNFTsException.fulfill() } - XCTAssertEqual(userNFTs.count, 3) // empty nfts, populated w/ balance nfts, populated w/ metadata - guard let lastUpdatedNFTs = userNFTs.last else { + XCTAssertEqual(userNFTGroups.count, 3) // empty nfts, populated w/ balance nfts, populated w/ metadata + guard let lastUpdatedNFTGroups = userNFTGroups.last else { + XCTFail("Unexpected test result") + return + } + XCTAssertEqual(lastUpdatedNFTGroups.count, 1) + guard let onlyGroupAssets = lastUpdatedNFTGroups.first?.assets else { XCTFail("Unexpected test result") return } - let hiddenNFTs = lastUpdatedNFTs.filter { !$0.token.visible && !$0.token.isSpam } + let hiddenNFTs = onlyGroupAssets.filter { + !$0.token.visible && !$0.token.isSpam + } + XCTAssertEqual(hiddenNFTs.count, 1) XCTAssertEqual(hiddenNFTs[safe: 0]?.token.symbol, mockEthUserAssets[safe: 4]?.symbol) XCTAssertEqual(hiddenNFTs[safe: 0]?.nftMetadata?.imageURLString, self.mockERC721Metadata.imageURLString) @@ -384,7 +411,8 @@ class NFTStoreTests: XCTestCase { cancellables.removeAll() } - func testUpdateSpamGrupOnlyFromSimpleHash() async { + // MARK: Group By `None` + func testUpdateSpamGroupOnlyFromSimpleHash() async { let mockEthUserAssets: [BraveWallet.BlockchainToken] = [ .previewToken.copy(asVisibleAsset: true), .mockUSDCToken.copy(asVisibleAsset: false), // Verify non-visible assets not displayed #6386 @@ -411,6 +439,7 @@ class NFTStoreTests: XCTestCase { completion("", metadata, .success, "") } + // MARK: Group By: None // setup store let store = NFTStore( keyringService: keyringService, @@ -424,17 +453,21 @@ class NFTStoreTests: XCTestCase { // test that `update()` will assign new value to `userNFTs` publisher let userSpamNFTsException = expectation(description: "update-userInvisibleNFTs1") - store.$userNFTs + store.$userNFTGroups .dropFirst() .collect(3) - .sink { userNFTs in + .sink { userNFTGroups in defer { userSpamNFTsException.fulfill() } - XCTAssertEqual(userNFTs.count, 3) // empty nfts, populated w/ balance nfts, populated w/ metadata - guard let lastUpdatedUserNFTs = userNFTs.last else { + XCTAssertEqual(userNFTGroups.count, 3) // empty nfts, populated w/ balance nfts, populated w/ metadata + guard let lastUpdatedUserNFTGroups = userNFTGroups.last else { + XCTFail("Unexpected test result") + return + } + XCTAssertEqual(lastUpdatedUserNFTGroups.count, 1) + guard let spamNFTs = lastUpdatedUserNFTGroups.first?.assets.filter(\.token.isSpam) else { XCTFail("Unexpected test result") return } - let spamNFTs = lastUpdatedUserNFTs.filter(\.token.isSpam) XCTAssertEqual(spamNFTs.count, 1) XCTAssertEqual(spamNFTs[safe: 0]?.token.symbol, self.spamEthNFT.symbol) XCTAssertEqual(spamNFTs[safe: 0]?.nftMetadata?.imageURLString, self.mockERC721Metadata.imageURLString) @@ -446,7 +479,8 @@ class NFTStoreTests: XCTestCase { cancellables.removeAll() } - func testUpdateSpamGrupFromSimpleHashAndUserMarked() async { + // MARK: Group By `None` + func testUpdateSpamGroupFromSimpleHashAndUserMarked() async { let mockEthUserAssets: [BraveWallet.BlockchainToken] = [ .previewToken.copy(asVisibleAsset: true), .mockUSDCToken.copy(asVisibleAsset: false), // Verify non-visible assets not displayed #6386 @@ -486,17 +520,21 @@ class NFTStoreTests: XCTestCase { // test that `update()` will assign new value to `userNFTs` publisher let userSpamNFTsException = expectation(description: "update-userSpamNFTsException2") - store.$userNFTs + store.$userNFTGroups .dropFirst() .collect(3) - .sink { userNFTs in + .sink { userNFTGroups in defer { userSpamNFTsException.fulfill() } - XCTAssertEqual(userNFTs.count, 3) // empty nfts, populated w/ balance nfts, populated w/ metadata - guard let lastUpdatedUserNFTs = userNFTs.last else { + XCTAssertEqual(userNFTGroups.count, 3) // empty nfts, populated w/ balance nfts, populated w/ metadata + guard let lastUpdatedUserNFTGroups = userNFTGroups.last else { + XCTFail("Unexpected test result") + return + } + XCTAssertEqual(lastUpdatedUserNFTGroups.count, 1) + guard let spamNFTs = lastUpdatedUserNFTGroups.first?.assets.filter(\.token.isSpam) else { XCTFail("Unexpected test result") return } - let spamNFTs = lastUpdatedUserNFTs.filter(\.token.isSpam) XCTAssertEqual(spamNFTs.count, 2) XCTAssertEqual(spamNFTs[safe: 0]?.token.symbol, BraveWallet.BlockchainToken.mockSolanaNFTToken.symbol) XCTAssertEqual(spamNFTs[safe: 0]?.nftMetadata?.imageURLString, self.mockSolMetadata.imageURLString) @@ -511,7 +549,8 @@ class NFTStoreTests: XCTestCase { cancellables.removeAll() } - func testUpdateSpamGrupDuplicationFromSimpleHashAndUserMarked() async { + // MARK: Group By `None` + func testUpdateSpamGroupDuplicationFromSimpleHashAndUserMarked() async { let mockEthUserAssets: [BraveWallet.BlockchainToken] = [ .previewToken.copy(asVisibleAsset: true), .mockUSDCToken.copy(asVisibleAsset: false), // Verify non-visible assets not displayed #6386 @@ -552,17 +591,21 @@ class NFTStoreTests: XCTestCase { // test that `update()` will assign new value to `userSpamNFTs` publisher let userSpamNFTsException = expectation(description: "update-userSpamNFTsException2") - store.$userNFTs + store.$userNFTGroups .dropFirst() .collect(3) - .sink { userNFTs in + .sink { userNFTGroups in defer { userSpamNFTsException.fulfill() } - XCTAssertEqual(userNFTs.count, 3) // empty nfts, populated w/ balance nfts, populated w/ metadata - guard let lastUpdatedUserNFTs = userNFTs.last else { + XCTAssertEqual(userNFTGroups.count, 3) // empty nfts, populated w/ balance nfts, populated w/ metadata + guard let lastUpdatedUserNFTGroups = userNFTGroups.last else { + XCTFail("Unexpected test result") + return + } + XCTAssertEqual(lastUpdatedUserNFTGroups.count, 1) + guard let spamNFTs = lastUpdatedUserNFTGroups.first?.assets.filter(\.token.isSpam) else { XCTFail("Unexpected test result") return } - let spamNFTs = lastUpdatedUserNFTs.filter(\.token.isSpam) XCTAssertEqual(spamNFTs.count, 1) XCTAssertEqual(spamNFTs[safe: 0]?.token.symbol, self.spamEthNFT.symbol) XCTAssertEqual(spamNFTs[safe: 0]?.nftMetadata?.imageURLString, self.mockERC721Metadata.imageURLString) @@ -573,4 +616,321 @@ class NFTStoreTests: XCTestCase { await fulfillment(of: [userSpamNFTsException], timeout: 1) cancellables.removeAll() } + + // MARK: Group by `Accounts` with `displayType`: `visible` + func testUpdateGroupByAccountsVisibleNFTs() async { + let mockEthUserAssets: [BraveWallet.BlockchainToken] = [ + .previewToken.copy(asVisibleAsset: true), + .mockUSDCToken.copy(asVisibleAsset: false), + .mockERC721NFTToken, + unownedEthNFT, + invisibleEthNFT + ] + let mockSolUserAssets: [BraveWallet.BlockchainToken] = [ + BraveWallet.NetworkInfo.mockSolana.nativeToken.copy(asVisibleAsset: true), + .mockSpdToken.copy(asVisibleAsset: false), + .mockSolanaNFTToken + ] + + // setup test services + let (keyringService, rpcService, walletService, assetRatioService, mockAssetManager) = setupServices(mockEthUserAssets: mockEthUserAssets, mockSolUserAssets: mockSolUserAssets) + + // setup store + let store = NFTStore( + keyringService: keyringService, + rpcService: rpcService, + walletService: walletService, + assetRatioService: assetRatioService, + blockchainRegistry: BraveWallet.TestBlockchainRegistry(), + ipfsApi: TestIpfsAPI(), + userAssetManager: mockAssetManager + ) + + let defaultFilters = store.filters + + let groupByAccountVisibleExpectation = expectation(description: "groupByAccountVisibleExpectation") + store.$userNFTGroups + .dropFirst() + .collect(3) + .sink { userNFTGroups in + defer { groupByAccountVisibleExpectation.fulfill() } + XCTAssertEqual(userNFTGroups.count, 3) // empty nfts, populated w/ balance nfts, populated w/ metadata + guard let lastUpdatedUserNFTGroups = userNFTGroups.last else { + XCTFail("Unexpected test result") + return + } + XCTAssertEqual(lastUpdatedUserNFTGroups.count, 3) + guard + let groupOneVisibleNFTs = lastUpdatedUserNFTGroups.first?.assets.filter(\.token.visible), + let groupTwoVisibleNFTs = lastUpdatedUserNFTGroups[safe: 1]?.assets.filter(\.token.visible), + let groupThreeVisibleNFTs = lastUpdatedUserNFTGroups[safe: 2]?.assets.filter(\.token.visible) + else { + XCTFail("Unexpected test result") + return + } + XCTAssertEqual(groupOneVisibleNFTs.count, 1) + XCTAssertEqual(groupOneVisibleNFTs[safe: 0]?.token.symbol, mockSolUserAssets[safe: 2]?.symbol) + XCTAssertEqual(groupTwoVisibleNFTs.count, 1) + XCTAssertEqual(groupTwoVisibleNFTs[safe: 0]?.token.symbol, mockEthUserAssets[safe: 2]?.symbol) + XCTAssertTrue(groupThreeVisibleNFTs.isEmpty) + }.store(in: &cancellables) + store.saveFilters(.init( + groupBy: .accounts, + sortOrder: defaultFilters.sortOrder, + isHidingSmallBalances: defaultFilters.isHidingSmallBalances, + isHidingUnownedNFTs: true, + isShowingNFTNetworkLogo: defaultFilters.isShowingNFTNetworkLogo, + accounts: defaultFilters.accounts, + networks: defaultFilters.networks + )) + await fulfillment(of: [groupByAccountVisibleExpectation], timeout: 1) + cancellables.removeAll() + } + // MARK: Group by `Accounts` with `displayType`: `hidden` + func testUpdateGroupByAccountsHiddenNFTs() async { + let mockEthUserAssets: [BraveWallet.BlockchainToken] = [ + .previewToken.copy(asVisibleAsset: true), + .mockUSDCToken.copy(asVisibleAsset: false), + .mockERC721NFTToken.copy(asVisibleAsset: false), + unownedEthNFT, + invisibleEthNFT + ] + let mockSolUserAssets: [BraveWallet.BlockchainToken] = [ + BraveWallet.NetworkInfo.mockSolana.nativeToken.copy(asVisibleAsset: true), + .mockSpdToken.copy(asVisibleAsset: false), + .mockSolanaNFTToken.copy(asVisibleAsset: false) + ] + + // setup test services + let (keyringService, rpcService, walletService, assetRatioService, mockAssetManager) = setupServices(mockEthUserAssets: mockEthUserAssets, mockSolUserAssets: mockSolUserAssets) + rpcService._erc721Metadata = { contractAddress, tokenId, chainId, completion in + let metadata = """ + { + "image": "mock.image.url", + "name": "mock nft name", + "description": "mock nft description" + } + """ + completion("", metadata, .success, "") + } + + // setup store + let store = NFTStore( + keyringService: keyringService, + rpcService: rpcService, + walletService: walletService, + assetRatioService: assetRatioService, + blockchainRegistry: BraveWallet.TestBlockchainRegistry(), + ipfsApi: TestIpfsAPI(), + userAssetManager: mockAssetManager + ) + + let defaultFilters = store.filters + + let groupByAccountHiddenNFTsException = expectation(description: "groupByAccountHiddenNFTsException") + store.$userNFTGroups + .dropFirst() + .collect(3) + .sink { userNFTGroups in + defer { groupByAccountHiddenNFTsException.fulfill() } + XCTAssertEqual(userNFTGroups.count, 3) // empty nfts, populated w/ balance nfts, populated w/ metadata + guard let lastUpdatedNFTGroups = userNFTGroups.last else { + XCTFail("Unexpected test result") + return + } + XCTAssertEqual(lastUpdatedNFTGroups.count, 3) + guard + let groupOne = lastUpdatedNFTGroups.first, + let groupTwo = lastUpdatedNFTGroups[safe: 1], + let groupThree = lastUpdatedNFTGroups[safe: 2] + else { + XCTFail("Unexpected test result") + return + } + let groupOneHiddenNFTs = groupOne.assets.filter { + !$0.token.visible && !$0.token.isSpam + } + let groupTwoHiddenNFTs = groupTwo.assets.filter { + !$0.token.visible && !$0.token.isSpam + } + let groupThreeHiddenNFTs = groupThree.assets.filter { + !$0.token.visible && !$0.token.isSpam + } + XCTAssertEqual(groupOneHiddenNFTs.count, 1) + XCTAssertEqual(groupOneHiddenNFTs[safe: 0]?.token.symbol, mockSolUserAssets[safe: 2]?.symbol) + XCTAssertEqual(groupOneHiddenNFTs[safe: 0]?.nftMetadata?.imageURLString, self.mockSolMetadata.imageURLString) + XCTAssertEqual(groupOneHiddenNFTs[safe: 0]?.nftMetadata?.name, self.mockSolMetadata.name) + XCTAssertEqual(groupTwoHiddenNFTs.count, 1) + XCTAssertEqual(groupTwoHiddenNFTs[safe: 0]?.token.symbol, mockEthUserAssets[safe: 2]?.symbol) + XCTAssertEqual(groupTwoHiddenNFTs[safe: 0]?.nftMetadata?.imageURLString, self.mockERC721Metadata.imageURLString) + XCTAssertEqual(groupTwoHiddenNFTs[safe: 0]?.nftMetadata?.name, self.mockERC721Metadata.name) + XCTAssertEqual(groupThreeHiddenNFTs.count, 0) + }.store(in: &cancellables) + store.saveFilters(.init( + groupBy: .accounts, + sortOrder: defaultFilters.sortOrder, + isHidingSmallBalances: defaultFilters.isHidingSmallBalances, + isHidingUnownedNFTs: true, + isShowingNFTNetworkLogo: defaultFilters.isShowingNFTNetworkLogo, + accounts: defaultFilters.accounts, + networks: defaultFilters.networks + )) + await fulfillment(of: [groupByAccountHiddenNFTsException], timeout: 1) + cancellables.removeAll() + } + + // MARK: Group by `Networks` with `displayType`: `visible` + func testUpdateGroupByNetworksVisibleNFTs() async { + let mockEthUserAssets: [BraveWallet.BlockchainToken] = [ + .previewToken.copy(asVisibleAsset: true), + .mockUSDCToken.copy(asVisibleAsset: false), + .mockERC721NFTToken, + unownedEthNFT, + invisibleEthNFT + ] + let mockSolUserAssets: [BraveWallet.BlockchainToken] = [ + BraveWallet.NetworkInfo.mockSolana.nativeToken.copy(asVisibleAsset: true), + .mockSpdToken.copy(asVisibleAsset: false), + .mockSolanaNFTToken + ] + + // setup test services + let (keyringService, rpcService, walletService, assetRatioService, mockAssetManager) = setupServices(mockEthUserAssets: mockEthUserAssets, mockSolUserAssets: mockSolUserAssets) + + // setup store + let store = NFTStore( + keyringService: keyringService, + rpcService: rpcService, + walletService: walletService, + assetRatioService: assetRatioService, + blockchainRegistry: BraveWallet.TestBlockchainRegistry(), + ipfsApi: TestIpfsAPI(), + userAssetManager: mockAssetManager + ) + + let defaultFilters = store.filters + + let groupByNetworkVisibleExpectation = expectation(description: "groupByNetworkVisibleExpectation") + store.$userNFTGroups + .dropFirst() + .collect(3) + .sink { userNFTGroups in + defer { groupByNetworkVisibleExpectation.fulfill() } + XCTAssertEqual(userNFTGroups.count, 3) // empty nfts, populated w/ balance nfts, populated w/ metadata + guard let lastUpdatedUserNFTGroups = userNFTGroups.last else { + XCTFail("Unexpected test result") + return + } + XCTAssertEqual(lastUpdatedUserNFTGroups.count, 2) + guard + let groupOneVisibleNFTs = lastUpdatedUserNFTGroups.first?.assets.filter(\.token.visible), + let groupTwoVisibleNFTs = lastUpdatedUserNFTGroups.last?.assets.filter(\.token.visible) + else { + XCTFail("Unexpected test result") + return + } + XCTAssertEqual(groupOneVisibleNFTs.count, 1) + XCTAssertEqual(groupOneVisibleNFTs[safe: 0]?.token.symbol, mockSolUserAssets[safe: 2]?.symbol) + XCTAssertEqual(groupTwoVisibleNFTs.count, 1) + XCTAssertEqual(groupTwoVisibleNFTs[safe: 0]?.token.symbol, mockEthUserAssets[safe: 2]?.symbol) + }.store(in: &cancellables) + store.saveFilters(.init( + groupBy: .networks, + sortOrder: defaultFilters.sortOrder, + isHidingSmallBalances: defaultFilters.isHidingSmallBalances, + isHidingUnownedNFTs: true, + isShowingNFTNetworkLogo: defaultFilters.isShowingNFTNetworkLogo, + accounts: defaultFilters.accounts, + networks: defaultFilters.networks + )) + await fulfillment(of: [groupByNetworkVisibleExpectation], timeout: 1) + cancellables.removeAll() + } + // MARK: Group by `Networks` with `displayType`: `hidden` + func testUpdateGroupByNetworksHiddenNFTs() async { + let mockEthUserAssets: [BraveWallet.BlockchainToken] = [ + .previewToken.copy(asVisibleAsset: true), + .mockUSDCToken.copy(asVisibleAsset: false), + .mockERC721NFTToken.copy(asVisibleAsset: false), + unownedEthNFT, + invisibleEthNFT + ] + let mockSolUserAssets: [BraveWallet.BlockchainToken] = [ + BraveWallet.NetworkInfo.mockSolana.nativeToken.copy(asVisibleAsset: true), + .mockSpdToken.copy(asVisibleAsset: false), + .mockSolanaNFTToken.copy(asVisibleAsset: false) + ] + + // setup test services + let (keyringService, rpcService, walletService, assetRatioService, mockAssetManager) = setupServices(mockEthUserAssets: mockEthUserAssets, mockSolUserAssets: mockSolUserAssets) + rpcService._erc721Metadata = { contractAddress, tokenId, chainId, completion in + let metadata = """ + { + "image": "mock.image.url", + "name": "mock nft name", + "description": "mock nft description" + } + """ + completion("", metadata, .success, "") + } + + // setup store + let store = NFTStore( + keyringService: keyringService, + rpcService: rpcService, + walletService: walletService, + assetRatioService: assetRatioService, + blockchainRegistry: BraveWallet.TestBlockchainRegistry(), + ipfsApi: TestIpfsAPI(), + userAssetManager: mockAssetManager + ) + + let defaultFilters = store.filters + + let groupByAccountHiddenNFTsException = expectation(description: "groupByAccountHiddenNFTsException") + store.$userNFTGroups + .dropFirst() + .collect(3) + .sink { userNFTGroups in + defer { groupByAccountHiddenNFTsException.fulfill() } + XCTAssertEqual(userNFTGroups.count, 3) // empty nfts, populated w/ balance nfts, populated w/ metadata + guard let lastUpdatedNFTGroups = userNFTGroups.last else { + XCTFail("Unexpected test result") + return + } + XCTAssertEqual(lastUpdatedNFTGroups.count, 2) + guard + let groupOne = lastUpdatedNFTGroups.first, + let groupTwo = lastUpdatedNFTGroups.last + else { + XCTFail("Unexpected test result") + return + } + let groupOneHiddenNFTs = groupOne.assets.filter { + !$0.token.visible && !$0.token.isSpam + } + let groupTwoHiddenNFTs = groupTwo.assets.filter { + !$0.token.visible && !$0.token.isSpam + } + XCTAssertEqual(groupOneHiddenNFTs.count, 1) + XCTAssertEqual(groupOneHiddenNFTs[safe: 0]?.token.symbol, mockSolUserAssets[safe: 2]?.symbol) + XCTAssertEqual(groupOneHiddenNFTs[safe: 0]?.nftMetadata?.imageURLString, self.mockSolMetadata.imageURLString) + XCTAssertEqual(groupOneHiddenNFTs[safe: 0]?.nftMetadata?.name, self.mockSolMetadata.name) + XCTAssertEqual(groupTwoHiddenNFTs.count, 1) + XCTAssertEqual(groupTwoHiddenNFTs[safe: 0]?.token.symbol, mockEthUserAssets[safe: 2]?.symbol) + XCTAssertEqual(groupTwoHiddenNFTs[safe: 0]?.nftMetadata?.imageURLString, self.mockERC721Metadata.imageURLString) + XCTAssertEqual(groupTwoHiddenNFTs[safe: 0]?.nftMetadata?.name, self.mockERC721Metadata.name) + }.store(in: &cancellables) + store.saveFilters(.init( + groupBy: .networks, + sortOrder: defaultFilters.sortOrder, + isHidingSmallBalances: defaultFilters.isHidingSmallBalances, + isHidingUnownedNFTs: true, + isShowingNFTNetworkLogo: defaultFilters.isShowingNFTNetworkLogo, + accounts: defaultFilters.accounts, + networks: defaultFilters.networks + )) + await fulfillment(of: [groupByAccountHiddenNFTsException], timeout: 1) + cancellables.removeAll() + } }