Skip to content

Commit b2de9c4

Browse files
committed
✅ server: add tests for declined transactions
1 parent 2f14466 commit b2de9c4

3 files changed

Lines changed: 495 additions & 26 deletions

File tree

.changeset/late-dingos-sit.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@exactly/server": patch
3+
---
4+
5+
✅ add tests for declined transactions

server/test/api/activity.test.ts

Lines changed: 115 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import "../mocks/sentry";
66

77
import { captureException } from "@sentry/node";
88
import { testClient } from "hono/testing";
9+
import assert from "node:assert";
910
import { safeParse, type InferOutput } from "valibot";
1011
import { padHex, zeroHash, type Hash } from "viem";
1112
import { privateKeyToAddress } from "viem/accounts";
@@ -18,6 +19,24 @@ import app, { CreditActivity, DebitActivity, InstallmentsActivity, PandaActivity
1819
import database, { cards, transactions } from "../../database";
1920
import anvilClient from "../anvilClient";
2021

22+
function httpSerialize<T>(object: T): T {
23+
const cloned = structuredClone(object);
24+
return removeUndefined(cloned) as T;
25+
}
26+
27+
function removeUndefined(object: unknown): unknown {
28+
if (object === null || typeof object !== "object") return object;
29+
if (Array.isArray(object)) return object.map((value) => removeUndefined(value));
30+
31+
const result: Record<string, unknown> = {};
32+
for (const [key, value] of Object.entries(object)) {
33+
if (value !== undefined) {
34+
result[key] = removeUndefined(value);
35+
}
36+
}
37+
return result;
38+
}
39+
2140
const appClient = testClient(app);
2241
const account = deriveAddress(inject("ExaAccountFactory"), {
2342
x: padHex(privateKeyToAddress(padHex("0xb0b"))),
@@ -66,26 +85,25 @@ describe.concurrent("authenticated", () => {
6685

6786
beforeAll(async () => {
6887
await database.insert(cards).values([{ id: "activity", credentialId: "bob", lastFour: "1234" }]);
69-
const logs = [
70-
...(await anvilClient.getContractEvents({
71-
abi: marketAbi,
72-
eventName: "BorrowAtMaturity",
73-
address: [inject("MarketEXA"), inject("MarketUSDC"), inject("MarketWETH")],
74-
args: { borrower: account },
75-
toBlock: "latest",
76-
fromBlock: 0n,
77-
strict: true,
78-
})),
79-
...(await anvilClient.getContractEvents({
80-
abi: marketAbi,
81-
eventName: "Withdraw",
82-
address: [inject("MarketEXA"), inject("MarketUSDC"), inject("MarketWETH")],
83-
args: { owner: account },
84-
toBlock: "latest",
85-
fromBlock: 0n,
86-
strict: true,
87-
})),
88-
];
88+
const cardLogs = await anvilClient.getContractEvents({
89+
abi: marketAbi,
90+
eventName: "BorrowAtMaturity",
91+
address: inject("MarketUSDC"),
92+
args: { borrower: account },
93+
toBlock: "latest",
94+
fromBlock: 0n,
95+
strict: true,
96+
});
97+
const withdrawLogs = await anvilClient.getContractEvents({
98+
abi: marketAbi,
99+
eventName: "Withdraw",
100+
address: inject("MarketUSDC"),
101+
args: { owner: account },
102+
toBlock: "latest",
103+
fromBlock: 0n,
104+
strict: true,
105+
});
106+
const logs = [...cardLogs, ...withdrawLogs];
89107
const timestamps = await Promise.all(
90108
[...new Set(logs.map(({ blockNumber }) => blockNumber))].map((blockNumber) =>
91109
anvilClient.getBlock({ blockNumber }),
@@ -126,7 +144,6 @@ describe.concurrent("authenticated", () => {
126144
createdAt,
127145
body: {
128146
id: String(index),
129-
type: "spend",
130147
spend: {
131148
...spendTemplate,
132149
amount: Number(total) / 1e4,
@@ -161,7 +178,10 @@ describe.concurrent("authenticated", () => {
161178
const panda = safeParse(PandaActivity, {
162179
...(payload as object),
163180
hashes,
164-
borrows: eventName === "Withdraw" ? [null] : [{ blockNumber, events }],
181+
borrows:
182+
eventName === "Withdraw"
183+
? hashes.map(() => null)
184+
: hashes.map((currentHash) => (currentHash === hash ? { timestamp: blockTimestamp, events } : null)),
165185
});
166186
if (panda.success) return panda.output;
167187
const eventCount = eventName === "Withdraw" ? 0 : events.length;
@@ -184,7 +204,7 @@ describe.concurrent("authenticated", () => {
184204
);
185205

186206
expect(response.status).toBe(200);
187-
await expect(response.json()).resolves.toStrictEqual(activity);
207+
await expect(response.json()).resolves.toMatchObject(httpSerialize(activity));
188208
});
189209

190210
it("reports bad transaction", async () => {
@@ -207,7 +227,7 @@ describe.concurrent("authenticated", () => {
207227
}),
208228
);
209229
expect(response.status).toBe(200);
210-
await expect(response.json()).resolves.toStrictEqual(activity);
230+
await expect(response.json()).resolves.toMatchObject(httpSerialize(activity));
211231
});
212232
});
213233

@@ -289,6 +309,77 @@ describe.concurrent("authenticated", () => {
289309
]),
290310
);
291311
});
312+
313+
describe("declined transactions", () => {
314+
it("parses declined transaction with created action", () => {
315+
const result = safeParse(PandaActivity, {
316+
type: "panda",
317+
hashes: [zeroHash],
318+
borrows: [null],
319+
bodies: [
320+
{
321+
action: "created",
322+
createdAt: "2024-01-15T10:30:00.000Z",
323+
status: "declined",
324+
reason: "insufficient funds",
325+
body: {
326+
id: "declined-tx-1",
327+
spend: {
328+
amount: 1000,
329+
currency: "usd",
330+
localAmount: 1000,
331+
localCurrency: "usd",
332+
merchantCity: "Buenos Aires",
333+
merchantCountry: "AR",
334+
merchantName: "Test Merchant",
335+
},
336+
},
337+
},
338+
],
339+
});
340+
341+
expect(result.success).toBe(true);
342+
assert.ok(result.success);
343+
expect(result.output.status).toBe("declined");
344+
expect(result.output.reason).toBe("insufficient funds");
345+
expect(result.output.timestamp).toBe("2024-01-15T10:30:00.000Z");
346+
expect(result.output.id).toBe("declined-tx-1");
347+
});
348+
349+
it("parses declined transaction with requested action normalized to created", () => {
350+
const result = safeParse(PandaActivity, {
351+
type: "panda",
352+
hashes: [zeroHash],
353+
borrows: [null],
354+
bodies: [
355+
{
356+
action: "requested",
357+
createdAt: "2024-01-15T11:00:00.000Z",
358+
status: "declined",
359+
reason: "merchant blocked",
360+
body: {
361+
id: "declined-tx-2",
362+
spend: {
363+
amount: 500,
364+
currency: "usd",
365+
localAmount: 500,
366+
localCurrency: "usd",
367+
merchantCity: "New York",
368+
merchantCountry: "US",
369+
merchantName: "Blocked Merchant",
370+
},
371+
},
372+
},
373+
],
374+
});
375+
376+
expect(result.success).toBe(true);
377+
assert.ok(result.success);
378+
expect(result.output.status).toBe("declined");
379+
expect(result.output.reason).toBe("merchant blocked");
380+
expect(result.output.timestamp).toBe("2024-01-15T11:00:00.000Z");
381+
});
382+
});
292383
});
293384

294385
vi.mock("@sentry/node", { spy: true });

0 commit comments

Comments
 (0)