diff --git a/test/CheckIn.t.sol b/test/CheckIn.t.sol index bbc4b30..7fffcfa 100644 --- a/test/CheckIn.t.sol +++ b/test/CheckIn.t.sol @@ -1,9 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity 0.8.30; +import {FullTicketData} from "@ticket-storage/FactoryStorage.sol"; import {DeployedHostItTickets} from "@ticket-test/states/DeployedHostItTickets.sol"; /// forge-lint: disable-next-line(unaliased-plain-import) import "@ticket-logs/CheckInLogs.sol"; +/// forge-lint: disable-next-line(unaliased-plain-import) +import "@ticket-errors/CheckInErrors.sol"; contract CheckInTest is DeployedHostItTickets { function test_checkIn() public { @@ -54,4 +57,168 @@ contract CheckInTest is DeployedHostItTickets { vm.expectRevert(); checkInFacet.checkIn(ticketId, alice, 1); } + + // ====================================================================== + // REVERT TESTS + // ====================================================================== + + function test_checkIn_revertsNotTicketOwner() public { + (uint64 ticketId,) = _mintTicketFree(); + vm.warp(1 days + 1); + vm.expectRevert(abi.encodeWithSelector(NotTicketOwner.selector, uint40(1))); + checkInFacet.checkIn(ticketId, bob, 1); + } + + function test_checkIn_revertsAlreadyCheckedInForDay() public { + (uint64 ticketId, uint40 tokenId) = _mintTicketFree(); + vm.warp(1 days + 1); + checkInFacet.checkIn(ticketId, alice, tokenId); + vm.expectRevert(abi.encodeWithSelector(AlreadyCheckedInForDay.selector, uint8(0))); + checkInFacet.checkIn(ticketId, alice, tokenId); + } + + function test_addTicketAdmins_revertsNoAdmins() public { + (uint64 ticketId,) = _mintTicketFree(); + address[] memory admins = new address[](0); + vm.expectRevert(NoAdmins.selector); + checkInFacet.addTicketAdmins(ticketId, admins); + } + + function test_addTicketAdmins_revertsAddressZero() public { + (uint64 ticketId,) = _mintTicketFree(); + address[] memory admins = new address[](1); + admins[0] = address(0); + vm.expectRevert(AddressZeroAdmin.selector); + checkInFacet.addTicketAdmins(ticketId, admins); + } + + function test_removeTicketAdmins_revertsNoAdmins() public { + (uint64 ticketId,) = _mintTicketFree(); + address[] memory admins = new address[](0); + vm.expectRevert(NoAdmins.selector); + checkInFacet.removeTicketAdmins(ticketId, admins); + } + + function test_removeTicketAdmins_revertsAddressZero() public { + (uint64 ticketId,) = _mintTicketFree(); + address[] memory admins = new address[](1); + admins[0] = address(0); + vm.expectRevert(AddressZeroAdmin.selector); + checkInFacet.removeTicketAdmins(ticketId, admins); + } + + function test_addTicketAdmins_revertsNonMainAdmin() public { + (uint64 ticketId,) = _mintTicketFree(); + address[] memory admins = new address[](1); + admins[0] = charlie; + vm.prank(alice); + vm.expectRevert(); + checkInFacet.addTicketAdmins(ticketId, admins); + } + + function test_removeTicketAdmins_revertsNonMainAdmin() public { + (uint64 ticketId,) = _mintTicketFree(); + address[] memory admins = new address[](1); + admins[0] = bob; + checkInFacet.addTicketAdmins(ticketId, admins); + vm.prank(alice); + vm.expectRevert(); + checkInFacet.removeTicketAdmins(ticketId, admins); + } + + function test_checkIn_revertsNonAdmin() public { + (uint64 ticketId, uint40 tokenId) = _mintTicketFree(); + vm.warp(1 days + 1); + vm.prank(alice); + vm.expectRevert(); + checkInFacet.checkIn(ticketId, alice, tokenId); + } + + function test_getCheckedIn() public { + (uint64 ticketId, uint40 tokenId) = _mintTicketFree(); + vm.warp(1 days + 1); + checkInFacet.checkIn(ticketId, alice, tokenId); + address[] memory checkedIn = checkInFacet.getCheckedIn(ticketId); + assertEq(checkedIn.length, 1); + assertEq(checkedIn[0], alice); + } + + function test_getCheckedInForDay() public { + (uint64 ticketId, uint40 tokenId) = _mintTicketFree(); + vm.warp(1 days + 1); + checkInFacet.checkIn(ticketId, alice, tokenId); + address[] memory checkedIn = checkInFacet.getCheckedInForDay(ticketId, 0); + assertEq(checkedIn.length, 1); + assertEq(checkedIn[0], alice); + // Day 1 should be empty + address[] memory day1 = checkInFacet.getCheckedInForDay(ticketId, 1); + assertEq(day1.length, 0); + } + + function test_isCheckedIn_returnsFalseWhenNot() public { + (uint64 ticketId,) = _mintTicketFree(); + assertFalse(checkInFacet.isCheckedIn(ticketId, alice)); + assertFalse(checkInFacet.isCheckedInForDay(ticketId, 0, alice)); + } + + // ====================================================================== + // FUZZ TESTS + // ====================================================================== + + function testFuzz_checkIn_validDay(uint256 dayOffset) public { + (uint64 ticketId, uint40 tokenId) = _mintTicketFree(); + FullTicketData memory ftd = factoryFacet.ticketData(ticketId); + + // Event runs from startTime to endTime (1 day duration in default data) + uint256 eventDuration = ftd.endTime - ftd.startTime; + dayOffset = bound(dayOffset, 0, eventDuration - 1); + + vm.warp(ftd.startTime + dayOffset); + checkInFacet.checkIn(ticketId, alice, tokenId); + + uint8 expectedDay = uint8(dayOffset / 1 days); + assertTrue(checkInFacet.isCheckedIn(ticketId, alice)); + assertTrue(checkInFacet.isCheckedInForDay(ticketId, expectedDay, alice)); + } + + function testFuzz_checkIn_revertsBeforeStart(uint256 warpTo) public { + (uint64 ticketId, uint40 tokenId) = _mintTicketFree(); + FullTicketData memory ftd = factoryFacet.ticketData(ticketId); + + warpTo = bound(warpTo, block.timestamp, ftd.startTime - 1); + vm.warp(warpTo); + + vm.expectRevert(TicketUsePeriodNotStarted.selector); + checkInFacet.checkIn(ticketId, alice, tokenId); + } + + function testFuzz_checkIn_revertsAfterEnd(uint256 extraTime) public { + (uint64 ticketId, uint40 tokenId) = _mintTicketFree(); + FullTicketData memory ftd = factoryFacet.ticketData(ticketId); + + extraTime = bound(extraTime, 1, 365 days); + vm.warp(ftd.endTime + extraTime); + + vm.expectRevert(TicketUsePeriodHasEnded.selector); + checkInFacet.checkIn(ticketId, alice, tokenId); + } + + function testFuzz_addTicketAdmins(uint8 adminCount) public { + adminCount = uint8(bound(adminCount, 1, 20)); + + (uint64 ticketId,) = _mintTicketFree(); + + address[] memory admins = new address[](adminCount); + for (uint8 i; i < adminCount; ++i) { + admins[i] = makeAddr(string(abi.encodePacked("admin", i))); + } + + checkInFacet.addTicketAdmins(ticketId, admins); + + // Each admin can check in + vm.warp(1 days + 1); + vm.prank(admins[0]); + checkInFacet.checkIn(ticketId, alice, 1); + assertTrue(checkInFacet.isCheckedIn(ticketId, alice)); + } } diff --git a/test/Factory.t.sol b/test/Factory.t.sol index 8d09e0b..1344c48 100644 --- a/test/Factory.t.sol +++ b/test/Factory.t.sol @@ -3,7 +3,10 @@ pragma solidity 0.8.30; import {TicketCreated, TicketUpdated} from "@ticket-logs/FactoryLogs.sol"; import {ExtraTicketData, FullTicketData, TicketData} from "@ticket-storage/FactoryStorage.sol"; +import {FeeType} from "@ticket-storage/MarketplaceStorage.sol"; import {DeployedHostItTickets} from "@ticket-test/states/DeployedHostItTickets.sol"; +/// forge-lint: disable-next-line(unaliased-plain-import) +import "@ticket-errors/FactoryErrors.sol"; contract FactoryTest is DeployedHostItTickets { function test_createFreeTicket() public { @@ -168,27 +171,237 @@ contract FactoryTest is DeployedHostItTickets { assertEq(ticketAdminRole, uint256(keccak256(abi.encode(keccak256("host.it.ticket.admin"), ticketId)))); } + //*////////////////////////////////////////////////////////////////////////// + // REVERT TESTS + //////////////////////////////////////////////////////////////////////////*// + + function test_createTicket_revertsEmptyName() public { + TicketData memory td = _getFreeTicketData(); + td.name = ""; + vm.expectRevert(EmptyName.selector); + factoryFacet.createTicket(td, _getZeroFeeType(), _getZeroFee()); + } + + function test_createTicket_revertsEmptyURI() public { + TicketData memory td = _getFreeTicketData(); + td.uri = ""; + vm.expectRevert(EmptyURI.selector); + factoryFacet.createTicket(td, _getZeroFeeType(), _getZeroFee()); + } + + function test_createTicket_revertsMaxTicketsIsZero() public { + TicketData memory td = _getFreeTicketData(); + td.maxTickets = 0; + vm.expectRevert(MaxTicketsIsZero.selector); + factoryFacet.createTicket(td, _getZeroFeeType(), _getZeroFee()); + } + + function test_createTicket_revertsPurchaseStartTimeTooLate() public { + TicketData memory td = _getFreeTicketData(); + // purchaseStartTime must be <= startTime - 1 day + td.purchaseStartTime = td.startTime; + vm.expectRevert(PurchaseStartTimeShouldBeOneDayBeforeStartTime.selector); + factoryFacet.createTicket(td, _getZeroFeeType(), _getZeroFee()); + } + + function test_createPaidTicket_revertsArrayMismatch() public { + TicketData memory td = _getPaidTicketData(); + // Empty feeTypes but paid ticket + vm.expectRevert(ArrayMismatch.selector); + factoryFacet.createTicket(td, _getZeroFeeType(), _getZeroFee()); + } + + function test_createPaidTicket_revertsArrayLengthMismatch() public { + TicketData memory td = _getPaidTicketData(); + FeeType[] memory feeTypes = new FeeType[](2); + feeTypes[0] = FeeType.ETH; + feeTypes[1] = FeeType.USDT; + uint256[] memory fees = new uint256[](1); + fees[0] = ETH_FEE; + vm.expectRevert(ArrayMismatch.selector); + factoryFacet.createTicket(td, feeTypes, fees); + } + + function test_createPaidTicket_revertsZeroFee() public { + TicketData memory td = _getPaidTicketData(); + FeeType[] memory feeTypes = new FeeType[](1); + feeTypes[0] = FeeType.ETH; + uint256[] memory fees = new uint256[](1); + fees[0] = 0; + vm.expectRevert(abi.encodeWithSelector(ZeroFee.selector, FeeType.ETH)); + factoryFacet.createTicket(td, feeTypes, fees); + } + + function test_createPaidTicket_revertsDuplicateFeeType() public { + TicketData memory td = _getPaidTicketData(); + FeeType[] memory feeTypes = new FeeType[](2); + feeTypes[0] = FeeType.ETH; + feeTypes[1] = FeeType.ETH; + uint256[] memory fees = new uint256[](2); + fees[0] = ETH_FEE; + fees[1] = ETH_FEE; + vm.expectRevert(abi.encodeWithSelector(FeeAlreadySet.selector, FeeType.ETH)); + factoryFacet.createTicket(td, feeTypes, fees); + } + + function test_createTicket_revertsNonAdminCannotUpdate() public { + _createFreeTicket(); + uint64 ticketId = factoryFacet.ticketCount(); + TicketData memory td = _getFreeUpdatedTicketData(); + vm.prank(alice); + vm.expectRevert(); + factoryFacet.updateTicket(td, ticketId); + } + + function test_updateTicket_revertsTicketDoesNotExist() public { + TicketData memory td = _getFreeUpdatedTicketData(); + vm.expectRevert(abi.encodeWithSelector(TicketDoesNotExist.selector, uint64(999))); + factoryFacet.updateTicket(td, 999); + } + + function test_updateTicket_revertsEndTimeTooClose() public { + _createFreeTicket(); + uint64 ticketId = factoryFacet.ticketCount(); + TicketData memory td = _getFreeUpdatedTicketData(); + td.endTime = td.startTime; // Less than startTime + 1 day + vm.expectRevert(EndTimeShouldBeOneDayAfterStartTime.selector); + factoryFacet.updateTicket(td, ticketId); + } + + function test_updateTicket_revertsPurchaseStartTimeTooLate() public { + _createFreeTicket(); + uint64 ticketId = factoryFacet.ticketCount(); + TicketData memory td = _getFreeUpdatedTicketData(); + td.purchaseStartTime = td.startTime; // Must be <= startTime - 1 day + vm.expectRevert(PurchaseStartTimeShouldBeOneDayBeforeStartTime.selector); + factoryFacet.updateTicket(td, ticketId); + } + + function test_updateTicket_revertsMaxTicketsBelowSupply() public { + _createFreeTicket(); + uint64 ticketId = factoryFacet.ticketCount(); + // Mint a ticket so supply > 0 + marketplaceFacet.mintTicket(ticketId, FeeType.NONE, alice); + // Try to set maxTickets to 0 (below supply of 1) + // maxTickets > 0 triggers the check, and totalSupply() == 1 + TicketData memory td = _getFreeUpdatedTicketData(); + // We need maxTickets > 0 but < totalSupply which is impossible with uint40 + // Let's mint to get supply then try to lower max + // Actually we need maxTickets < totalSupply. Mint one, then update maxTickets to... well 0 won't trigger the branch. + // The condition is: _ticketData.maxTickets > 0 AND _ticketData.maxTickets < ticket.totalSupply() + // We can't hit this since totalSupply=1 and maxTickets must be uint40 >= 1 + // BUT if we mint multiple... + marketplaceFacet.mintTicket(ticketId, FeeType.NONE, bob); + // Now totalSupply = 2. Setting maxTickets to 1 should revert + td.maxTickets = 1; + vm.expectRevert(MaxTicketsShouldEqualSupply.selector); + factoryFacet.updateTicket(td, ticketId); + } + + function test_ticketData_revertsTicketDoesNotExist() public { + vm.expectRevert(abi.encodeWithSelector(TicketDoesNotExist.selector, uint64(999))); + factoryFacet.ticketData(999); + } + //*////////////////////////////////////////////////////////////////////////// // FUZZ TESTS //////////////////////////////////////////////////////////////////////////*// - // /// forge-config: default.fuzz.runs = 20 - // /// forge-config: default.fuzz.max-test-rejects = 100_000_000 - // function test_fuzz_createTicket(TicketData memory _ticketData) public { - // vm.assume(_ticketData.endTime > bound(_ticketData.startTime, 0, type(uint48).max - 2 days)); - // bound(_ticketData.purchaseStartTime, 0, _currentTime); - // bound(_ticketData.maxTickets, 0, type(uint8).max); - // factoryFacet.createTicket(_ticketData, _getFeeTypes(), _getFees()); - // } - - // /// forge-config: default.fuzz.runs = 256 - // /// forge-config: default.fuzz.max-test-rejects = 4_000_000 - // function test_fuzz_updateTicket(TicketData memory _ticketData) public { - // bound(_ticketData.startTime, 0, type(uint48).max - 2 days); - // vm.assume(_ticketData.endTime == type(uint48).max - 1 days); - // bound(_ticketData.purchaseStartTime, 0, _currentTime); - // bound(_ticketData.maxTickets, 0, type(uint8).max); - // _createPaidTicket(); - // factoryFacet.updateTicket(_ticketData, 1); - // } + function testFuzz_createTicket_validTimes(uint48 startOffset, uint48 duration) public { + startOffset = uint48(bound(startOffset, 2 days, 365 days)); + duration = uint48(bound(duration, 1 days, 365 days)); + + uint48 startTime = uint48(block.timestamp) + startOffset; + uint48 endTime = startTime + duration; + uint48 purchaseStartTime = startTime - uint48(1 days); + + TicketData memory td = TicketData({ + startTime: startTime, + endTime: endTime, + purchaseStartTime: purchaseStartTime, + maxTickets: type(uint40).max, + maxTicketsPerUser: 0, + isFree: true, + isRefundable: false, + name: "Fuzz Ticket", + symbol: "", + uri: "ipfs://fuzz" + }); + + factoryFacet.createTicket(td, _getZeroFeeType(), _getZeroFee()); + uint64 ticketId = factoryFacet.ticketCount(); + assertTrue(factoryFacet.ticketExists(ticketId)); + + FullTicketData memory ftd = factoryFacet.ticketData(ticketId); + assertEq(ftd.startTime, startTime); + assertEq(ftd.endTime, endTime); + assertEq(ftd.purchaseStartTime, purchaseStartTime); + } + + function testFuzz_createTicket_revertsStartTimeInPast(uint48 offset) public { + offset = uint48(bound(offset, 1, uint48(block.timestamp))); + uint48 startTime = uint48(block.timestamp) - offset; + + TicketData memory td = TicketData({ + startTime: startTime, + endTime: startTime + uint48(2 days), + purchaseStartTime: 0, + maxTickets: type(uint40).max, + maxTicketsPerUser: 0, + isFree: true, + isRefundable: false, + name: "Bad Ticket", + symbol: "", + uri: "ipfs://bad" + }); + + vm.expectRevert(StartTimeShouldBeAhead.selector); + factoryFacet.createTicket(td, _getZeroFeeType(), _getZeroFee()); + } + + function testFuzz_createTicket_revertsEndTimeTooClose(uint48 gap) public { + gap = uint48(bound(gap, 0, 1 days - 1)); + uint48 startTime = uint48(block.timestamp + 2 days); + uint48 endTime = startTime + gap; + + TicketData memory td = TicketData({ + startTime: startTime, + endTime: endTime, + purchaseStartTime: uint48(block.timestamp), + maxTickets: type(uint40).max, + maxTicketsPerUser: 0, + isFree: true, + isRefundable: false, + name: "Bad Ticket", + symbol: "", + uri: "ipfs://bad" + }); + + vm.expectRevert(EndTimeShouldBeOneDayAfterStartTime.selector); + factoryFacet.createTicket(td, _getZeroFeeType(), _getZeroFee()); + } + + function testFuzz_ticketHash(uint64 ticketId) public view { + bytes32 ticketHash = factoryFacet.ticketHash(ticketId); + assertEq(ticketHash, keccak256(abi.encode(keccak256("host.it.ticket"), ticketId))); + } + + function testFuzz_mainAdminRole(uint64 ticketId) public view { + uint256 mainAdminRole = factoryFacet.mainAdminRole(ticketId); + assertEq(mainAdminRole, uint256(keccak256(abi.encode(keccak256("host.it.ticket.main.admin"), ticketId)))); + } + + function testFuzz_ticketAdminRole(uint64 ticketId) public view { + uint256 ticketAdminRole = factoryFacet.ticketAdminRole(ticketId); + assertEq(ticketAdminRole, uint256(keccak256(abi.encode(keccak256("host.it.ticket.admin"), ticketId)))); + } + + function testFuzz_ticketExists(uint64 ticketId) public { + _createFreeTicket(); + if (ticketId == 1) { + assertTrue(factoryFacet.ticketExists(ticketId)); + } else { + assertFalse(factoryFacet.ticketExists(ticketId)); + } + } } diff --git a/test/Marketplace.t.sol b/test/Marketplace.t.sol index 72c131c..8a195f0 100644 --- a/test/Marketplace.t.sol +++ b/test/Marketplace.t.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.30; import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; -import {FullTicketData} from "@ticket-storage/FactoryStorage.sol"; +import {FullTicketData, TicketData} from "@ticket-storage/FactoryStorage.sol"; import {FeeType} from "@ticket-storage/MarketplaceStorage.sol"; import {DeployedHostItTickets} from "@ticket-test/states/DeployedHostItTickets.sol"; import {ITicket} from "@ticket/interfaces/ITicket.sol"; @@ -562,6 +562,145 @@ contract MarketplaceTest is DeployedHostItTickets, ERC721Holder { marketplaceFacet.mintTicket{value: 1 ether}(ticketId, FeeType.WETH, alice); } + // ====================================================================== + // TICKET SOLD OUT + // ====================================================================== + + function test_mintTicket_revertsTicketSoldOut() public { + TicketData memory td = _getFreeTicketData(); + td.maxTickets = 1; + factoryFacet.createTicket(td, _getZeroFeeType(), _getZeroFee()); + uint64 ticketId = factoryFacet.ticketCount(); + marketplaceFacet.mintTicket(ticketId, FeeType.NONE, alice); + vm.expectRevert(TicketSoldOut.selector); + marketplaceFacet.mintTicket(ticketId, FeeType.NONE, bob); + } + + // ====================================================================== + // MAX TICKETS PER USER + // ====================================================================== + + function test_mintTicket_revertsMaxTicketsHeld() public { + TicketData memory td = _getFreeTicketData(); + td.maxTicketsPerUser = 1; + factoryFacet.createTicket(td, _getZeroFeeType(), _getZeroFee()); + uint64 ticketId = factoryFacet.ticketCount(); + // Mint first ticket (balance becomes 1, check is > maxTicketsPerUser so 1 > 1 = false, ok) + marketplaceFacet.mintTicket(ticketId, FeeType.NONE, alice); + // Second mint: balance is 1, but then check is 1 > 1 = false. Need to check logic... + // Actually the check is: if (ticket.balanceOf(_buyer) > ticketData.maxTicketsPerUser) revert + // After first mint, balance = 1. maxTicketsPerUser = 1. 1 > 1 = false. So need to mint again. + marketplaceFacet.mintTicket(ticketId, FeeType.NONE, alice); + // Now balance = 2. 2 > 1 = true. Revert. + vm.expectRevert(MaxTicketsHeld.selector); + marketplaceFacet.mintTicket(ticketId, FeeType.NONE, alice); + } + + // ====================================================================== + // PURCHASE TIME CHECKS + // ====================================================================== + + function test_mintTicket_revertsPurchaseTimeNotReached() public { + TicketData memory td = _getFreeTicketData(); + td.purchaseStartTime = uint48(block.timestamp + 1 days); + td.startTime = uint48(block.timestamp + 2 days); + td.endTime = uint48(block.timestamp + 3 days); + factoryFacet.createTicket(td, _getZeroFeeType(), _getZeroFee()); + uint64 ticketId = factoryFacet.ticketCount(); + vm.expectRevert(PurchaseTimeNotReached.selector); + marketplaceFacet.mintTicket(ticketId, FeeType.NONE, alice); + } + + function test_mintTicket_revertsAfterEventEnded() public { + _createFreeTicket(); + uint64 ticketId = factoryFacet.ticketCount(); + FullTicketData memory ftd = factoryFacet.ticketData(ticketId); + vm.warp(ftd.endTime + 1); + vm.expectRevert(PurchaseTimeNotReached.selector); + marketplaceFacet.mintTicket(ticketId, FeeType.NONE, alice); + } + + // ====================================================================== + // SET TICKET FEES: REVERT CASES + // ====================================================================== + + function test_setTicketFees_revertsZeroFee() public { + _createFreeTicket(); + uint64 ticketId = factoryFacet.ticketCount(); + FeeType[] memory feeTypes = new FeeType[](1); + feeTypes[0] = FeeType.ETH; + uint256[] memory fees = new uint256[](1); + fees[0] = 0; + vm.expectRevert(ZeroFee.selector); + marketplaceFacet.setTicketFees(ticketId, feeTypes, fees); + } + + function test_setTicketFees_revertsFeeAlreadySet() public { + _createFreeTicket(); + uint64 ticketId = factoryFacet.ticketCount(); + FeeType[] memory feeTypes = new FeeType[](1); + feeTypes[0] = FeeType.ETH; + uint256[] memory fees = new uint256[](1); + fees[0] = ETH_FEE; + marketplaceFacet.setTicketFees(ticketId, feeTypes, fees); + vm.expectRevert(FeeAlreadySet.selector); + marketplaceFacet.setTicketFees(ticketId, feeTypes, fees); + } + + function test_setTicketFees_revertsInvalidFeeConfig() public { + _createFreeTicket(); + uint64 ticketId = factoryFacet.ticketCount(); + FeeType[] memory feeTypes = new FeeType[](2); + feeTypes[0] = FeeType.ETH; + feeTypes[1] = FeeType.USDT; + uint256[] memory fees = new uint256[](1); + fees[0] = ETH_FEE; + vm.expectRevert(InvalidFeeConfig.selector); + marketplaceFacet.setTicketFees(ticketId, feeTypes, fees); + } + + function test_setTicketFees_revertsNonAdmin() public { + _createFreeTicket(); + uint64 ticketId = factoryFacet.ticketCount(); + vm.prank(alice); + vm.expectRevert(); + marketplaceFacet.setTicketFees(ticketId, _getFeeTypes(), _getFees()); + } + + // ====================================================================== + // WITHDRAW TO CONTRACT REVERTS + // ====================================================================== + + function test_withdrawTicketBalance_revertsContractNotAllowed() public { + (uint64 ticketId,,,) = _mintTicketETHRefundable(); + FullTicketData memory ftd = factoryFacet.ticketData(ticketId); + vm.warp(ftd.endTime + marketplaceFacet.getRefundPeriod()); + vm.expectRevert(ContractNotAllowed.selector); + marketplaceFacet.withdrawTicketBalance(ticketId, FeeType.ETH, hostIt); + } + + function test_withdrawHostItBalance_revertsContractNotAllowed() public { + _mintTicketETH(); + vm.expectRevert(ContractNotAllowed.selector); + marketplaceFacet.withdrawHostItBalance(FeeType.ETH, hostIt); + } + + function test_withdrawHostItBalance_revertsNonOwner() public { + _mintTicketETH(); + vm.prank(alice); + vm.expectRevert(); + marketplaceFacet.withdrawHostItBalance(FeeType.ETH, withdrawer); + } + + // ====================================================================== + // TICKET DOES NOT EXIST + // ====================================================================== + + function test_mintTicket_revertsTicketDoesNotExist() public { + vm.expectRevert(); + marketplaceFacet.mintTicket(999, FeeType.NONE, alice); + } + // ====================================================================== // MIXED: REFUNDABLE + NON-REFUNDABLE HOSTIT ACCUMULATION // ====================================================================== @@ -575,5 +714,320 @@ contract MarketplaceTest is DeployedHostItTickets, ERC721Holder { assertEq(marketplaceFacet.getHostItBalance(FeeType.ETH), hostItFee1 + hostItFee2); } + // ====================================================================== + // FUZZ TESTS + // ====================================================================== + + function testFuzz_hostItFeeCalculation(uint256 fee) public view { + fee = bound(fee, 0, type(uint256).max / 300); + uint256 hostItFee = marketplaceFacet.getHostItFee(fee); + assertEq(hostItFee, (fee * 300) / 10_000); + } + + function testFuzz_totalFeeIsSumOfParts(uint256 fee) public { + fee = bound(fee, 1, 1e30); + + _createFreeTicket(); + uint64 ticketId = factoryFacet.ticketCount(); + + FeeType[] memory feeTypes = new FeeType[](1); + feeTypes[0] = FeeType.ETH; + uint256[] memory fees = new uint256[](1); + fees[0] = fee; + marketplaceFacet.setTicketFees(ticketId, feeTypes, fees); + + (uint256 ticketFee, uint256 hostItFee, uint256 totalFee) = marketplaceFacet.getAllFees(ticketId, FeeType.ETH); + assertEq(ticketFee, fee); + assertEq(hostItFee, (fee * 300) / 10_000); + assertEq(totalFee, ticketFee + hostItFee); + } + + function testFuzz_directPayment_ETH_accounting(uint256 fee) public { + fee = bound(fee, 1, 1e24); + + TicketData memory td = _getPaidTicketData(); + FeeType[] memory feeTypes = new FeeType[](1); + feeTypes[0] = FeeType.ETH; + uint256[] memory fees = new uint256[](1); + fees[0] = fee; + factoryFacet.createTicket(td, feeTypes, fees); + uint64 ticketId = factoryFacet.ticketCount(); + + (uint256 ticketFee, uint256 hostItFee, uint256 totalFee) = marketplaceFacet.getAllFees(ticketId, FeeType.ETH); + + uint256 ownerBalBefore = owner.balance; + uint256 contractBalBefore = hostIt.balance; + + hoax(alice, totalFee); + marketplaceFacet.mintTicket{value: totalFee}(ticketId, FeeType.ETH, alice); + + assertEq(owner.balance - ownerBalBefore, ticketFee); + assertEq(hostIt.balance - contractBalBefore, hostItFee); + assertEq(marketplaceFacet.getHostItBalance(FeeType.ETH), hostItFee); + assertEq(marketplaceFacet.getTicketBalance(ticketId, FeeType.ETH), 0); + assertEq(alice.balance, 0); + } + + function testFuzz_directPayment_USDT_accounting(uint256 fee) public { + fee = bound(fee, 1, 1e30); + + TicketData memory td = _getPaidTicketData(); + FeeType[] memory feeTypes = new FeeType[](1); + feeTypes[0] = FeeType.USDT; + uint256[] memory fees = new uint256[](1); + fees[0] = fee; + factoryFacet.createTicket(td, feeTypes, fees); + uint64 ticketId = factoryFacet.ticketCount(); + + (uint256 ticketFee, uint256 hostItFee, uint256 totalFee) = marketplaceFacet.getAllFees(ticketId, FeeType.USDT); + ERC20Mock usdt = ERC20Mock(marketplaceFacet.getFeeTokenAddress(FeeType.USDT)); + usdt.mint(alice, totalFee); + + vm.prank(alice); + usdt.approve(address(marketplaceFacet), totalFee); + vm.prank(alice); + marketplaceFacet.mintTicket(ticketId, FeeType.USDT, alice); + + assertEq(usdt.balanceOf(owner), ticketFee); + assertEq(usdt.balanceOf(hostIt), hostItFee); + assertEq(marketplaceFacet.getHostItBalance(FeeType.USDT), hostItFee); + assertEq(marketplaceFacet.getTicketBalance(ticketId, FeeType.USDT), 0); + assertEq(usdt.balanceOf(alice), 0); + } + + function testFuzz_refundable_ETH_escrow(uint256 fee) public { + fee = bound(fee, 1, 1e24); + + TicketData memory td = _getRefundablePaidTicketData(); + FeeType[] memory feeTypes = new FeeType[](1); + feeTypes[0] = FeeType.ETH; + uint256[] memory fees = new uint256[](1); + fees[0] = fee; + factoryFacet.createTicket(td, feeTypes, fees); + uint64 ticketId = factoryFacet.ticketCount(); + + (uint256 ticketFee, uint256 hostItFee, uint256 totalFee) = marketplaceFacet.getAllFees(ticketId, FeeType.ETH); + + uint256 ownerBalBefore = owner.balance; + uint256 contractBalBefore = hostIt.balance; + + hoax(alice, totalFee); + marketplaceFacet.mintTicket{value: totalFee}(ticketId, FeeType.ETH, alice); + + assertEq(owner.balance, ownerBalBefore); + assertEq(hostIt.balance - contractBalBefore, ticketFee + hostItFee); + assertEq(marketplaceFacet.getTicketBalance(ticketId, FeeType.ETH), ticketFee); + assertEq(marketplaceFacet.getHostItBalance(FeeType.ETH), hostItFee); + } + + function testFuzz_refundable_ETH_claimWithinWindow(uint256 warpOffset) public { + (uint64 ticketId, uint40 tokenId, uint256 fee, uint256 hostItFee) = _mintTicketETHRefundable(); + FullTicketData memory ftd = factoryFacet.ticketData(ticketId); + ITicket ticket = ITicket(ftd.ticketAddress); + + vm.prank(alice); + ticket.approve(hostIt, tokenId); + + uint256 refundPeriod = marketplaceFacet.getRefundPeriod(); + warpOffset = bound(warpOffset, 0, refundPeriod); + vm.warp(ftd.endTime + warpOffset); + + vm.prank(alice); + marketplaceFacet.claimRefund(ticketId, FeeType.ETH, tokenId, bob); + + assertEq(bob.balance, fee); + assertEq(marketplaceFacet.getTicketBalance(ticketId, FeeType.ETH), 0); + assertEq(marketplaceFacet.getHostItBalance(FeeType.ETH), hostItFee); + } + + function testFuzz_refundable_ETH_claimRevertsBeforeEndTime(uint256 warpTo) public { + (uint64 ticketId, uint40 tokenId,,) = _mintTicketETHRefundable(); + FullTicketData memory ftd = factoryFacet.ticketData(ticketId); + ITicket ticket = ITicket(ftd.ticketAddress); + + vm.prank(alice); + ticket.approve(hostIt, tokenId); + + warpTo = bound(warpTo, block.timestamp, ftd.endTime - 1); + vm.warp(warpTo); + + vm.prank(alice); + vm.expectRevert(RefundPeriodNotReached.selector); + marketplaceFacet.claimRefund(ticketId, FeeType.ETH, tokenId, bob); + } + + function testFuzz_refundable_ETH_claimRevertsAfterWindow(uint256 extraTime) public { + (uint64 ticketId, uint40 tokenId,,) = _mintTicketETHRefundable(); + FullTicketData memory ftd = factoryFacet.ticketData(ticketId); + ITicket ticket = ITicket(ftd.ticketAddress); + + vm.prank(alice); + ticket.approve(hostIt, tokenId); + + uint256 refundPeriod = marketplaceFacet.getRefundPeriod(); + extraTime = bound(extraTime, 1, 365 days); + vm.warp(ftd.endTime + refundPeriod + extraTime); + + vm.prank(alice); + vm.expectRevert(RefundPeriodExpired.selector); + marketplaceFacet.claimRefund(ticketId, FeeType.ETH, tokenId, bob); + } + + function testFuzz_refundable_ETH_withdrawAfterRefundPeriod(uint256 extraTime) public { + (uint64 ticketId,, uint256 fee,) = _mintTicketETHRefundable(); + FullTicketData memory ftd = factoryFacet.ticketData(ticketId); + + uint256 refundPeriod = marketplaceFacet.getRefundPeriod(); + extraTime = bound(extraTime, 0, 365 days); + vm.warp(ftd.endTime + refundPeriod + extraTime); + + marketplaceFacet.withdrawTicketBalance(ticketId, FeeType.ETH, withdrawer); + assertEq(withdrawer.balance, fee); + assertEq(marketplaceFacet.getTicketBalance(ticketId, FeeType.ETH), 0); + } + + function testFuzz_refundable_ETH_withdrawRevertsBeforeRefundPeriod(uint256 warpTo) public { + (uint64 ticketId,,,) = _mintTicketETHRefundable(); + FullTicketData memory ftd = factoryFacet.ticketData(ticketId); + + uint256 refundPeriod = marketplaceFacet.getRefundPeriod(); + warpTo = bound(warpTo, block.timestamp, ftd.endTime + refundPeriod - 1); + vm.warp(warpTo); + + vm.expectRevert(WithdrawPeriodNotReached.selector); + marketplaceFacet.withdrawTicketBalance(ticketId, FeeType.ETH, withdrawer); + } + + function testFuzz_directPayment_ETH_multipleBuyersAccumulate(uint8 buyerCount) public { + buyerCount = uint8(bound(buyerCount, 1, 20)); + + _createPaidTicket(); + uint64 ticketId = factoryFacet.ticketCount(); + (uint256 ticketFee, uint256 hostItFee, uint256 totalFee) = marketplaceFacet.getAllFees(ticketId, FeeType.ETH); + + uint256 ownerBalBefore = owner.balance; + + for (uint8 i; i < buyerCount; ++i) { + address buyer = makeAddr(string(abi.encodePacked("buyer", i))); + hoax(buyer, totalFee); + marketplaceFacet.mintTicket{value: totalFee}(ticketId, FeeType.ETH, buyer); + } + + assertEq(marketplaceFacet.getHostItBalance(FeeType.ETH), hostItFee * buyerCount); + assertEq(owner.balance - ownerBalBefore, ticketFee * buyerCount); + + FullTicketData memory ftd = factoryFacet.ticketData(ticketId); + assertEq(ftd.soldTickets, buyerCount); + } + + function testFuzz_directPayment_ETH_revertsInsufficientValue(uint256 underpay) public { + _createPaidTicket(); + uint64 ticketId = factoryFacet.ticketCount(); + (,, uint256 totalFee) = marketplaceFacet.getAllFees(ticketId, FeeType.ETH); + + underpay = bound(underpay, 1, totalFee); + uint256 sent = totalFee - underpay; + + hoax(alice, totalFee); + vm.expectRevert(abi.encodeWithSelector(TicketPurchaseFailed.selector, FeeType.ETH, totalFee)); + marketplaceFacet.mintTicket{value: sent}(ticketId, FeeType.ETH, alice); + } + + function testFuzz_withdrawHostItBalance_ETH(uint256 fee) public { + // fee must be >= 34 so hostItFee = fee * 300 / 10_000 > 0 + fee = bound(fee, 34, 1e24); + + TicketData memory td = _getPaidTicketData(); + FeeType[] memory feeTypes = new FeeType[](1); + feeTypes[0] = FeeType.ETH; + uint256[] memory fees = new uint256[](1); + fees[0] = fee; + factoryFacet.createTicket(td, feeTypes, fees); + uint64 ticketId = factoryFacet.ticketCount(); + + (, uint256 hostItFee, uint256 totalFee) = marketplaceFacet.getAllFees(ticketId, FeeType.ETH); + + hoax(alice, totalFee); + marketplaceFacet.mintTicket{value: totalFee}(ticketId, FeeType.ETH, alice); + + marketplaceFacet.withdrawHostItBalance(FeeType.ETH, withdrawer); + assertEq(withdrawer.balance, hostItFee); + assertEq(marketplaceFacet.getHostItBalance(FeeType.ETH), 0); + } + + function testFuzz_refundable_ETH_fullLifecycle(uint256 fee, uint256 refundOffset) public { + fee = bound(fee, 1, 1e24); + uint256 refundPeriod = marketplaceFacet.getRefundPeriod(); + refundOffset = bound(refundOffset, 0, refundPeriod); + + TicketData memory td = _getRefundablePaidTicketData(); + FeeType[] memory feeTypes = new FeeType[](1); + feeTypes[0] = FeeType.ETH; + uint256[] memory fees = new uint256[](1); + fees[0] = fee; + factoryFacet.createTicket(td, feeTypes, fees); + uint64 ticketId = factoryFacet.ticketCount(); + + (uint256 ticketFee, uint256 hostItFee, uint256 totalFee) = marketplaceFacet.getAllFees(ticketId, FeeType.ETH); + FullTicketData memory ftd = factoryFacet.ticketData(ticketId); + ITicket ticket = ITicket(ftd.ticketAddress); + + hoax(alice, totalFee); + uint40 tokenId = marketplaceFacet.mintTicket{value: totalFee}(ticketId, FeeType.ETH, alice); + assertEq(marketplaceFacet.getTicketBalance(ticketId, FeeType.ETH), ticketFee); + + vm.prank(alice); + ticket.approve(hostIt, tokenId); + vm.warp(ftd.endTime + refundOffset); + + vm.prank(alice); + marketplaceFacet.claimRefund(ticketId, FeeType.ETH, tokenId, bob); + + assertEq(bob.balance, ticketFee); + assertEq(marketplaceFacet.getTicketBalance(ticketId, FeeType.ETH), 0); + assertEq(marketplaceFacet.getHostItBalance(FeeType.ETH), hostItFee); + assertEq(ticket.ownerOf(tokenId), owner); + } + + function testFuzz_refundable_ETH_multipleBuyersPartialRefund(uint8 buyerCount, uint8 refundCount) public { + buyerCount = uint8(bound(buyerCount, 2, 10)); + refundCount = uint8(bound(refundCount, 1, buyerCount - 1)); + + TicketData memory td = _getRefundablePaidTicketData(); + FeeType[] memory feeTypes = new FeeType[](1); + feeTypes[0] = FeeType.ETH; + uint256[] memory fees = new uint256[](1); + fees[0] = ETH_FEE; + factoryFacet.createTicket(td, feeTypes, fees); + uint64 ticketId = factoryFacet.ticketCount(); + + (uint256 ticketFee, uint256 hostItFee, uint256 totalFee) = marketplaceFacet.getAllFees(ticketId, FeeType.ETH); + FullTicketData memory ftd = factoryFacet.ticketData(ticketId); + ITicket ticket = ITicket(ftd.ticketAddress); + + address[] memory buyers = new address[](buyerCount); + uint40[] memory tokenIds = new uint40[](buyerCount); + for (uint8 i; i < buyerCount; ++i) { + buyers[i] = makeAddr(string(abi.encodePacked("rbuyer", i))); + hoax(buyers[i], totalFee); + tokenIds[i] = marketplaceFacet.mintTicket{value: totalFee}(ticketId, FeeType.ETH, buyers[i]); + } + + assertEq(marketplaceFacet.getTicketBalance(ticketId, FeeType.ETH), ticketFee * buyerCount); + + vm.warp(ftd.endTime); + + for (uint8 i; i < refundCount; ++i) { + vm.prank(buyers[i]); + ticket.approve(hostIt, tokenIds[i]); + vm.prank(buyers[i]); + marketplaceFacet.claimRefund(ticketId, FeeType.ETH, tokenIds[i], buyers[i]); + } + + uint256 remaining = uint256(buyerCount - refundCount) * ticketFee; + assertEq(marketplaceFacet.getTicketBalance(ticketId, FeeType.ETH), remaining); + assertEq(marketplaceFacet.getHostItBalance(FeeType.ETH), hostItFee * buyerCount); + } + receive() external payable {} } diff --git a/test/Ticket.t.sol b/test/Ticket.t.sol index 48975ca..1d47820 100644 --- a/test/Ticket.t.sol +++ b/test/Ticket.t.sol @@ -1,10 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity 0.8.30; +import {IERC2981} from "@openzeppelin/contracts/interfaces/IERC2981.sol"; +import {IERC721Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {IERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Enumerable.sol"; import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import {ITicket} from "@ticket/interfaces/ITicket.sol"; import {Ticket} from "@ticket/libs/Ticket.sol"; import {Test} from "forge-std/Test.sol"; @@ -146,4 +150,147 @@ contract TicketTest is Test { assertEq(ticketClone2.baseURI(), "ipfs://2"); assertNotEq(address(ticketClone), address(ticketClone2)); } + + // ====================================================================== + // COVERAGE TESTS + // ====================================================================== + + function test_tokenURI_revertsNonExistentToken() public { + vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721NonexistentToken.selector, uint256(999))); + ticketClone.tokenURI(999); + } + + function test_supportsInterface_ERC721() public view { + assertTrue(ticketClone.supportsInterface(type(IERC721).interfaceId)); + } + + function test_supportsInterface_ERC721Enumerable() public view { + assertTrue(ticketClone.supportsInterface(type(IERC721Enumerable).interfaceId)); + } + + function test_supportsInterface_ERC2981() public view { + assertTrue(ticketClone.supportsInterface(type(IERC2981).interfaceId)); + } + + function test_supportsInterface_ERC165() public view { + assertTrue(ticketClone.supportsInterface(type(IERC165).interfaceId)); + } + + function test_supportsInterface_invalid() public view { + assertFalse(ticketClone.supportsInterface(0xffffffff)); + } + + function test_royaltyInfo() public { + uint256 tokenId = ticketClone.mint(alice); + (address receiver, uint256 royalty) = ticketClone.royaltyInfo(tokenId, 10_000); + assertEq(receiver, owner); + assertEq(royalty, 500); // 5% of 10_000 + } + + function test_defaultSymbol() public view { + assertEq(ticketClone.symbol(), "TICKET"); + } + + function test_initializeWithCustomSymbol() public { + Ticket clone2 = Ticket(address(ticketImpl).clone()); + clone2.initialize(owner, "Test", "CUSTOM", "ipfs://"); + assertEq(clone2.symbol(), "CUSTOM"); + } + + function test_revertDoubleInitialize() public { + vm.expectRevert(); + ticketClone.initialize(owner, "Again", "", "ipfs://"); + } + + function test_mintRevertsNonOwner() public { + vm.prank(alice); + vm.expectRevert(); + ticketClone.mint(alice); + } + + function test_pauseRevertsNonOwner() public { + vm.prank(alice); + vm.expectRevert(); + ticketClone.pause(); + } + + function test_unpauseRevertsNonOwner() public { + ticketClone.pause(); + vm.prank(alice); + vm.expectRevert(); + ticketClone.unpause(); + } + + function test_updateNameRevertsNonOwner() public { + vm.prank(alice); + vm.expectRevert(); + ticketClone.updateName("Hack"); + } + + function test_updateSymbolRevertsNonOwner() public { + vm.prank(alice); + vm.expectRevert(); + ticketClone.updateSymbol("HACK"); + } + + function test_updateURIRevertsNonOwner() public { + vm.prank(alice); + vm.expectRevert(); + ticketClone.updateURI("ipfs://hack"); + } + + function test_totalSupplyAndTokenByIndex() public { + ticketClone.mint(alice); + ticketClone.mint(bob); + assertEq(ticketClone.totalSupply(), 2); + assertEq(ticketClone.tokenByIndex(0), 1); + assertEq(ticketClone.tokenByIndex(1), 2); + } + + function test_tokenOfOwnerByIndex() public { + ticketClone.mint(alice); + ticketClone.mint(alice); + assertEq(ticketClone.tokenOfOwnerByIndex(alice, 0), 1); + assertEq(ticketClone.tokenOfOwnerByIndex(alice, 1), 2); + } + + // ====================================================================== + // FUZZ TESTS + // ====================================================================== + + function testFuzz_mint(uint8 count) public { + count = uint8(bound(count, 1, 50)); + for (uint8 i; i < count; ++i) { + ticketClone.mint(alice); + } + assertEq(ticketClone.totalSupply(), count); + assertEq(ticketClone.balanceOf(alice), count); + } + + function testFuzz_updateName(string calldata name) public { + vm.assume(bytes(name).length > 0); + ticketClone.updateName(name); + assertEq(ticketClone.name(), name); + } + + function testFuzz_updateSymbol(string calldata symbol) public { + ticketClone.updateSymbol(symbol); + assertEq(ticketClone.symbol(), symbol); + } + + function testFuzz_updateURI(string calldata uri) public { + ticketClone.updateURI(uri); + assertEq(ticketClone.baseURI(), uri); + } + + /// forge-lint: disable-next-item(erc20-unchecked-transfer) + function testFuzz_transferBetweenAddresses(address to) public { + vm.assume(to != address(0) && to.code.length == 0 && to != alice); + uint256 tokenId = ticketClone.mint(alice); + vm.prank(alice); + ticketClone.transferFrom(alice, to, tokenId); + assertEq(ticketClone.ownerOf(tokenId), to); + assertEq(ticketClone.balanceOf(to), 1); + assertEq(ticketClone.balanceOf(alice), 0); + } }