Skip to content
Merged
Show file tree
Hide file tree
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
48 changes: 48 additions & 0 deletions src/__tests__/deriveBlockedUtxoRefs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { deriveBlockedUtxoRefs } from "@/utils/blockedUtxoRefs";

function pendingTx(id: string, refs: { txHash: string; txIndex: number }[]) {
return {
id,
txJson: JSON.stringify({ inputs: refs.map((txIn) => ({ txIn })) }),
};
}

describe("deriveBlockedUtxoRefs", () => {
const HASH_A = "a".repeat(64);
const HASH_B = "b".repeat(64);

it("collects input refs from every pending transaction", () => {
const refs = deriveBlockedUtxoRefs([
pendingTx("tx-1", [{ txHash: HASH_A, txIndex: 0 }]),
pendingTx("tx-2", [{ txHash: HASH_B, txIndex: 3 }]),
]);
expect(refs).toEqual([
{ hash: HASH_A, index: 0 },
{ hash: HASH_B, index: 3 },
]);
});

it("frees the excluded transaction's inputs, keeping the rest blocked", () => {
const refs = deriveBlockedUtxoRefs(
[
pendingTx("editing", [{ txHash: HASH_A, txIndex: 0 }]),
pendingTx("other", [{ txHash: HASH_B, txIndex: 1 }]),
],
"editing",
);
expect(refs).toEqual([{ hash: HASH_B, index: 1 }]);
});

it("tolerates malformed txJson and missing input refs", () => {
const refs = deriveBlockedUtxoRefs([
{ id: "bad-json", txJson: "{not json" },
{ id: "no-inputs", txJson: JSON.stringify({}) },
{
id: "partial-ref",
txJson: JSON.stringify({ inputs: [{ txIn: { txHash: HASH_A } }] }),
},
pendingTx("good", [{ txHash: HASH_B, txIndex: 2 }]),
]);
expect(refs).toEqual([{ hash: HASH_B, index: 2 }]);
});
});
53 changes: 53 additions & 0 deletions src/__tests__/drepContext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { deriveDrepVoteContext } from "@/lib/governance/drep-context";
import { MultisigWallet, type MultisigKey } from "@/utils/multisigSDK";

const PAYMENT_HASH = "a".repeat(56);
const DREP_HASH = "b".repeat(56);

function walletWith(keys: MultisigKey[]): MultisigWallet {
return new MultisigWallet("Test", keys, "", 1, 0);
}

const APP_WALLET = { dRepId: "drep1fallback", scriptCbor: "82fallback" };

describe("deriveDrepVoteContext", () => {
test("uses the wallet's role-3 DRep script when present", () => {
const wallet = walletWith([
{ keyHash: PAYMENT_HASH, role: 0, name: "P" },
{ keyHash: DREP_HASH, role: 3, name: "D" },
]);
const ctx = deriveDrepVoteContext(wallet, APP_WALLET);
expect(ctx).toBeDefined();
expect(ctx!.dRepId.startsWith("drep")).toBe(true);
expect(ctx!.drepScriptCbor).toBe(wallet.getDRepScript());
// Role-3 derivation must not silently return the app-wallet fallback.
expect(ctx!.dRepId).not.toBe(APP_WALLET.dRepId);
});

test("falls back to the payment script when there are no DRep keys", () => {
const wallet = walletWith([{ keyHash: PAYMENT_HASH, role: 0, name: "P" }]);
const ctx = deriveDrepVoteContext(wallet, undefined);
expect(ctx).toBeDefined();
expect(ctx!.drepScriptCbor).toBe(wallet.getDRepScript());
});

test("legacy wallets without a MultisigWallet use the app wallet", () => {
expect(deriveDrepVoteContext(undefined, APP_WALLET)).toEqual({
dRepId: "drep1fallback",
drepScriptCbor: "82fallback",
});
});

test("keyless wallet with app fallback returns the fallback, without → undefined", () => {
const keyless = walletWith([]);
expect(deriveDrepVoteContext(keyless, APP_WALLET)).toEqual({
dRepId: "drep1fallback",
drepScriptCbor: "82fallback",
});
expect(deriveDrepVoteContext(keyless, undefined)).toBeUndefined();
expect(deriveDrepVoteContext(undefined, undefined)).toBeUndefined();
expect(
deriveDrepVoteContext(undefined, { dRepId: null, scriptCbor: "x" }),
).toBeUndefined();
});
});
213 changes: 213 additions & 0 deletions src/__tests__/governanceRationale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { hashDrepAnchor } from "@meshsdk/core";

