Skip to content

Commit d64461a

Browse files
committed
test(payments): cover adapters and integration
1 parent c493117 commit d64461a

4 files changed

Lines changed: 887 additions & 0 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { HttpService } from "@nestjs/axios";
2+
import { ConfigService } from "@nestjs/config";
3+
import { Account, Keypair, Networks } from "@stellar/stellar-sdk";
4+
import { of } from "rxjs";
5+
import {
6+
CreatedPayment,
7+
IPaymentProcessor,
8+
PaymentRequest,
9+
PaymentStatus,
10+
} from "../../interfaces/payment-processor.interface";
11+
import { GrantfoxAdapter } from "../grantfox/grantfox.adapter";
12+
import { StellarAdapter } from "../stellar/stellar.adapter";
13+
14+
/**
15+
* Contract-compliance suite. Every adapter is driven through the full
16+
* create → sign → submit → getStatus → refund lifecycle against *mocked*
17+
* network clients, and each step's result is type/shape-checked against
18+
* {@link IPaymentProcessor}. Adding a new adapter here is the one place that
19+
* proves it honours the contract.
20+
*/
21+
22+
const VALID_STATUSES = new Set<string>(Object.values(PaymentStatus));
23+
24+
interface AdapterCase {
25+
name: string;
26+
build: () => IPaymentProcessor;
27+
request: PaymentRequest;
28+
}
29+
30+
function stellarCase(): AdapterCase {
31+
const signingKp = Keypair.random();
32+
const payerKp = Keypair.random();
33+
const destKp = Keypair.random();
34+
35+
const build = (): IPaymentProcessor => {
36+
const call = jest
37+
.fn()
38+
.mockResolvedValue({ hash: "TXHASH", successful: true });
39+
const server = {
40+
loadAccount: jest
41+
.fn()
42+
.mockResolvedValue(new Account(payerKp.publicKey(), "100")),
43+
submitTransaction: jest
44+
.fn()
45+
.mockResolvedValue({ hash: "TXHASH", successful: true }),
46+
transactions: jest
47+
.fn()
48+
.mockReturnValue({ transaction: jest.fn().mockReturnValue({ call }) }),
49+
payments: jest.fn().mockReturnValue({
50+
forTransaction: jest.fn().mockReturnValue({
51+
call: jest.fn().mockResolvedValue({
52+
records: [
53+
{
54+
type: "payment",
55+
amount: "10",
56+
from: payerKp.publicKey(),
57+
to: signingKp.publicKey(),
58+
asset_type: "native",
59+
},
60+
],
61+
}),
62+
}),
63+
}),
64+
};
65+
const config = {
66+
get: jest.fn((key: string, def?: unknown) =>
67+
key === "STELLAR_NETWORK_PASSPHRASE"
68+
? Networks.TESTNET
69+
: key === "STELLAR_SIGNING_SECRET"
70+
? signingKp.secret()
71+
: def,
72+
),
73+
} as unknown as ConfigService;
74+
return new StellarAdapter(server as any, config);
75+
};
76+
77+
return {
78+
name: "StellarAdapter",
79+
build,
80+
request: {
81+
amount: "10",
82+
currency: "XLM",
83+
destination: destKp.publicKey(),
84+
source: payerKp.publicKey(),
85+
idempotencyKey: "idem-stellar",
86+
},
87+
};
88+
}
89+
90+
function grantfoxCase(): AdapterCase {
91+
const build = (): IPaymentProcessor => {
92+
const http = {
93+
post: jest.fn((url: string) =>
94+
of(
95+
url.endsWith("/refund")
96+
? { data: { refundId: "rf_1", status: "refunded", amount: "10" } }
97+
: { data: { id: "gf_1", status: "processing" } },
98+
),
99+
),
100+
get: jest.fn(() =>
101+
of({
102+
data: {
103+
id: "gf_1",
104+
status: "confirmed",
105+
transactionHash: "0xabc",
106+
amount: "10",
107+
},
108+
}),
109+
),
110+
};
111+
const config = {
112+
get: jest.fn((key: string, def?: unknown) => {
113+
const env: Record<string, string> = {
114+
GRANTFOX_API_URL: "https://api.grantfox.example",
115+
GRANTFOX_API_KEY: "key",
116+
};
117+
return env[key] ?? def;
118+
}),
119+
} as unknown as ConfigService;
120+
return new GrantfoxAdapter(http as unknown as HttpService, config);
121+
};
122+
123+
return {
124+
name: "GrantfoxAdapter",
125+
build,
126+
request: {
127+
amount: "10",
128+
currency: "USD",
129+
destination: "acct_1",
130+
idempotencyKey: "idem-grantfox",
131+
},
132+
};
133+
}
134+
135+
const cases = [stellarCase(), grantfoxCase()];
136+
137+
describe.each(cases)("IPaymentProcessor contract: $name", (testCase) => {
138+
let adapter: IPaymentProcessor;
139+
140+
beforeEach(() => {
141+
adapter = testCase.build();
142+
});
143+
144+
it("exposes the full interface surface", () => {
145+
expect(typeof adapter.name).toBe("string");
146+
expect(typeof adapter.displayName).toBe("string");
147+
expect(adapter.capabilities).toEqual(
148+
expect.objectContaining({
149+
supportsPartialRefund: expect.any(Boolean),
150+
requiresClientSideSigning: expect.any(Boolean),
151+
currencies: expect.any(Array),
152+
}),
153+
);
154+
for (const method of [
155+
"initialize",
156+
"createPayment",
157+
"signTransaction",
158+
"submitTransaction",
159+
"getStatus",
160+
"refund",
161+
] as const) {
162+
expect(typeof adapter[method]).toBe("function");
163+
}
164+
});
165+
166+
it("runs a create → sign → submit → status → refund round-trip with typed results", async () => {
167+
const created: CreatedPayment = await adapter.createPayment(
168+
testCase.request,
169+
);
170+
expect(typeof created.paymentId).toBe("string");
171+
expect(VALID_STATUSES.has(created.status)).toBe(true);
172+
173+
const signed = await adapter.signTransaction(created);
174+
expect(signed.paymentId).toBe(created.paymentId);
175+
expect(typeof signed.signedPayload).toBe("string");
176+
177+
const submitted = await adapter.submitTransaction(signed);
178+
expect(typeof submitted.transactionHash).toBe("string");
179+
expect(VALID_STATUSES.has(submitted.status)).toBe(true);
180+
181+
const status = await adapter.getStatus(submitted.transactionHash);
182+
expect(VALID_STATUSES.has(status.status)).toBe(true);
183+
184+
const refund = await adapter.refund({
185+
paymentId: submitted.transactionHash,
186+
idempotencyKey: "refund-key",
187+
});
188+
expect(typeof refund.refundId).toBe("string");
189+
expect(typeof refund.refundedAmount).toBe("string");
190+
expect([
191+
PaymentStatus.REFUNDED,
192+
PaymentStatus.PARTIALLY_REFUNDED,
193+
]).toContain(refund.status);
194+
});
195+
196+
it("initialize() resolves without throwing", async () => {
197+
await expect(adapter.initialize()).resolves.toBeUndefined();
198+
});
199+
});
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { HttpService } from "@nestjs/axios";
2+
import {
3+
BadRequestException,
4+
ServiceUnavailableException,
5+
} from "@nestjs/common";
6+
import { ConfigService } from "@nestjs/config";
7+
import { of, throwError } from "rxjs";
8+
import { PaymentStatus } from "../../interfaces/payment-processor.interface";
9+
import { GrantfoxAdapter } from "./grantfox.adapter";
10+
11+
const API_URL = "https://api.grantfox.example";
12+
const API_KEY = "secret-key";
13+
14+
function makeConfig(overrides: Record<string, string> = {}): ConfigService {
15+
const env: Record<string, string> = {
16+
GRANTFOX_API_URL: API_URL,
17+
GRANTFOX_API_KEY: API_KEY,
18+
...overrides,
19+
};
20+
return {
21+
get: jest.fn((key: string, def?: unknown) => env[key] ?? def),
22+
} as unknown as ConfigService;
23+
}
24+
25+
describe("GrantfoxAdapter", () => {
26+
let http: { post: jest.Mock; get: jest.Mock };
27+
let adapter: GrantfoxAdapter;
28+
29+
beforeEach(() => {
30+
http = { post: jest.fn(), get: jest.fn() };
31+
adapter = new GrantfoxAdapter(http as unknown as HttpService, makeConfig());
32+
});
33+
34+
it("advertises its identity", () => {
35+
expect(adapter.name).toBe("grantfox");
36+
expect(adapter.displayName).toBe("Grantfox");
37+
});
38+
39+
describe("createPayment", () => {
40+
it("POSTs to /payments with auth + idempotency headers and maps the response", async () => {
41+
http.post.mockReturnValue(
42+
of({ data: { id: "gf_123", status: "processing" } }),
43+
);
44+
45+
const created = await adapter.createPayment({
46+
amount: "25.50",
47+
currency: "USD",
48+
destination: "acct_1",
49+
idempotencyKey: "idem-1",
50+
});
51+
52+
expect(http.post).toHaveBeenCalledTimes(1);
53+
const [url, body, config] = http.post.mock.calls[0];
54+
expect(url).toBe(`${API_URL}/payments`);
55+
expect(body).toMatchObject({ amount: "25.50", currency: "USD" });
56+
expect(config.headers.Authorization).toBe(`Bearer ${API_KEY}`);
57+
expect(config.headers["Idempotency-Key"]).toBe("idem-1");
58+
59+
expect(created.paymentId).toBe("gf_123");
60+
expect(created.status).toBe(PaymentStatus.PROCESSING);
61+
});
62+
63+
it("throws ServiceUnavailable when the API URL is not configured", async () => {
64+
const unconfigured = new GrantfoxAdapter(
65+
http as unknown as HttpService,
66+
makeConfig({ GRANTFOX_API_URL: "" }),
67+
);
68+
69+
await expect(
70+
unconfigured.createPayment({
71+
amount: "1",
72+
currency: "USD",
73+
destination: "acct_1",
74+
idempotencyKey: "idem-1",
75+
}),
76+
).rejects.toBeInstanceOf(ServiceUnavailableException);
77+
expect(http.post).not.toHaveBeenCalled();
78+
});
79+
});
80+
81+
describe("getStatus", () => {
82+
it("GETs /payments/:id and maps status + hash", async () => {
83+
http.get.mockReturnValue(
84+
of({
85+
data: {
86+
id: "gf_123",
87+
status: "confirmed",
88+
transactionHash: "0xabc",
89+
amount: "25.50",
90+
},
91+
}),
92+
);
93+
94+
const status = await adapter.getStatus("gf_123");
95+
96+
expect(http.get).toHaveBeenCalledWith(
97+
`${API_URL}/payments/gf_123`,
98+
expect.objectContaining({
99+
headers: expect.objectContaining({
100+
Authorization: `Bearer ${API_KEY}`,
101+
}),
102+
}),
103+
);
104+
expect(status.status).toBe(PaymentStatus.CONFIRMED);
105+
expect(status.transactionHash).toBe("0xabc");
106+
});
107+
});
108+
109+
describe("refund", () => {
110+
it("POSTs a full refund and maps REFUNDED", async () => {
111+
http.post.mockReturnValue(
112+
of({ data: { refundId: "rf_1", status: "refunded", amount: "25.50" } }),
113+
);
114+
115+
const result = await adapter.refund({
116+
paymentId: "gf_123",
117+
idempotencyKey: "r-1",
118+
});
119+
120+
const [url, body] = http.post.mock.calls[0];
121+
expect(url).toBe(`${API_URL}/payments/gf_123/refund`);
122+
expect(body).not.toHaveProperty("amount");
123+
expect(result.status).toBe(PaymentStatus.REFUNDED);
124+
expect(result.refundId).toBe("rf_1");
125+
});
126+
127+
it("forwards a partial amount in the refund body", async () => {
128+
http.post.mockReturnValue(
129+
of({
130+
data: { refundId: "rf_2", status: "partially_refunded", amount: "5" },
131+
}),
132+
);
133+
134+
const result = await adapter.refund({
135+
paymentId: "gf_123",
136+
amount: "5",
137+
idempotencyKey: "r-2",
138+
});
139+
140+
const [, body] = http.post.mock.calls[0];
141+
expect(body).toMatchObject({ amount: "5" });
142+
expect(result.status).toBe(PaymentStatus.PARTIALLY_REFUNDED);
143+
expect(result.refundedAmount).toBe("5");
144+
});
145+
});
146+
147+
describe("error mapping", () => {
148+
it("maps a 4xx upstream error to BadRequest", async () => {
149+
http.post.mockReturnValue(
150+
throwError(() => ({
151+
response: { status: 422, data: { message: "invalid destination" } },
152+
message: "Request failed with status code 422",
153+
})),
154+
);
155+
156+
await expect(
157+
adapter.createPayment({
158+
amount: "1",
159+
currency: "USD",
160+
destination: "bad",
161+
idempotencyKey: "idem-1",
162+
}),
163+
).rejects.toBeInstanceOf(BadRequestException);
164+
});
165+
166+
it("maps a 5xx upstream error to ServiceUnavailable", async () => {
167+
http.get.mockReturnValue(
168+
throwError(() => ({
169+
response: { status: 503, data: {} },
170+
message: "Service Unavailable",
171+
})),
172+
);
173+
174+
await expect(adapter.getStatus("gf_123")).rejects.toBeInstanceOf(
175+
ServiceUnavailableException,
176+
);
177+
});
178+
});
179+
});

0 commit comments

Comments
 (0)