diff options
| author | Adam Soltys <[email protected]> | 2026-02-05 16:44:47 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-06 09:00:10 -0800 |
| commit | 4e9c0c5198c7884cfd97cb4495f6156632e3124a (patch) | |
| tree | ce8144806d7f88a7c3084eca3fd9669f13df54af /test | |
| parent | 635ca5ad96c791bdd229f50c138b4f3292ae29ad (diff) | |
| download | coinos-server-4e9c0c5198c7884cfd97cb4495f6156632e3124a.tar.gz coinos-server-4e9c0c5198c7884cfd97cb4495f6156632e3124a.zip | |
payment forwarding
Diffstat (limited to 'test')
| -rw-r--r-- | test/forward.test.ts | 369 | ||||
| -rw-r--r-- | test/preload.ts | 262 |
2 files changed, 631 insertions, 0 deletions
diff --git a/test/forward.test.ts b/test/forward.test.ts new file mode 100644 index 00000000..55e88a3e --- /dev/null +++ b/test/forward.test.ts @@ -0,0 +1,369 @@ +import { describe, test, expect, beforeEach } from "bun:test"; + +// Mocks are loaded via test/preload.ts (bunfig.toml [test].preload) +// Access the mock functions we need for assertions +import { tbCredit } from "$lib/tb"; +import { warn } from "$lib/logging"; +import { callWebhook } from "$lib/webhooks"; +import ln from "$lib/ln"; + +const mockTbCredit = tbCredit as any; +const mockWarn = warn as any; +const mockCallWebhook = callWebhook as any; +const mockLnXpay = ln.xpay as any; + +import { completePayment } from "$lib/payments"; +import routes from "$routes/payments"; + +// ===================================================================== +// Helpers +// ===================================================================== + +const store = () => globalThis.__testStore; + +const custodialUid = "custodial-uid"; +const arkAccountId = "ark-account-123"; +const bolt11 = "lnbcrt10u1pntestqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"; + +const makeUser = (overrides = {}) => ({ + id: custodialUid, + username: "testuser", + currency: "USD", + language: "en", + autowithdraw: false, + threshold: 0, + reserve: 0, + destination: "", + ...overrides, +}); + +const makeInvoice = (overrides: any = {}) => ({ + id: "inv-001", + uid: custodialUid, + aid: custodialUid, + type: "ark", + currency: "USD", + amount: 1000, + received: 0, + pending: 0, + rate: 50000, + forward: null as string | null, + forwarded: false, + hash: "inv-hash-001", + memo: "", + tip: null, + webhook: null, + secret: null, + ...overrides, +}); + +const seedInvoice = (inv: any) => { + store().kvStore[`invoice:${inv.id}`] = JSON.stringify(inv); + store().kvStore[`invoice:${inv.hash}`] = inv.id; +}; + +const seedUser = (user: any) => { + store().kvStore[`user:${user.username}`] = JSON.stringify(user); + store().kvStore[`user:${user.id}`] = JSON.stringify(user); +}; + +const resetAll = () => { + const s = store(); + for (const k of Object.keys(s.kvStore)) delete s.kvStore[k]; + for (const k of Object.keys(s.listStore)) delete s.listStore[k]; + for (const k of Object.keys(s.setStore)) delete s.setStore[k]; + + mockTbCredit.mockClear(); + mockWarn.mockClear(); + mockCallWebhook.mockClear(); + mockLnXpay.mockClear(); + + mockLnXpay.mockImplementation(async () => ({ + amount_sent_msat: 1_000_000, + payment_preimage: "preimage-abc123", + })); + + // Seed rates and limits + s.kvStore["rates"] = JSON.stringify({ USD: 50000, CAD: 68000 }); + s.kvStore["limit"] = JSON.stringify(100_000_000); + s.kvStore["lightning:limit"] = JSON.stringify(100_000_000); + s.kvStore["internal:limit"] = JSON.stringify(100_000_000); + s.kvStore["freeze"] = JSON.stringify(false); + s.kvStore["hardfreeze"] = JSON.stringify(false); + + seedUser(makeUser()); +}; + +const makeRes = () => { + let sent: any; + return { + send: (data: any) => { sent = data; }, + code: (code: number) => ({ + send: (data: any) => { sent = data; }, + }), + getSent: () => sent, + }; +}; + +// ===================================================================== +// Tests: completePayment forward logic +// ===================================================================== + +describe("completePayment", () => { + beforeEach(resetAll); + + test("forwards payment and debit stays in custodial", async () => { + const user = makeUser(); + const inv = makeInvoice({ aid: arkAccountId, forward: bolt11 }); + const p = { amount: 1000, confirmed: true, type: "ark", uid: custodialUid, created: Date.now() }; + + const w: any = await completePayment(inv, p, user); + + expect(w).toBeTruthy(); + expect(w.amount).toBeLessThan(0); + expect(w.type).toBe("lightning"); + + // Debit stays in custodial list + expect(store().listStore[`${custodialUid}:payments`] ?? []).toContain(w.id); + + expect(inv.forwarded).toBe(true); + expect(mockCallWebhook).toHaveBeenCalled(); + }); + + test("forwards and debit stays in custodial when aid matches user", async () => { + const user = makeUser(); + const inv = makeInvoice({ aid: custodialUid, forward: bolt11 }); + const p = { amount: 1000, confirmed: true, type: "ark", uid: custodialUid, created: Date.now() }; + + const w: any = await completePayment(inv, p, user); + + expect(w).toBeTruthy(); + expect(w.amount).toBeLessThan(0); + expect(store().listStore[`${custodialUid}:payments`] ?? []).toContain(w.id); + expect(inv.forwarded).toBe(true); + }); + + test("handles forward failure gracefully", async () => { + const user = makeUser(); + const inv = makeInvoice({ aid: arkAccountId, forward: bolt11 }); + const p = { amount: 1000, confirmed: true, type: "ark", uid: custodialUid, created: Date.now() }; + + mockLnXpay.mockImplementationOnce(async () => { throw new Error("route not found"); }); + + const w = await completePayment(inv, p, user); + + expect(w).toBeUndefined(); + expect(mockWarn).toHaveBeenCalled(); + expect(mockWarn.mock.calls.some((c: any[]) => c.includes("forward failed"))).toBe(true); + }); + + test("skips forward when already forwarded", async () => { + const user = makeUser(); + const inv = makeInvoice({ aid: arkAccountId, forward: bolt11, forwarded: true }); + const p = { amount: 1000, confirmed: true, type: "ark", uid: custodialUid, created: Date.now() }; + + const w = await completePayment(inv, p, user); + + expect(w).toBeUndefined(); + expect(mockLnXpay).not.toHaveBeenCalled(); + }); + + test("skips forward when payment not confirmed", async () => { + const user = makeUser(); + const inv = makeInvoice({ aid: arkAccountId, forward: bolt11 }); + const p = { amount: 1000, confirmed: false, type: "ark", uid: custodialUid, created: Date.now() }; + + const w = await completePayment(inv, p, user); + + expect(w).toBeUndefined(); + expect(mockLnXpay).not.toHaveBeenCalled(); + }); +}); + +// ===================================================================== +// Tests: arkReceive route handler +// ===================================================================== + +describe("arkReceive", () => { + beforeEach(resetAll); + + test("credits custodial and forwards to Lightning", async () => { + const inv = makeInvoice({ aid: arkAccountId, forward: bolt11 }); + seedInvoice(inv); + + const req = { + body: { iid: inv.id, amount: 1000, hash: "ark-txid-999" }, + user: makeUser(), + }; + const res = makeRes(); + + await routes.arkReceive(req as any, res as any); + const sent = (res as any).getSent(); + + // Response is the custodial credit record + expect(sent).toBeTruthy(); + expect(sent.amount).toBe(1000); + expect(sent.aid).toBe(custodialUid); + + // tbCredit called for deposit + expect(mockTbCredit).toHaveBeenCalledWith(custodialUid, custodialUid, inv.type, 1000, false); + + // Ark txid mapped for dedup + expect(store().kvStore[`payment:${arkAccountId}:ark-txid-999`]).toBeTruthy(); + + // Credit record in custodial list + expect(store().listStore[`${custodialUid}:payments`] ?? []).toContain(sent.id); + + // Forward triggered Lightning send + expect(mockLnXpay).toHaveBeenCalled(); + }); + + test("credits custodial without forward", async () => { + const inv = makeInvoice({ aid: arkAccountId }); + seedInvoice(inv); + + const req = { + body: { iid: inv.id, amount: 1000, hash: "ark-txid-888" }, + user: makeUser(), + }; + const res = makeRes(); + + await routes.arkReceive(req as any, res as any); + const sent = (res as any).getSent(); + + expect(sent).toBeTruthy(); + expect(sent.amount).toBe(1000); + expect(sent.aid).toBe(custodialUid); + + // Always credits custodial TB + expect(mockTbCredit).toHaveBeenCalledWith(custodialUid, custodialUid, inv.type, 1000, false); + // Record in custodial list + expect(store().listStore[`${custodialUid}:payments`] ?? []).toContain(sent.id); + }); + + test("credits custodial even when forward fails", async () => { + const inv = makeInvoice({ aid: arkAccountId, forward: bolt11 }); + seedInvoice(inv); + + mockLnXpay.mockImplementationOnce(async () => { throw new Error("no route"); }); + + const req = { + body: { iid: inv.id, amount: 1000, hash: "ark-txid-777" }, + user: makeUser(), + }; + const res = makeRes(); + + await routes.arkReceive(req as any, res as any); + const sent = (res as any).getSent(); + + // Credit still lands in custodial even though forward failed + expect(sent).toBeTruthy(); + expect(sent.amount).toBe(1000); + expect(sent.aid).toBe(custodialUid); + + expect(mockTbCredit).toHaveBeenCalledWith(custodialUid, custodialUid, inv.type, 1000, false); + expect(store().listStore[`${custodialUid}:payments`] ?? []).toContain(sent.id); + }); + + test("rejects invalid amount", async () => { + const inv = makeInvoice(); + seedInvoice(inv); + + const req = { + body: { iid: inv.id, amount: 0, hash: "ark-txid-000" }, + user: makeUser(), + }; + const res = makeRes(); + + await routes.arkReceive(req as any, res as any); + // Should error - check that send wasn't called with a valid payment + const sent = (res as any).getSent(); + expect(sent).toBeDefined(); + }); + + test("rejects unauthorized user", async () => { + const inv = makeInvoice({ uid: "other-user-id" }); + seedInvoice(inv); + + const req = { + body: { iid: inv.id, amount: 1000, hash: "ark-txid-666" }, + user: makeUser(), + }; + const res = makeRes(); + + await routes.arkReceive(req as any, res as any); + const sent = (res as any).getSent(); + expect(sent).toBeDefined(); + }); +}); + +// ===================================================================== +// Tests: arkSync skips change VTXOs +// ===================================================================== + +describe("arkSync", () => { + beforeEach(resetAll); + + test("skips received/change VTXOs (positive amounts)", async () => { + const req = { + body: { + aid: arkAccountId, + transactions: [ + { hash: "change-vtxo-hash", amount: 9000, settled: true, createdAt: Date.now() }, + ], + }, + user: makeUser(), + }; + const res = makeRes(); + + await routes.arkSync(req as any, res as any); + const sent = (res as any).getSent(); + + expect(sent.synced).toBe(0); + expect(store().listStore[`${arkAccountId}:payments`] ?? []).toHaveLength(0); + }); + + test("records sent transactions (negative amounts)", async () => { + const req = { + body: { + aid: arkAccountId, + transactions: [ + { hash: "send-tx-hash", amount: -1000, settled: true, createdAt: Date.now() }, + ], + }, + user: makeUser(), + }; + const res = makeRes(); + + await routes.arkSync(req as any, res as any); + const sent = (res as any).getSent(); + + expect(sent.synced).toBe(1); + expect((store().listStore[`${arkAccountId}:payments`] ?? []).length).toBe(1); + }); + + test("deduplicates known transactions", async () => { + // Pre-store a known tx hash + store().kvStore[`payment:${arkAccountId}:known-hash`] = JSON.stringify("existing-id"); + store().kvStore["payment:existing-id"] = JSON.stringify({ id: "existing-id", confirmed: false }); + + const req = { + body: { + aid: arkAccountId, + transactions: [ + { hash: "known-hash", amount: -1000, settled: true, createdAt: Date.now() }, + ], + }, + user: makeUser(), + }; + const res = makeRes(); + + await routes.arkSync(req as any, res as any); + const sent = (res as any).getSent(); + + expect(sent.synced).toBe(0); + // Existing record should be updated to confirmed + const updated = JSON.parse(store().kvStore["payment:existing-id"]); + expect(updated.confirmed).toBe(true); + }); +}); diff --git a/test/preload.ts b/test/preload.ts new file mode 100644 index 00000000..0704561a --- /dev/null +++ b/test/preload.ts @@ -0,0 +1,262 @@ +import { mock } from "bun:test"; + +// This file is loaded before test files via bunfig.toml [test].preload +// Mocks must be set up here to intercept transitive imports + +let kvStore: Record<string, string> = {}; +let listStore: Record<string, string[]> = {}; +let setStore: Record<string, Set<string>> = {}; + +globalThis.__testStore = { kvStore, listStore, setStore }; + +const makeMulti = () => { + const ops: Array<() => Promise<any>> = []; + const chain: any = { + set: (k: string, v: string) => { + ops.push(async () => { globalThis.__testStore.kvStore[k] = v; }); + return chain; + }, + lPush: (k: string, v: string) => { + ops.push(async () => { + const ls = globalThis.__testStore.listStore; + if (!ls[k]) ls[k] = []; + ls[k].unshift(v); + }); + return chain; + }, + del: (k: string) => { + ops.push(async () => { delete globalThis.__testStore.kvStore[k]; }); + return chain; + }, + lRem: (k: string, _count: number, v: string) => { + ops.push(async () => { + const ls = globalThis.__testStore.listStore; + if (ls[k]) { + const idx = ls[k].indexOf(v); + if (idx >= 0) ls[k].splice(idx, 1); + } + }); + return chain; + }, + sRem: (k: string, v: string) => { + ops.push(async () => globalThis.__testStore.setStore[k]?.delete(v)); + return chain; + }, + exec: async () => { + for (const op of ops) await op(); + return []; + }, + }; + return chain; +}; + +mock.module("$lib/db", () => { + const kv = () => globalThis.__testStore.kvStore; + const ls = () => globalThis.__testStore.listStore; + const ss = () => globalThis.__testStore.setStore; + const g = async (k: string) => { + const v = kv()[k] ?? null; + if (v === null) return null; + try { return JSON.parse(v); } catch { return v; } + }; + const s = (k: string, v: any) => { + if (k === "user:null" || k === "user:undefined") throw new Error("null user"); + kv()[k] = JSON.stringify(v); + }; + const gf = async (k: string) => { + const v = kv()[k] ?? null; + if (v === null) return null; + try { return JSON.parse(v); } catch { return v; } + }; + return { + db: { + get: async (k: string) => kv()[k] ?? null, + set: async (k: string, v: string) => { kv()[k] = v; return "OK"; }, + del: async (k: string) => { delete kv()[k]; return 1; }, + exists: async (k: string) => (kv()[k] !== undefined ? 1 : 0), + lPush: async (k: string, v: string) => { + if (!ls()[k]) ls()[k] = []; + ls()[k].unshift(v); + return ls()[k].length; + }, + lRem: async (k: string, _count: number, v: string) => { + if (!ls()[k]) return 0; + const idx = ls()[k].indexOf(v); + if (idx >= 0) { ls()[k].splice(idx, 1); return 1; } + return 0; + }, + lRange: async (k: string, start: number, end: number) => { + if (!ls()[k]) return []; + if (end === -1) return ls()[k].slice(start); + return ls()[k].slice(start, end + 1); + }, + lPos: async (k: string, v: string) => { + if (!ls()[k]) return null; + const idx = ls()[k].indexOf(v); + return idx >= 0 ? idx : null; + }, + sAdd: async (k: string, ...vals: string[]) => { + if (!ss()[k]) ss()[k] = new Set(); + for (const v of vals) ss()[k].add(v); + return vals.length; + }, + sRem: async (k: string, v: string) => ss()[k]?.delete(v) ? 1 : 0, + sIsMember: async (k: string, v: string) => ss()[k]?.has(v) ?? false, + sMembers: async (k: string) => (ss()[k] ? [...ss()[k]] : []), + incrBy: async (k: string, n: number) => { + const v = Number.parseInt(kv()[k] || "0") + n; + kv()[k] = String(v); + return v; + }, + watch: async () => {}, + multi: makeMulti, + expire: async () => 1, + setNX: async (k: string, v: string) => { + if (kv()[k]) return false; + kv()[k] = v; + return true; + }, + zScore: async () => null, + zAdd: async () => 1, + zCard: async () => 0, + zRemRangeByRank: async () => 0, + keys: async () => [], + type: async () => "none", + }, + g, s, gf, + ga: async () => null, + archive: { lRange: async () => [] }, + }; +}); + +mock.module("$config", () => ({ + default: { + bitcoin: { host: "localhost", wallet: "test", user: "u", password: "p", network: "regtest", port: 18443 }, + liquid: { host: "localhost", wallet: "test", user: "u", password: "p", btc: "test-asset", port: 7040 }, + lightning: "/dev/null", + fee: { bitcoin: 0.004, liquid: 0.001, lightning: 0.001 }, + adminpass: "test", + ark: { arkPrivateKey: "0000", arkServerUrl: "http://localhost" }, + nostr: "ws://localhost:7777", + tigerbeetle: { cluster_id: 0n, replica_addresses: ["localhost:3000"] }, + vapid: { pk: "test", sk: "test" }, + support: "[email protected]", + txWebhookSecret: "test", + }, +})); + +mock.module("$lib/tb", () => ({ + getBalance: mock(async () => 10_000_000), + getCredit: mock(async () => 0), + tbDebit: mock(async () => 0), + tbCredit: mock(async () => undefined), + tbRefund: mock(async () => undefined), + tbReverse: mock(async () => undefined), + tbConfirm: mock(async () => undefined), + tbSetBalance: mock(async () => undefined), + tbSetPending: mock(async () => undefined), + fundDebit: mock(async () => ({ err: null })), + initTigerBeetle: mock(async () => {}), +})); + +mock.module("$lib/ln", () => ({ + default: { + decode: mock(async () => ({ type: "bolt11", amount_msat: 1_000_000, payee: "test-payee" })), + listpeerchannels: mock(async () => ({ channels: [] })), + listpays: mock(async () => ({ pays: [] })), + xpay: mock(async () => ({ amount_sent_msat: 1_000_000, payment_preimage: "preimage-abc" })), + getinfo: mock(async () => ({ id: "our-node-id" })), + listinvoices: mock(async () => ({ invoices: [] })), + listfunds: mock(async () => ({ channels: [] })), + keysend: mock(async () => ({})), + fetchinvoice: mock(async () => ({})), + sendinvoice: mock(async () => ({})), + }, +})); + +mock.module("$lib/logging", () => ({ l: () => {}, warn: mock(() => {}), err: () => {} })); +mock.module("$lib/notifications", () => ({ notify: () => {}, nwcNotify: () => {} })); +mock.module("$lib/webhooks", () => ({ callWebhook: mock(() => {}) })); +mock.module("$lib/sockets", () => ({ emit: () => {}, sendHeartbeat: () => {} })); +mock.module("$lib/square", () => ({ squarePayment: () => {} })); +mock.module("$lib/nostr", () => ({ + handleZap: async () => {}, publish: async () => {}, + serverPubkey: "m", serverPubkey2: "m", serverSecret: "m", serverSecret2: "m", +})); +mock.module("$lib/mqtt", () => ({ default: { publish: () => {} } })); +mock.module("$lib/ark", () => ({ + getArkAddress: async () => "ark-addr", sendArk: async () => "ark-txid", getArkBalance: async () => 0, +})); +mock.module("$lib/ecash", () => ({ request: async () => ({}) })); +mock.module("$lib/lightning", () => ({ + replay: async () => ({}), fixBolt12: () => {}, listenForLightning: () => {}, +})); +mock.module("$lib/mail", () => ({ mail: async () => {}, templates: {} })); +mock.module("$lib/auth", () => ({ + requirePin: async () => {}, auth: (_r: any, _s: any, n: any) => n(), optional: (_r: any, _s: any, n: any) => n(), admin: (_r: any, _s: any, n: any) => n(), +})); +mock.module("$lib/invoices", () => ({ + generate: mock(async ({ invoice, user }: any) => ({ + id: "gen-inv", hash: "gen-hash", uid: user?.id, received: 0, pending: 0, ...invoice, + })), +})); +mock.module("$lib/api", () => ({ default: { bitcoin: "http://localhost", liquid: "http://localhost" } })); +mock.module("$lib/store", () => ({ default: { rates: { USD: 50000 } } })); +mock.module("@coinos/rpc", () => ({ + default: () => new Proxy({}, { get: () => async () => ({}) }), +})); + +mock.module("$lib/utils", () => { + const SATS = 100_000_000; + const kv = () => globalThis.__testStore.kvStore; + return { + SATS, + btc: (n: number) => Number.parseFloat((n / SATS).toFixed(8)), + fail: (msg: string) => { throw new Error(msg); }, + fmt: (n: number) => String(n), + formatReceipt: () => {}, + getInvoice: async (hash: string) => { + const raw = kv()[`invoice:${hash}`] ?? null; + if (!raw) return null; + let iid; + try { iid = JSON.parse(raw); } catch { iid = raw; } + if (iid?.id) iid = iid.id; + else if (iid?.hash) iid = iid.hash; + const raw2 = kv()[`invoice:${iid}`] ?? null; + if (!raw2) return null; + try { return JSON.parse(raw2); } catch { return raw2; } + }, + getPayment: async (id: string) => { + const raw = kv()[`payment:${id}`]; + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (typeof parsed === "string") { + const p = kv()[`payment:${parsed}`]; + return p ? JSON.parse(p) : null; + } + return parsed; + } catch { return null; } + }, + getUser: async (username: string) => { + const raw = kv()[`user:${username}`]; + return raw ? JSON.parse(raw) : null; + }, + getAccount: async () => null, + link: (id: string) => `http://test/${id}`, + sats: (n: number) => Math.round(n * SATS), + sleep: async () => {}, + t: () => ({ insufficientFunds: "Insufficient funds" }), + bail: (res: any, msg: string) => res.code(500).send(msg), + bip21: () => "", + fields: [], + nada: () => {}, + fiat: (n: number, r: number) => (n * r) / SATS, + f: (s: any) => String(s), + pick: (O: any, K: string[]) => K.reduce((o: any, k: string) => ((o[k] = O[k]), o), {}), + prod: false, + uniq: (a: any[], k: any) => [...new Map(a.map((x: any) => [k(x), x])).values()], + wait: async () => {}, + time: () => ({ start: () => {}, end: () => {} }), + }; +}); |
