Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
310 changes: 310 additions & 0 deletions test/StoryFactory.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {Test} from "forge-std/Test.sol";
import {StoryFactory} from "../src/StoryFactory.sol";
import {IMCV2_Bond, TokenParams, BondParams} from "../src/interfaces/IMCV2_Bond.sol";
import {IERC20} from "../src/interfaces/IERC20.sol";

// ---------------------------------------------------------------------------
// Mock MCV2_Bond — returns a deterministic token address per call
// ---------------------------------------------------------------------------
contract MockBond is IMCV2_Bond {
uint256 public createCount;
address public lastCreator;

function createToken(TokenParams calldata, BondParams calldata) external payable returns (address) {
createCount++;
// Deterministic fake token address
return address(uint160(0xBEEF0000 + createCount));
}

function updateBondCreator(address, address creator) external {
lastCreator = creator;
}

function mint(address, uint256, uint256, address) external pure returns (uint256) {
return 0;
}

function burn(address, uint256, uint256, address) external pure returns (uint256) {
return 0;
}

function getReserveForToken(address, uint256) external pure returns (uint256, uint256) {
return (0, 0);
}

function getRefundForTokens(address, uint256) external pure returns (uint256, uint256) {
return (0, 0);
}

function claimRoyalties(address) external {}
}

// ---------------------------------------------------------------------------
// Mock ERC-20 ($PLOT)
// ---------------------------------------------------------------------------
contract MockPlot is IERC20 {
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;

function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
}

function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
return true;
}

