Skip to content

Commit 0aaac6d

Browse files
improve test coverage
1 parent 0ad4029 commit 0aaac6d

7 files changed

Lines changed: 676 additions & 4 deletions

File tree

test/src/apps/hdb/commands/coinbase/balances/coinbase-balances-handlers.test.ts

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ const {
1818
loggerErrorMock,
1919
logMock,
2020
tableMock,
21+
clientQueryMock,
22+
clientReleaseMock,
23+
poolConnectMock,
2124
} = vi.hoisted(() => ({
2225
getToAndFromDatesMock: vi.fn(() => Promise.resolve({
2326
from: dateUtc({ year: 2026, month: 1, day: 1 }),
@@ -90,16 +93,19 @@ const {
9093
},
9194
])),
9295
getClientMock: vi.fn(() => Promise.resolve({
93-
connect: vi.fn(() => Promise.resolve({
94-
query: vi.fn(() => Promise.resolve(undefined)),
95-
release: vi.fn(),
96-
})),
96+
connect: poolConnectMock,
9797
})),
9898
loggerWarnMock: vi.fn(),
9999
loggerInfoMock: vi.fn(),
100100
loggerErrorMock: vi.fn(),
101101
logMock: vi.fn(),
102102
tableMock: vi.fn(),
103+
clientQueryMock: vi.fn(() => Promise.resolve(undefined)),
104+
clientReleaseMock: vi.fn(),
105+
poolConnectMock: vi.fn(() => Promise.resolve({
106+
query: clientQueryMock,
107+
release: clientReleaseMock,
108+
})),
103109
}));
104110

