import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import bs58 from "bs58";
import { expect } from "chai";
import type { BaseContract, Signer } from "ethers";
import { ethers } from "hardhat";
import type { CyberValleyEventTicket } from "../../typechain-types";
const IPFS_HOST = "http://test.ipfs.host";
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
describe("CyberValleyEventTicket", () => {
describe("tokenURI", () => {
it("returns valid URI", async function () {
this.timeout(120000);
const { undertest, eventManager, master } =
await loadFixture(deployContract);
const cid = "QmTM7eS1BSMd9FfH4ihdjzVbyTPn8iCXtm3QLWkeRgEBtK";
const mh = getBytes32FromMultiash(cid);
await undertest
.connect(eventManager)
.mint(
master,
1,
0,
mh.digest,
mh.hashFunction,
mh.size,
ZERO_ADDRESS,
0,
);
const tx = await undertest.connect(master).tokenURI(1);
await expect(tx).to.equal(`${IPFS_HOST}/${cid}`);
});
it("reverts for non-existent token", async () => {
const { undertest } = await loadFixture(deployContract);
await expect(undertest.tokenURI(999)).to.be.revertedWithCustomError(
undertest,
"ERC721NonexistentToken",
);
});
});
describe("redeemTicket", () => {
it("marks ticket as redeemed and emits event", async () => {
const { undertest, eventManager, master, staff } =
await loadFixture(deployContract);
const mh = getTestMultihash();
await undertest
.connect(eventManager)
.mint(
master,
1,
0,
mh.digest,
mh.hashFunction,
mh.size,
ZERO_ADDRESS,
100,
);
expect(await undertest.isRedeemed(1)).to.be.false;
await expect(undertest.connect(staff).redeemTicket(1))
.to.emit(undertest, "TicketRedeemed")
.withArgs(1);
expect(await undertest.isRedeemed(1)).to.be.true;
});
it("reverts if ticket already redeemed", async () => {
const { undertest, eventManager, master, staff } =
await loadFixture(deployContract);
const mh = getTestMultihash();
await undertest
.connect(eventManager)
.mint(
master,
1,
0,
mh.digest,
mh.hashFunction,
mh.size,
ZERO_ADDRESS,
100,
);
await undertest.connect(staff).redeemTicket(1);
await expect(undertest.connect(staff).redeemTicket(1)).to.be.revertedWith(
"Token was redeemed already",
);
});
it("reverts if non-staff calls", async () => {
const { undertest, eventManager, master, owner } =
await loadFixture(deployContract);
const mh = getTestMultihash();
await undertest
.connect(eventManager)
.mint(
master,
1,
0,
mh.digest,
mh.hashFunction,
mh.size,
ZERO_ADDRESS,
100,
);
await expect(undertest.connect(owner).redeemTicket(1)).to.be.revertedWith(
"Must have staff role",
);
});
it("allows redeeming non-existent ticket (no existence check)", async () => {
const { undertest, staff } = await loadFixture(deployContract);
// Note: The contract doesn't check if token exists before redeeming
// It only checks the isRedeemed mapping which defaults to false
await expect(undertest.connect(staff).redeemTicket(999))
.to.emit(undertest, "TicketRedeemed")
.withArgs(999);
expect(await undertest.isRedeemed(999)).to.be.true;
});
});
describe("ticketMeta", () => {
it("returns correct metadata for valid ticket", async () => {
const { undertest, eventManager, master } =
await loadFixture(deployContract);
const mh = getTestMultihash();
await undertest
.connect(eventManager)
.mint(
master,
1,
0,
mh.digest,
mh.hashFunction,
mh.size,
ZERO_ADDRESS,
100,
);
const meta = await undertest.ticketMeta(1);
expect(meta.digest).to.equal(mh.digest);
expect(meta.hashFunction).to.equal(mh.hashFunction);
expect(meta.size).to.equal(mh.size);
});
it("reverts for non-existent ticket", async () => {
const { undertest } = await loadFixture(deployContract);
await expect(undertest.ticketMeta(999)).to.be.revertedWithCustomError(
undertest,
"ERC721NonexistentToken",
);
});
});
describe("transferFrom", () => {
it("reverts for non-mint transfers (transfer restriction)", async () => {
const { undertest, eventManager, master, owner } =
await loadFixture(deployContract);
const mh = getTestMultihash();
// Mint ticket to master
await undertest
.connect(eventManager)
.mint(
master,
1,
0,
mh.digest,
mh.hashFunction,
mh.size,
ZERO_ADDRESS,
100,
);
// Attempt to transfer from master to owner should fail
await expect(
undertest
.connect(master)
.transferFrom(await master.getAddress(), await owner.getAddress(), 1),
).to.be.revertedWith("Token transfer is disabled");
});
it("allows mint (from zero address)", async () => {
const { undertest, eventManager, master } =
await loadFixture(deployContract);
const mh = getTestMultihash();
// Mint should succeed (from address(0))
await expect(
undertest
.connect(eventManager)
.mint(
master,
1,
0,
mh.digest,
mh.hashFunction,
mh.size,
ZERO_ADDRESS,
100,
),
).to.not.be.reverted;
expect(await undertest.ownerOf(1)).to.equal(await master.getAddress());
});
});
describe("setIpfsHost", () => {
it("updates IPFS host (only master)", async () => {
const { undertest, master } = await loadFixture(deployContract);
const newHost = "https://new.ipfs.host";
await undertest.connect(master).setIpfsHost(newHost);
// Verify by minting and checking tokenURI
const mh = getTestMultihash();
const [, , eventManager] = await ethers.getSigners();
await undertest
.connect(eventManager)
.mint(
master,
1,
0,
mh.digest,
mh.hashFunction,
mh.size,
ZERO_ADDRESS,
100,
);
const cid = getCIDFromMultihash(mh.digest, mh.hashFunction, mh.size);
expect(await undertest.tokenURI(1)).to.equal(`${newHost}/${cid}`);
});
it("reverts if non-master calls", async () => {
const { undertest, owner } = await loadFixture(deployContract);
await expect(
undertest.connect(owner).setIpfsHost("https://new.host"),
).to.be.revertedWith("Must have master role");
});
});
describe("setEventManagerAddress", () => {
it("sets event manager address and grants role (only once)", async () => {
const [owner, master, newEventManager] = await ethers.getSigners();
const CyberValleyEventTicketFactory = await ethers.getContractFactory(
"CyberValleyEventTicket",
);
const eventTicket = await CyberValleyEventTicketFactory.deploy(
"CyberValleyEventTicket",
"CVET",
master,
"",
);
const EVENT_MANAGER_ROLE = await eventTicket.EVENT_MANAGER_ROLE();
// Before setting, newEventManager should not have role
expect(
await eventTicket.hasRole(
EVENT_MANAGER_ROLE,
await newEventManager.getAddress(),
),
).to.be.false;
await expect(
eventTicket
.connect(master)
.setEventManagerAddress(await newEventManager.getAddress()),
)
.to.emit(eventTicket, "RoleGranted")
.withArgs(
EVENT_MANAGER_ROLE,
await newEventManager.getAddress(),
await master.getAddress(),
);
expect(
await eventTicket.hasRole(
EVENT_MANAGER_ROLE,
await newEventManager.getAddress(),
),
).to.be.true;
expect(await eventTicket.eventManagerAddress()).to.equal(
await newEventManager.getAddress(),
);
});
it("reverts for zero address", async () => {
const { undertest, master } = await loadFixture(deployContract);
await expect(
undertest.connect(master).setEventManagerAddress(ethers.ZeroAddress),
).to.be.revertedWith("Event manager address cannot be zero");
});
it("reverts if already set", async () => {
const { undertest, master, eventManager } =
await loadFixture(deployContract);
const [, , , newManager] = await ethers.getSigners();
await expect(
undertest
.connect(master)
.setEventManagerAddress(await newManager.getAddress()),
).to.be.revertedWith("Event manager was already saved");
});
it("reverts if non-admin calls", async () => {
const { undertest, owner } = await loadFixture(deployContract);
await expect(
undertest
.connect(owner)
.setEventManagerAddress(await owner.getAddress()),
).to.be.revertedWithCustomError(
undertest,
"AccessControlUnauthorizedAccount",
);
});
});
describe("mint (single)", () => {
it("mints single ticket with correct metadata", async () => {
const { undertest, eventManager, master, owner } =
await loadFixture(deployContract);
const mh = getTestMultihash();
const referrer = await owner.getAddress();
await expect(
undertest
.connect(eventManager)
.mint(
master,
1,
0,
mh.digest,
mh.hashFunction,
mh.size,
referrer,
100,
),
)
.to.emit(undertest, "TicketMinted")
.withArgs(
1,
1,
0,
await master.getAddress(),
mh.digest,
mh.hashFunction,
mh.size,
referrer,
100,
);
expect(await undertest.ownerOf(1)).to.equal(await master.getAddress());
});
it("reverts if non-event-manager calls", async () => {
const { undertest, master, owner } = await loadFixture(deployContract);
const mh = getTestMultihash();
await expect(
undertest
.connect(owner)
.mint(
master,
1,
0,
mh.digest,
mh.hashFunction,
mh.size,
ZERO_ADDRESS,
100,
),
).to.be.revertedWith("Must have event manager role");
});
it("increments token ID correctly", async () => {
const { undertest, eventManager, master, owner } =
await loadFixture(deployContract);
const mh = getTestMultihash();
await undertest
.connect(eventManager)
.mint(
master,
1,
0,
mh.digest,
mh.hashFunction,
mh.size,
ZERO_ADDRESS,
100,
);
await undertest
.connect(eventManager)
.mint(
owner,
2,
1,
mh.digest,
mh.hashFunction,
mh.size,
ZERO_ADDRESS,
200,
);
expect(await undertest.ownerOf(1)).to.equal(await master.getAddress());
expect(await undertest.ownerOf(2)).to.equal(await owner.getAddress());
});
});
describe("mintBatch", () => {
it("mints multiple tickets with correct metadata", async () => {
const { undertest, eventManager, master } =
await loadFixture(deployContract);
const mh = getTestMultihash();
await undertest
.connect(eventManager)
.mintBatch(
master,
1,
0,
3,
mh.digest,
mh.hashFunction,
mh.size,
ZERO_ADDRESS,
50,
);
expect(await undertest.ownerOf(1)).to.equal(await master.getAddress());
expect(await undertest.ownerOf(2)).to.equal(await master.getAddress());
expect(await undertest.ownerOf(3)).to.equal(await master.getAddress());
});
it("emits TicketMinted for each ticket", async () => {
const { undertest, eventManager, master } =
await loadFixture(deployContract);
const mh = getTestMultihash();
const tx = await undertest
.connect(eventManager)
.mintBatch(
master,
1,
0,
2,
mh.digest,
mh.hashFunction,
mh.size,
ZERO_ADDRESS,
50,
);
const receipt = await tx.wait();
const events = receipt?.logs.filter(
(log) => "fragment" in log && log.fragment?.name === "TicketMinted",
);
expect(events?.length).to.equal(2);
});
it("reverts if non-event-manager calls", async () => {
const { undertest, master, owner } = await loadFixture(deployContract);
const mh = getTestMultihash();
await expect(
undertest
.connect(owner)
.mintBatch(
master,
1,
0,
2,
mh.digest,
mh.hashFunction,
mh.size,
ZERO_ADDRESS,
100,
),
).to.be.revertedWith("Must have event manager role");
});
});
describe("supportsInterface", () => {
it("supports ERC721 interface", async () => {
const { undertest } = await loadFixture(deployContract);
// ERC721 interface ID: 0x80ac58cd
expect(await undertest.supportsInterface("0x80ac58cd")).to.be.true;
});
it("supports AccessControl interface", async () => {
const { undertest } = await loadFixture(deployContract);
// AccessControl interface ID: 0x7965db0b
expect(await undertest.supportsInterface("0x7965db0b")).to.be.true;
});
it("does not support invalid interface", async () => {
const { undertest } = await loadFixture(deployContract);
expect(await undertest.supportsInterface("0x12345678")).to.be.false;
});
});
});
type ContractFixture = {
undertest: CyberValleyEventTicket & BaseContract;
master: Signer;
eventManager: Signer;
staff: Signer;
owner: Signer;
};
async function deployContract(): Promise<ContractFixture> {
const [owner, master, eventManager, staff] = await ethers.getSigners();
const CyberValleyEventTicketFactory = await ethers.getContractFactory(
"CyberValleyEventTicket",
);
const eventTicket = await CyberValleyEventTicketFactory.deploy(
"CyberValleyEventTicket",
"CVET",
master,
"",
);
await eventTicket
.connect(master)
.setEventManagerAddress(eventManager.address);
await eventTicket.connect(master).setIpfsHost(IPFS_HOST);
// Grant STAFF_ROLE to staff
const STAFF_ROLE = await eventTicket.STAFF_ROLE();
await eventTicket
.connect(master)
.grantRole(STAFF_ROLE, await staff.getAddress());
return { master, eventManager, staff, owner, undertest: eventTicket };
}
function getBytes32FromMultiash(multihash: string) {
const decoded = bs58.decode(multihash);
return {
digest: `0x${Buffer.from(decoded.slice(2)).toString("hex")}`,
hashFunction: decoded[0],
size: decoded[1],
};
}
function getTestMultihash() {
return {
digest:
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
hashFunction: 18,
size: 32,
};
}
function getCIDFromMultihash(
digest: string,
hashFunction: number,
size: number,
): string {
// Reverse of getBytes32FromMultiash for testing
const hashBytes = Buffer.from(digest.slice(2), "hex");
const multihashBytes = Buffer.concat([
Buffer.from([hashFunction, size]),
hashBytes,
]);
return bs58.encode(multihashBytes);
}