fix: Address review findings (CRITICAL/HIGH)

CRITICAL fixes:
- Stop passing mint URLs as relay URLs in initNDK (protocol mismatch)
- Add unit tests for buildTransactionHistory (dedup, fallback, sort)

HIGH fixes:
- Restore .claude entry in .gitignore
- Add warning logging for skipped malformed proof tags in nutzap parsing
- Add error handling + 30s timeout to NDK initialization
- Add unit tests for formatCSV and escapeField
- Export escapeField for testability

Also: add test script to package.json, include test/ in tsconfig

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jon Staab
2026-04-11 05:44:22 -07:00
parent def3157f9b
commit 65f7179557
10 changed files with 361 additions and 15 deletions
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect } from "bun:test";
import { formatCSV, escapeField } from "../src/csv.js";
import type { TransactionRecord } from "../src/types.js";
describe("escapeField", () => {
it("should return simple strings unchanged", () => {
expect(escapeField("hello")).toBe("hello");
});
it("should quote fields containing commas", () => {
expect(escapeField("hello, world")).toBe('"hello, world"');
});
it("should escape double quotes inside fields", () => {
expect(escapeField('say "hello"')).toBe('"say ""hello"""');
});
it("should quote fields containing newlines", () => {
expect(escapeField("line1\nline2")).toBe('"line1\nline2"');
});
it("should handle empty strings", () => {
expect(escapeField("")).toBe("");
});
});
describe("formatCSV", () => {
it("should produce correct header row for empty records", () => {
const csv = formatCSV([]);
expect(csv).toBe("date,type,amount,unit,mint,token_id,memo");
});
it("should format a simple record", () => {
const records: TransactionRecord[] = [
{
date: "2025-01-01T00:00:00Z",
type: "receive",
amount: 100,
unit: "sat",
mint: "https://mint.example",
token_id: "abc123",
memo: "",
},
];
const lines = formatCSV(records).split("\n");
expect(lines).toHaveLength(2);
expect(lines[1]).toContain("100");
expect(lines[1]).toContain("receive");
});
it("should escape fields containing commas", () => {
const records: TransactionRecord[] = [
{
date: "2025-01-01T00:00:00Z",
type: "receive",
amount: 100,
unit: "sat",
mint: "https://mint.example",
token_id: "abc",
memo: "hello, world",
},
];
const csv = formatCSV(records);
expect(csv).toContain('"hello, world"');
});
it("should escape fields containing double quotes", () => {
const records: TransactionRecord[] = [
{
date: "2025-01-01T00:00:00Z",
type: "receive",
amount: 100,
unit: "sat",
mint: "",
token_id: "abc",
memo: 'say "hello"',
},
];
const csv = formatCSV(records);
expect(csv).toContain('"say ""hello"""');
});
it("should sort records by date ascending", () => {
const records: TransactionRecord[] = [
{
date: "2025-03-01T00:00:00Z",
type: "send",
amount: 50,
unit: "sat",
mint: "",
token_id: "b",
memo: "",
},
{
date: "2025-01-01T00:00:00Z",
type: "receive",
amount: 100,
unit: "sat",
mint: "",
token_id: "a",
memo: "",
},
];
const lines = formatCSV(records).split("\n");
expect(lines[1]).toContain(",a,");
expect(lines[2]).toContain(",b,");
});
});
+219
View File
@@ -0,0 +1,219 @@
import { describe, it, expect } from "bun:test";
import { buildTransactionHistory } from "../src/events.js";
import type {
WalletData,
TokenData,
HistoryEntry,
TransactionRecord,
} from "../src/types.js";
describe("buildTransactionHistory", () => {
const emptyWallet: WalletData = { mints: [], privkey: "" };
it("should build records from history events", () => {
const history: HistoryEntry[] = [
{
direction: "in",
amount: "100",
unit: "sat",
eventId: "evt1",
createdAt: 1000,
referencedEvents: [],
},
{
direction: "out",
amount: "50",
unit: "sat",
eventId: "evt2",
createdAt: 2000,
referencedEvents: [],
},
];
const records = buildTransactionHistory(emptyWallet, [], history, [], []);
expect(records).toHaveLength(2);
expect(records[0].type).toBe("receive");
expect(records[0].amount).toBe(100);
expect(records[1].type).toBe("send");
expect(records[1].amount).toBe(50);
});
it("should deduplicate by event ID across sources", () => {
const history: HistoryEntry[] = [
{
direction: "in",
amount: "100",
unit: "sat",
eventId: "same-id",
createdAt: 1000,
referencedEvents: [],
},
];
const nutzaps: TransactionRecord[] = [
{
date: "2025-01-01T00:00:00Z",
type: "zap",
amount: 100,
unit: "sat",
mint: "",
token_id: "same-id",
memo: "",
},
];
const records = buildTransactionHistory(
emptyWallet,
[],
history,
nutzaps,
[]
);
expect(records).toHaveLength(1);
});
it("should fall back to token events when no history events exist", () => {
const tokens: TokenData[] = [
{
mint: "https://mint.example",
unit: "sat",
proofs: [{ id: "p1", amount: 64, secret: "s", C: "c" }],
eventId: "tok1",
createdAt: 1000,
},
];
const records = buildTransactionHistory(emptyWallet, tokens, [], [], []);
expect(records).toHaveLength(1);
expect(records[0].amount).toBe(64);
expect(records[0].type).toBe("receive");
});
it("should NOT use token events when history events exist", () => {
const tokens: TokenData[] = [
{
mint: "https://mint.example",
unit: "sat",
proofs: [{ id: "p1", amount: 64, secret: "s", C: "c" }],
eventId: "tok1",
createdAt: 1000,
},
];
const history: HistoryEntry[] = [
{
direction: "in",
amount: "100",
unit: "sat",
eventId: "evt1",
createdAt: 2000,
referencedEvents: [],
},
];
const records = buildTransactionHistory(
emptyWallet,
tokens,
history,
[],
[]
);
expect(records).toHaveLength(1);
expect(records[0].token_id).toBe("evt1");
});
it("should sort records by date ascending", () => {
const nutzaps: TransactionRecord[] = [
{
date: new Date(3000 * 1000).toISOString(),
type: "zap",
amount: 10,
unit: "sat",
mint: "",
token_id: "z1",
memo: "",
},
{
date: new Date(1000 * 1000).toISOString(),
type: "zap",
amount: 20,
unit: "sat",
mint: "",
token_id: "z2",
memo: "",
},
];
const records = buildTransactionHistory(
emptyWallet,
[],
[],
nutzaps,
[]
);
expect(records[0].token_id).toBe("z2");
expect(records[1].token_id).toBe("z1");
});
it("should handle non-numeric amount gracefully (defaults to 0)", () => {
const history: HistoryEntry[] = [
{
direction: "in",
amount: "not-a-number",
unit: "sat",
eventId: "evt1",
createdAt: 1000,
referencedEvents: [],
},
];
const records = buildTransactionHistory(emptyWallet, [], history, [], []);
expect(records[0].amount).toBe(0);
});
it("should merge nutzaps and redemptions with history", () => {
const history: HistoryEntry[] = [
{
direction: "in",
amount: "100",
unit: "sat",
eventId: "h1",
createdAt: 1000,
referencedEvents: [],
},
];
const nutzaps: TransactionRecord[] = [
{
date: new Date(2000 * 1000).toISOString(),
type: "zap",
amount: 50,
unit: "sat",
mint: "",
token_id: "z1",
memo: "",
},
];
const redemptions: TransactionRecord[] = [
{
date: new Date(3000 * 1000).toISOString(),
type: "receive",
amount: 50,
unit: "sat",
mint: "",
token_id: "r1",
memo: "nutzap redemption",
},
];
const records = buildTransactionHistory(
emptyWallet,
[],
history,
nutzaps,
redemptions
);
expect(records).toHaveLength(3);
expect(records[0].token_id).toBe("h1");
expect(records[1].token_id).toBe("z1");
expect(records[2].token_id).toBe("r1");
});
});