function transfer(address to, uint256 amount) external returns (bool) {
require(balanceOf[msg.sender] >= amount, "Insufficient balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}

function transferFrom(address from, address to, uint256 amount) external returns (bool) {
require(balanceOf[from] >= amount, "Insufficient balance");
require(allowance[from][msg.sender] >= amount, "Insufficient allowance");
balanceOf[from] -= amount;
balanceOf[to] += amount;
allowance[from][msg.sender] -= amount;
return true;
}
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
contract StoryFactoryTest is Test {
StoryFactory public factory;
MockBond public bond;
MockPlot public plot;

address public writer = address(0xA11CE);
address public other = address(0xB0B);

// A valid CIDv0 (46 chars)
string constant VALID_CID = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG";
bytes32 constant FAKE_HASH = keccak256("test content");

function setUp() public {
bond = new MockBond();
plot = new MockPlot();

uint128[] memory ranges = new uint128[](2);
ranges[0] = 500_000e18;
ranges[1] = 1_000_000e18;
uint128[] memory prices = new uint128[](2);
prices[0] = 1e15;
prices[1] = 1e18;

factory = new StoryFactory(address(bond), address(plot), 1_000_000e18, ranges, prices);
}

// ===================================================================
// createStoryline — happy path
// ===================================================================

function test_createStoryline_happy() public {
vm.prank(writer);
uint256 id = factory.createStoryline("My Story", VALID_CID, FAKE_HASH, false);

assertEq(id, 1);
assertEq(factory.storylineCount(), 1);

(address w, address tok, uint256 plotCount, uint256 lastPlot, bool deadline, bool sunset) =
factory.storylines(1);
assertEq(w, writer);
assertTrue(tok != address(0));
assertEq(plotCount, 1);
assertEq(lastPlot, block.timestamp);
assertFalse(deadline);
assertFalse(sunset);

// Bond was called
assertEq(bond.createCount(), 1);
assertEq(bond.lastCreator(), writer);
}

function test_createStoryline_emitsEvents() public {
vm.prank(writer);

vm.expectEmit(true, true, false, true);
emit StoryFactory.StorylineCreated(
1, writer, address(uint160(0xBEEF0001)), "My Story", true, VALID_CID, FAKE_HASH
);

vm.expectEmit(true, true, true, true);
emit StoryFactory.PlotChained(1, 0, writer, VALID_CID, FAKE_HASH);

factory.createStoryline("My Story", VALID_CID, FAKE_HASH, true);
}

function test_createStoryline_revert_emptyTitle() public {
vm.prank(writer);
vm.expectRevert("Empty title");
factory.createStoryline("", VALID_CID, FAKE_HASH, false);
}

function test_createStoryline_revert_invalidCID_tooShort() public {
vm.prank(writer);
vm.expectRevert("Invalid CID");
factory.createStoryline("Title", "short", FAKE_HASH, false);
}

// ===================================================================
// chainPlot — happy path
// ===================================================================

function test_chainPlot_happy() public {
vm.prank(writer);
uint256 id = factory.createStoryline("Story", VALID_CID, FAKE_HASH, false);

string memory cid2 = "QmZwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdH";
bytes32 hash2 = keccak256("chapter 2");

vm.prank(writer);
factory.chainPlot(id, cid2, hash2);

(,, uint256 plotCount,,,) = factory.storylines(id);
assertEq(plotCount, 2);
}

function test_chainPlot_emitsCorrectIndex() public {
vm.prank(writer);
uint256 id = factory.createStoryline("Story", VALID_CID, FAKE_HASH, false);

string memory cid2 = "QmZwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdH";
bytes32 hash2 = keccak256("chapter 2");

vm.expectEmit(true, true, true, true);
emit StoryFactory.PlotChained(id, 1, writer, cid2, hash2);

vm.prank(writer);
factory.chainPlot(id, cid2, hash2);
}

// ===================================================================
// chainPlot — revert cases
// ===================================================================

function test_chainPlot_revert_notWriter() public {
vm.prank(writer);
uint256 id = factory.createStoryline("Story", VALID_CID, FAKE_HASH, false);

vm.prank(other);
vm.expectRevert("Not writer");
factory.chainPlot(id, VALID_CID, FAKE_HASH);
}

function test_chainPlot_revert_invalidCID() public {
vm.prank(writer);
uint256 id = factory.createStoryline("Story", VALID_CID, FAKE_HASH, false);

vm.prank(writer);
vm.expectRevert("Invalid CID");
factory.chainPlot(id, "too-short", FAKE_HASH);
}

function test_chainPlot_revert_deadlineExpired() public {
vm.prank(writer);
uint256 id = factory.createStoryline("Story", VALID_CID, FAKE_HASH, true); // deadline enabled

// Warp past 72 hours
vm.warp(block.timestamp + 72 hours + 1);

vm.prank(writer);
vm.expectRevert("Deadline passed");
factory.chainPlot(id, VALID_CID, FAKE_HASH);
}

function test_chainPlot_noDeadline_allowsLateWrite() public {
vm.prank(writer);
uint256 id = factory.createStoryline("Story", VALID_CID, FAKE_HASH, false); // no deadline

// Warp past 72 hours — should still work
vm.warp(block.timestamp + 100 days);

vm.prank(writer);
factory.chainPlot(id, VALID_CID, FAKE_HASH);

(,, uint256 plotCount,,,) = factory.storylines(id);
assertEq(plotCount, 2);
}

function test_chainPlot_revert_sunset() public {
vm.prank(writer);
uint256 id = factory.createStoryline("Story", VALID_CID, FAKE_HASH, false);

// Storyline struct is at mapping slot: keccak256(abi.encode(id, 2))
// where 2 is the storage slot of `storylines` mapping.
// Struct layout: writer(slot+0), token(slot+1), plotCount(slot+2),
// lastPlotTime(slot+3), hasDeadline+sunset packed in slot+4
// hasDeadline is at byte 0, sunset is at byte 1
bytes32 baseSlot = keccak256(abi.encode(id, uint256(2)));
bytes32 flagsSlot = bytes32(uint256(baseSlot) + 4);

// Read current value and set sunset bit (byte 1 = offset 1 from right)
bytes32 current = vm.load(address(factory), flagsSlot);
// sunset is the second bool in the packed slot — set byte at offset 1
bytes32 withSunset = current | bytes32(uint256(1) << 8);
vm.store(address(factory), flagsSlot, withSunset);

vm.prank(writer);
vm.expectRevert("Storyline sunset");
factory.chainPlot(id, VALID_CID, FAKE_HASH);
}

// ===================================================================
// donate — happy path
// ===================================================================

function test_donate_happy() public {
vm.prank(writer);
uint256 id = factory.createStoryline("Story", VALID_CID, FAKE_HASH, false);

// Give donor some tokens and approve
plot.mint(other, 1000e18);
vm.prank(other);
plot.approve(address(factory), 500e18);

vm.expectEmit(true, true, false, true);
emit StoryFactory.Donation(id, other, 100e18);

vm.prank(other);
factory.donate(id, 100e18);

assertEq(plot.balanceOf(writer), 100e18);
assertEq(plot.balanceOf(other), 900e18);
}

function test_donate_revert_zeroAmount() public {
vm.prank(writer);
uint256 id = factory.createStoryline("Story", VALID_CID, FAKE_HASH, false);

vm.prank(other);
vm.expectRevert("Zero amount");
factory.donate(id, 0);
}

function test_donate_revert_nonExistentStoryline() public {
vm.prank(other);
vm.expectRevert("Storyline does not exist");
factory.donate(999, 100e18);
}

function test_donate_revert_insufficientAllowance() public {
vm.prank(writer);
uint256 id = factory.createStoryline("Story", VALID_CID, FAKE_HASH, false);

plot.mint(other, 1000e18);
// No approval

vm.prank(other);
vm.expectRevert("Insufficient allowance");
factory.donate(id, 100e18);
}
}