105111
vi.mock("../../../../../../../src/apps/hdb/commands/shared/date-range-utils.js", () => ({
@@ -185,6 +191,16 @@ describe("hdb coinbase balance handlers", () => {
185191
expect(logMock.mock.calls[0]?.[0]).toContain("\"filters\"");
186192
});
187193

194+
it("prints current snapshot balances as json with current-balance metadata", async () => {
195+
await coinbaseBalancesBatch({ current: true, remote: true, json: true, raw: true });
196+
197+
expect(requestAccountsMock).toHaveBeenCalledTimes(1);
198+
expect(tableMock).not.toHaveBeenCalled();
199+
expect(logMock.mock.calls[0]?.[0]).toContain("\"mode\": \"snapshot\"");
200+
expect(logMock.mock.calls[0]?.[0]).toContain("\"includesCurrentBalance\": true");
201+
expect(logMock.mock.calls[0]?.[0]).toContain("\"raw\": true");
202+
});
203+
188204
it("queries batch snapshot and trace", async () => {
189205
await coinbaseBalancesBatch({ quiet: false, raw: true });
190206
await coinbaseBalancesTrace("eth2", { quiet: false, raw: true });
@@ -196,6 +212,24 @@ describe("hdb coinbase balance handlers", () => {
196212
);
197213
});
198214

215+
it("warns when list or trace lookups return no balances", async () => {
216+
selectCoinbaseBalanceLedgerMock.mockResolvedValueOnce([]);
217+
traceCoinbaseBalanceLedgerMock.mockResolvedValueOnce([]);
218+
219+
await expect(coinbaseBalances("btc", { quiet: false })).resolves.toEqual([]);
220+
await expect(coinbaseBalancesTrace("btc", { quiet: false })).resolves.toEqual([]);
221+
222+
expect(loggerWarnMock).toHaveBeenCalledTimes(2);
223+
});
224+
225+
it("uses drop flow when regenerating from scratch", async () => {
226+
await coinbaseBalancesRegenerate({ drop: true, quiet: true });
227+
228+
expect(dropCoinbaseBalanceLedgerTableMock).toHaveBeenCalledTimes(1);
229+
expect(createCoinbaseBalanceLedgerTableMock).toHaveBeenCalledTimes(1);
230+
expect(truncateCoinbaseBalanceLedgerTableMock).not.toHaveBeenCalled();
231+
});
232+
199233
it("regenerates balance ledger and writes computed rows", async () => {
200234
const count = await coinbaseBalancesRegenerate({ drop: false });
201235

@@ -222,4 +256,53 @@ describe("hdb coinbase balance handlers", () => {
222256
expect(count).toBe(5);
223257
});
224258

259+
it("supports unwrap into ETH without a negative balance", async () => {
260+
selectCoinbaseTransactionsMock.mockResolvedValueOnce([
261+
{
262+
id: "tx-eth",
263+
timestamp: dateUtc({ year: 2026, month: 1, day: 5 }),
264+
type: "Unwrap",
265+
asset: "ETH",
266+
num_quantity: "0.25",
267+
notes: "unwrap eth",
268+
},
269+
]);
270+
271+
const count = await coinbaseBalancesRegenerate({ quiet: true });
272+
273+
const firstBatch = insertCoinbaseBalanceLedgerBatchMock.mock.calls[0]?.[0];
274+
expect(firstBatch?.[0]?.asset).toBe("ETH");
275+
expect(firstBatch?.[1]?.tx_id).toBe("tx-eth");
276+
expect(firstBatch?.[1]?.balance).toBe("0.25");
277+
expect(loggerErrorMock).not.toHaveBeenCalled();
278+
expect(count).toBe(2);
279+
});
280+
281+
it("rolls back and rethrows when batched inserts fail", async () => {
282+
insertCoinbaseBalanceLedgerBatchMock.mockRejectedValueOnce(new Error("insert failed"));
283+
284+
await expect(coinbaseBalancesRegenerate({ quiet: true })).rejects.toThrow("insert failed");
285+
286+
expect(clientQueryMock).toHaveBeenNthCalledWith(1, "BEGIN");
287+
expect(clientQueryMock).toHaveBeenNthCalledWith(2, "ROLLBACK");
288+
expect(clientReleaseMock).toHaveBeenCalledTimes(1);
289+
});
290+
291+
it("throws when a transaction quantity is invalid during regenerate", async () => {
292+
selectCoinbaseTransactionsMock.mockResolvedValueOnce([
293+
{
294+
id: "tx-bad",
295+
timestamp: dateUtc({ year: 2026, month: 1, day: 2 }),
296+
type: "Buy",
297+
asset: "BTC",
298+
num_quantity: "not-a-number",
299+
notes: "bad row",
300+
},
301+
]);
302+
303+
await expect(coinbaseBalancesRegenerate({ quiet: true })).rejects.toThrow(
304+
"Invalid transaction quantity for tx-bad: not-a-number",
305+
);
306+
});
307+
225308
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
getAbbreviatedType,
4+
getClassifierForType,
5+
getSuperclassForType,
6+
getTypesForClassifier,
7+
} from "../../../../../../../src/apps/hdb/commands/coinbase/transactions/coinbase-transaction-classifiers.js";
8+
9+
describe("coinbase transaction classifiers", () => {
10+
it("maps classifiers and superclasses to concrete transaction types", () => {
11+
expect(getTypesForClassifier("trade_buy")).toEqual(["Advanced Trade Buy", "Buy"]);
12+
expect(getTypesForClassifier("income")).toEqual([
13+
"Staking Income",
14+
"Reward Income",
15+
"Subscription Rebate",
16+
"Subscription Rebates (24 Hours)",
17+
]);
18+
});
19+
20+
it("throws for an unknown classifier label", () => {
21+
expect(() => getTypesForClassifier("made-up")).toThrow("Unknown classifier: made-up");
22+
});
23+
24+
it("maps transaction types back to classifier and superclass labels", () => {
25+
expect(getClassifierForType("Buy")).toBe("trade_buy");
26+
expect(getSuperclassForType("Buy")).toBe("trade");
27+
expect(getClassifierForType("Deposit")).toBe("transfer_in");
28+
expect(getSuperclassForType("Deposit")).toBe("non_taxable");
29+
expect(getClassifierForType("Surprise")).toBe("unknown");
30+
expect(getSuperclassForType("Surprise")).toBe("uncategorized");
31+
});
32+
33+
it("returns abbreviations and falls back to the original type", () => {
34+
expect(getAbbreviatedType("Advanced Trade Buy")).toBe("ATB");
35+
expect(getAbbreviatedType("Reward Income")).toBe("RIN");
36+
expect(getAbbreviatedType("Custom Type")).toBe("Custom Type");
37+
});
38+
});

test/src/apps/hdb/commands/coinbase/transactions/coinbase-transactions-handlers.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,10 @@ describe("hdb coinbase transaction handlers", () => {
256256
);
257257
});
258258

259+
it("requires an explicit transaction id for id lookups", async () => {
260+
await expect(coinbaseTransactionsId(undefined, {})).rejects.toThrow("Must provide transaction ID(s)");
261+
});
262+
259263
it("imports statement csv rows in a transaction", async () => {
260264
const count = await coinbaseTransactionsStatement("/tmp/statement.csv", { normalize: true });
261265

@@ -264,6 +268,26 @@ describe("hdb coinbase transaction handlers", () => {
264268
expect(count).toBe(2);
265269
});
266270

271+
it("rolls back statement imports when batch insertion fails", async () => {
272+
const clientQueryMock = vi
273+
.fn()
274+
.mockResolvedValueOnce(undefined)
275+
.mockResolvedValueOnce(undefined);
276+
const releaseMock = vi.fn();
277+
getClientMock.mockResolvedValueOnce({
278+
connect: vi.fn(() => Promise.resolve({ query: clientQueryMock, release: releaseMock })),
279+
});
280+
insertCoinbaseTransactionsBatchMock.mockRejectedValueOnce(new Error("batch failed"));
281+
282+
await expect(coinbaseTransactionsStatement("/tmp/statement.csv", { normalize: true })).rejects.toThrow(
283+
"batch failed",
284+
);
285+
286+
expect(clientQueryMock).toHaveBeenNthCalledWith(1, "BEGIN");
287+
expect(clientQueryMock).toHaveBeenNthCalledWith(2, "ROLLBACK");
288+
expect(releaseMock).toHaveBeenCalledTimes(1);
289+
});
290+
267291
it("regenerates from csv directory with drop flow", async () => {
268292
const count = await coinbaseTransactionsRegenerate({ drop: true, normalize: true });
269293

@@ -305,4 +329,24 @@ describe("hdb coinbase transaction handlers", () => {
305329
expect(tableMock).toHaveBeenCalledTimes(1);
306330
expect(pnl).toBe(5200);
307331
});
332+
333+
it("skips invalid and zero balances and warns on pricing failures during nav", async () => {
334+
requestAccountsMock.mockResolvedValueOnce([
335+
{ currency: "USD", available_balance: { value: "100" }, hold: { value: "0" } },
336+
{ currency: "ETH", available_balance: { value: "0.5" }, hold: { value: "0" } },
337+
{ currency: "DOGE", available_balance: { value: "NaN" }, hold: { value: "0" } },
338+
{ currency: "ADA", available_balance: { value: "0" }, hold: { value: "0" } },
339+
]);
340+
requestProductMock.mockRejectedValueOnce(new Error("market unavailable"));
341+
selectCoinbaseTransactionsMock
342+
.mockResolvedValueOnce([{ num_quantity: "250" }])
343+
.mockResolvedValueOnce([{ num_quantity: "50" }]);
344+
selectCoinbaseTransactionsGroupMock.mockResolvedValueOnce([{ fee: "1.00" }]);
345+
346+
const pnl = await coinbaseTransactionsNav({ remote: true, quiet: true });
347+
348+
expect(loggerWarnMock).toHaveBeenCalledWith("Skipping NAV pricing for ETH: market unavailable");
349+
expect(tableMock).not.toHaveBeenCalled();
350+
expect(pnl).toBe(-100);
351+
});
308352
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const { getClientMock, loggerInfoMock } = vi.hoisted(() => ({
4+
getClientMock: vi.fn(),
5+
loggerInfoMock: vi.fn(),
6+
}));
7+
8+
vi.mock("../../../../../src/apps/hdb/db/db-client.js", () => ({
9+
getClient: getClientMock,
10+
}));
11+
12+
vi.mock("../../../../../src/shared/log/logger.js", () => ({
13+
logger: {
14+
info: loggerInfoMock,
15+
},
16+
}));
17+
18+
import { handleTestAction } from "../../../../../src/apps/hdb/commands/test.js";
19+
20+
describe("hdb test command", () => {
21+
beforeEach(() => {
22+
vi.clearAllMocks();
23+
});
24+
25+
it("logs an ISO string when the query returns a Date", async () => {
26+
const queryMock = vi.fn(() => Promise.resolve({
27+
rows: [{ now: new Date("2026-01-01T00:00:00.000Z") }],
28+
}));
29+
getClientMock.mockResolvedValue({ query: queryMock });
30+
31+
await handleTestAction();
32+
33+
expect(queryMock).toHaveBeenCalledWith("SELECT NOW() AS now");
34+
expect(loggerInfoMock).toHaveBeenCalledWith("2026-01-01T00:00:00.000Z");
35+
});
36+
37+
it("logs the raw string when the query returns a string timestamp", async () => {
38+
getClientMock.mockResolvedValue({
39+
query: vi.fn(() => Promise.resolve({ rows: [{ now: "2026-01-01 00:00:00+00" }] })),
40+
});
41+
42+
await handleTestAction();
43+
44+
expect(loggerInfoMock).toHaveBeenCalledWith("2026-01-01 00:00:00+00");
45+
});
46+
});

0 commit comments

Comments
 (0)