import {
buildRationaleJsonLd,
findBallotRowForVote,
uploadRationale,
} from "@/lib/governance/rationale";

/**
* Expected CIP-100 document, copied verbatim from the ballot editor's
* `constructJsonLdFromComment` (governance/ballot/ballot.tsx) — the shape
* parity assertion, since that builder isn't exported.
*/
function expectedDoc(comment: string) {
return {
"@context": {
CIP100:
"https://github.com/cardano-foundation/CIPs/blob/master/CIP-0100/README.md#",
hashAlgorithm: "CIP100:hashAlgorithm",
body: {
"@id": "CIP100:body",
"@context": {
references: {
"@id": "CIP100:references",
"@container": "@set",
"@context": {
GovernanceMetadata: "CIP100:GovernanceMetadataReference",
Other: "CIP100:OtherReference",
label: "CIP100:reference-label",
uri: "CIP100:reference-uri",
referenceHash: {
"@id": "CIP100:referenceHash",
"@context": {
hashDigest: "CIP100:hashDigest",
hashAlgorithm: "CIP100:hashAlgorithm",
},
},
},
},
comment: "CIP100:comment",
externalUpdates: {
"@id": "CIP100:externalUpdates",
"@context": {
title: "CIP100:update-title",
uri: "CIP100:uri",
},
},
},
},
authors: {
"@id": "CIP100:authors",
"@container": "@set",
"@context": {
name: "http://xmlns.com/foaf/0.1/name",
witness: {
"@id": "CIP100:witness",
"@context": {
witnessAlgorithm: "CIP100:witnessAlgorithm",
publicKey: "CIP100:publicKey",
signature: "CIP100:signature",
},
},
},
},
},
authors: [],
body: { comment },
hashAlgorithm: "blake2b-256",
};
}

describe("buildRationaleJsonLd", () => {
test("matches the ballot editor's CIP-100 document shape, trimmed", () => {
expect(buildRationaleJsonLd(" we support this ")).toEqual(
expectedDoc("we support this"),
);
});

test("hash covers the exact 2-space serialization that gets pinned", () => {
const doc = buildRationaleJsonLd("reasoning");
const hash = hashDrepAnchor(doc as object);
expect(hash).toMatch(/^[0-9a-f]{64}$/);
// A round-trip through the pinned bytes re-hashes identically — proves
// the uploaded document verifies against the attached anchorDataHash.
const pinnedBytes = JSON.stringify(doc, null, 2);
expect(hashDrepAnchor(JSON.parse(pinnedBytes) as object)).toBe(hash);
});
});

describe("uploadRationale", () => {
const realFetch = global.fetch;
afterEach(() => {
global.fetch = realFetch;
});

test("pins the exact hashed serialization and returns the anchor", async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
global.fetch = jest.fn(async (url: any, init: any) => {
calls.push({ url: String(url), init });
return {
ok: true,
json: async () => ({ url: "ipfs://newcid", cid: "newcid", id: "1" }),
} as Response;
}) as any;

const anchor = await uploadRationale(" reasoning ");

expect(calls).toHaveLength(1);
expect(calls[0]!.url).toBe("/api/pinata-storage/put");
const body = JSON.parse(String(calls[0]!.init.body));
expect(body.pathname).toMatch(/^rationale\/rationale-\d+\.jsonld$/);
const doc = buildRationaleJsonLd("reasoning");
expect(body.value).toBe(JSON.stringify(doc, null, 2));
expect(anchor).toEqual({
anchorUrl: "ipfs://newcid",
anchorDataHash: hashDrepAnchor(doc as object),
});
});

test("surfaces the API error message on failure", async () => {
global.fetch = jest.fn(async () => ({
ok: false,
status: 502,
json: async () => ({ error: "Pinata upload failed" }),
})) as any;
await expect(uploadRationale("text")).rejects.toThrow(
/Rationale upload failed: Pinata upload failed/,
);
});

test("rejects when the response has no URL", async () => {
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({}),
})) as any;
await expect(uploadRationale("text")).rejects.toThrow(/no URL returned/);
});
});

