Skip to content

Commit efcc171

Browse files
authored
test(lp): unit tests for lp positions subcommand (offline mocked) (#19)
lp.ts is 1803 LOC — the largest still-untested CLI handler at the start of this PR stack. The full surface (add/remove/farm/claim/ discover/pipeline/compound/autopilot/positions) needs to be split across multiple PRs. This commit covers only the smallest leaf (`lp positions`) so the basic --chain / --protocol / --address routing has shape-pinning regression coverage; other subcommands will follow in their own PRs. 4 new tests in lp-positions.test.ts: - errors when --chain is missing (no protocol enumeration) Asserts the standard "--chain is required" error envelope. - returns an empty array when balanceOf returns 0n across every protocol on the chain The viem.readContract mock returns 0n for every NPM contract; every protocol's NFT enumeration short-circuits and the output is the canonical empty array. - --protocol filter narrows enumeration to a single protocol Pins that --protocol controls the loop scope. - --address parameter overrides DEFI_WALLET_ADDRESS without throwing Pins the resolveAccount priority and ensures opts.address wins. Mock strategy: - vi.mock("viem"): createPublicClient.readContract returns 0n, so every NPM balanceOf reports zero NFTs and the loop is skipped. http() is a no-op factory. - vi.mock("@hypurrquant/defi-protocols"): createMerchantMoeLB returns an adapter whose discoverRewardedPools / etc. return [], so the LB scan path doesn't reach the network either. - All other adapter constructors fall through to the real implementations because the test protocols (uniswap-v2-monad, uniswap-v3-monad) don't trigger them in the empty-positions branch. Verified: - pnpm -C ts -r build — clean. - pnpm -C ts -r lint — 3 packages, tsc --noEmit clean. - pnpm -C ts -r test — defi-core 32/32, defi-protocols 43/43, defi-cli 98/98 (+4 lp-positions tests on a baseline of 94 swap tests; the merged main after PR #18 will be 102 once both land).
1 parent 5aa2c91 commit efcc171

1 file changed

Lines changed: 216 additions & 0 deletions

File tree

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// Unit tests for `defi lp positions` — covers the smallest leaf of lp.ts
2+
// (1803 LOC, the largest still-untested handler at the start of this PR
3+
// stack). Bounded scope: only the positions subcommand, only with a
4+
// vi.mock'd viem so all NFT enumeration short-circuits offline.
5+
//
6+
// The other lp subcommands (add/remove/farm/claim/discover/pipeline/
7+
// compound/autopilot) need their own follow-up PRs; the goal here is to
8+
// pin the basic --chain / --protocol / --address routing so a future
9+
// refactor can't quietly drop them.
10+
import { Command } from "commander";
11+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12+
13+
import { Executor } from "../executor.js";
14+
import { parseOutputMode } from "../output.js";
15+
16+
// vi.mock viem so any readContract call short-circuits to "no positions".
17+
// The lp positions handler walks every protocol's NPM contract via
18+
// balanceOf(); returning 0n in every case means the per-protocol loop
19+
// produces no entries, so the output is the empty array we want to
20+
// assert. Tests run offline and deterministically.
21+
vi.mock("viem", async (importOriginal) => {
22+
const actual = await importOriginal<typeof import("viem")>();
23+
return {
24+
...actual,
25+
createPublicClient: () => ({
26+
readContract: vi.fn(async () => 0n),
27+
}),
28+
http: () => () => ({}),
29+
};
30+
});
31+
32+
// vi.mock the defi-protocols package's adapter constructors so the
33+
// merchant-moe LB scan path also short-circuits without RPC.
34+
vi.mock("@hypurrquant/defi-protocols", async (importOriginal) => {
35+
const actual = await importOriginal<typeof import("@hypurrquant/defi-protocols")>();
36+
return {
37+
...actual,
38+
// The handler calls createMerchantMoeLB(protocol, rpcUrl) and then
39+
// .discoverRewardedPools(). Returning [] makes the inner for-loop
40+
// a no-op without touching the chain.
41+
createMerchantMoeLB: () => ({
42+
discoverRewardedPools: async () => [],
43+
findUserBinsWithBalance: async () => [],
44+
getUserPositions: async () => [],
45+
getPendingRewards: async () => [],
46+
}),
47+
// Other LP-side adapter constructors aren't called for the protocols
48+
// exercised in these tests (we use uniswap-v2-monad and
49+
// uniswap-v3-monad), so leaving them as the real implementations is
50+
// safe — the handler resolves them lazily.
51+
};
52+
});
53+
54+
const { registerLP } = await import("./lp.js");
55+
56+
interface CapturedOutput {
57+
json: string[];
58+
text: string[];
59+
}
60+
61+
function captureConsole(): { capture: CapturedOutput; restore: () => void } {
62+
const originalLog = console.log;
63+
const originalErr = process.stderr.write.bind(process.stderr);
64+
const capture: CapturedOutput = { json: [], text: [] };
65+
console.log = (msg?: unknown, ...rest: unknown[]) => {
66+
const line = [msg, ...rest]
67+
.map((m) => (typeof m === "string" ? m : JSON.stringify(m)))
68+
.join(" ");
69+
if (line.trim().startsWith("[") || line.trim().startsWith("{")) {
70+
capture.json.push(line);
71+
} else {
72+
capture.text.push(line);
73+
}
74+
};
75+
process.stderr.write = ((chunk: string | Uint8Array) => {
76+
capture.text.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
77+
return true;
78+
}) as typeof process.stderr.write;
79+
return {
80+
capture,
81+
restore: () => {
82+
console.log = originalLog;
83+
process.stderr.write = originalErr;
84+
},
85+
};
86+
}
87+
88+
function buildProgram(): Command {
89+
const program = new Command();
90+
program.exitOverride();
91+
program.option("--chain <chain>", "Target chain");
92+
program.option("--json", "Output as JSON");
93+
program.option("--ndjson", "Output as newline-delimited JSON");
94+
program.option("--fields <fields>", "Filter output fields");
95+
registerLP(
96+
program,
97+
() => parseOutputMode(program.opts<{ json?: boolean; ndjson?: boolean; fields?: string }>()),
98+
() => new Executor(false),
99+
);
100+
return program;
101+
}
102+
103+
const ENV_KEYS = ["DEFI_WALLET_ADDRESS", "DEFI_PRIVATE_KEY"] as const;
104+
let snapshot: Record<string, string | undefined> = {};
105+
106+
beforeEach(() => {
107+
snapshot = {};
108+
for (const k of ENV_KEYS) {
109+
snapshot[k] = process.env[k];
110+
delete process.env[k];
111+
}
112+
process.env["DEFI_WALLET_ADDRESS"] = "0x000000000000000000000000000000000000dEaD";
113+
});
114+
115+
afterEach(() => {
116+
for (const k of ENV_KEYS) {
117+
if (snapshot[k] === undefined) delete process.env[k];
118+
else process.env[k] = snapshot[k];
119+
}
120+
});
121+
122+
describe("defi lp positions", () => {
123+
it("errors when --chain is missing (no protocol enumeration)", async () => {
124+
const program = buildProgram();
125+
const { capture, restore } = captureConsole();
126+
try {
127+
await program.parseAsync(["node", "defi", "--json", "lp", "positions"]);
128+
} finally {
129+
restore();
130+
}
131+
expect(capture.json.length).toBeGreaterThan(0);
132+
const data = JSON.parse(capture.json.join("\n")) as { error?: string };
133+
expect(data.error).toBeTruthy();
134+
expect(data.error).toMatch(/--chain.*required/i);
135+
});
136+
137+
it("returns an empty array when balanceOf returns 0n across every protocol on the chain", async () => {
138+
const program = buildProgram();
139+
const { capture, restore } = captureConsole();
140+
try {
141+
await program.parseAsync([
142+
"node",
143+
"defi",
144+
"--json",
145+
"--chain",
146+
"monad",
147+
"lp",
148+
"positions",
149+
]);
150+
} finally {
151+
restore();
152+
}
153+
expect(capture.json.length).toBeGreaterThan(0);
154+
const data = JSON.parse(capture.json.join("\n"));
155+
// The mocked viem.readContract returns 0n, so every protocol with an
156+
// NPM contract reports 0 NFTs and is skipped. The output is the
157+
// canonical empty-positions array.
158+
expect(Array.isArray(data)).toBe(true);
159+
expect(data).toEqual([]);
160+
});
161+
162+
it("--protocol filter narrows enumeration to a single protocol", async () => {
163+
// With --protocol set to a real Monad protocol slug, the handler
164+
// should only walk that one entry. Even if it produces no
165+
// positions (because of our mocks), the call must complete
166+
// without throwing.
167+
const program = buildProgram();
168+
const { capture, restore } = captureConsole();
169+
try {
170+
await program.parseAsync([
171+
"node",
172+
"defi",
173+
"--json",
174+
"--chain",
175+
"monad",
176+
"lp",
177+
"positions",
178+
"--protocol",
179+
"uniswap-v3-monad",
180+
]);
181+
} finally {
182+
restore();
183+
}
184+
expect(capture.json.length).toBeGreaterThan(0);
185+
const data = JSON.parse(capture.json.join("\n"));
186+
expect(Array.isArray(data)).toBe(true);
187+
expect(data).toEqual([]);
188+
});
189+
190+
it("--address parameter overrides DEFI_WALLET_ADDRESS without throwing", async () => {
191+
// The handler resolves the user address via resolveAccount(opts.address,
192+
// lp.opts().wallet). When --address is passed it should win over the
193+
// env DEFI_WALLET_ADDRESS we set in beforeEach. Test that the call
194+
// completes and returns the empty-positions sentinel.
195+
const program = buildProgram();
196+
const { capture, restore } = captureConsole();
197+
try {
198+
await program.parseAsync([
199+
"node",
200+
"defi",
201+
"--json",
202+
"--chain",
203+
"monad",
204+
"lp",
205+
"positions",
206+
"--address",
207+
"0x000000000000000000000000000000000000bEEF",
208+
]);
209+
} finally {
210+
restore();
211+
}
212+
expect(capture.json.length).toBeGreaterThan(0);
213+
const data = JSON.parse(capture.json.join("\n"));
214+
expect(Array.isArray(data)).toBe(true);
215+
});
216+
});

0 commit comments

Comments
 (0)