describe("findBallotRowForVote", () => {
const GOV_HASH = "a".repeat(64);
const vote = {
govActionTxHash: GOV_HASH,
govActionIndex: 2,
anchor: { anchorUrl: "ipfs://old", anchorDataHash: "b".repeat(64) },
};

test("matches by old anchor URL", () => {
const ballots = [
{ id: "b1", items: ["x#0"], anchorUrls: ["", "ipfs://old"], anchorHashes: [] },
];
expect(findBallotRowForVote(ballots, vote)).toEqual({
ballotId: "b1",
index: 1,
});
});

test("matches by old anchor hash alone", () => {
const ballots = [
{ id: "b1", anchorUrls: [], anchorHashes: ["b".repeat(64)] },
];
expect(findBallotRowForVote(ballots, vote)).toEqual({
ballotId: "b1",
index: 0,
});
});

test("empty-string ballot entries never match", () => {
const ballots = [
{ id: "b1", items: [], anchorUrls: [""], anchorHashes: [""] },
];
const anchorlessBallotVote = {
...vote,
anchor: { anchorUrl: "", anchorDataHash: "" },
};
expect(
findBallotRowForVote(ballots, anchorlessBallotVote),
).toBeUndefined();
});

test("anchor-less vote falls back to proposal id match", () => {
const ballots = [
{ id: "b1", items: ["other#1"] },
{ id: "b2", items: ["skip#0", `${GOV_HASH}#2`] },
];
expect(
findBallotRowForVote(ballots, {
govActionTxHash: GOV_HASH,
govActionIndex: 2,
}),
).toEqual({ ballotId: "b2", index: 1 });
});

test("first anchor match wins over later proposal-id matches", () => {
const ballots = [
{ id: "b1", items: [`${GOV_HASH}#2`], anchorUrls: ["ipfs://old"] },
{ id: "b2", items: [`${GOV_HASH}#2`], anchorUrls: ["ipfs://old"] },
];
expect(findBallotRowForVote(ballots, vote)).toEqual({
ballotId: "b1",
index: 0,
});
});

test("no match returns undefined", () => {
expect(
findBallotRowForVote([], {
govActionTxHash: GOV_HASH,
govActionIndex: 0,
}),
).toBeUndefined();
});
});
2 changes: 1 addition & 1 deletion src/__tests__/normalizePoolId.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "@jest/globals";
import { resolvePoolId } from "@meshsdk/core";
import { normalizePoolIdForDelegation } from "@/lib/server/normalizePoolId";
import { normalizePoolIdForDelegation } from "@/utils/normalizePoolId";

describe("normalizePoolIdForDelegation", () => {
it("normalizes 56-char hex", () => {
Expand Down
43 changes: 43 additions & 0 deletions src/__tests__/proposalTitles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { ballotTitleMap } from "@/lib/governance/proposal-titles";

const PID_A = `${"a".repeat(64)}#0`;
const PID_B = `${"b".repeat(64)}#2`;

describe("ballotTitleMap", () => {
test("zips items with their descriptions", () => {
const map = ballotTitleMap([
{
items: [PID_A, PID_B],
itemDescriptions: ["Treasury Withdrawal Q3", "Hard Fork to v11"],
},
]);
expect(map.get(PID_A)).toBe("Treasury Withdrawal Q3");
expect(map.get(PID_B)).toBe("Hard Fork to v11");
});

test("first (newest) ballot wins on duplicate ids", () => {
const map = ballotTitleMap([
{ items: [PID_A], itemDescriptions: ["Newest title"] },
{ items: [PID_A], itemDescriptions: ["Older title"] },
]);
expect(map.get(PID_A)).toBe("Newest title");
});

test("skips empty and whitespace-only titles", () => {
const map = ballotTitleMap([
{ items: [PID_A, PID_B], itemDescriptions: ["", " "] },
{ items: [PID_A], itemDescriptions: ["Fallback title"] },
]);
expect(map.get(PID_A)).toBe("Fallback title");
expect(map.has(PID_B)).toBe(false);
});

test("tolerates undefined input and misaligned arrays", () => {
expect(ballotTitleMap(undefined).size).toBe(0);
const map = ballotTitleMap([
{ items: [PID_A, PID_B], itemDescriptions: ["Only one"] },
]);
expect(map.get(PID_A)).toBe("Only one");
expect(map.has(PID_B)).toBe(false);
});
});
Loading
Loading