@gr4vy/sdk 2.1.2 → 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/jsr.json +1 -1
- package/lib/config.d.ts +2 -2
- package/lib/config.js +2 -2
- package/package.json +4 -2
- package/scripts/endpoint-coverage.mjs +136 -0
- package/src/lib/config.ts +2 -2
- package/tests/backoffice/account-updater.test.ts +24 -0
- package/tests/backoffice/audit-logs.test.ts +20 -0
- package/tests/backoffice/merchant-accounts.test.ts +77 -0
- package/tests/backoffice/payment-services.test.ts +95 -0
- package/tests/backoffice/payouts.test.ts +49 -0
- package/tests/backoffice/reports.test.ts +69 -0
- package/tests/backoffice/three-ds-scenarios.test.ts +34 -0
- package/tests/flows/buyer-lifecycle.test.ts +74 -0
- package/tests/flows/transaction-lifecycle.test.ts +151 -0
- package/tests/processing/buyers.test.ts +119 -0
- package/tests/processing/checkout-sessions.test.ts +99 -0
- package/tests/processing/digital-wallets.test.ts +154 -0
- package/tests/processing/gift-cards.test.ts +45 -0
- package/tests/processing/payment-links.test.ts +34 -0
- package/tests/processing/payment-methods.test.ts +121 -0
- package/tests/processing/transactions.test.ts +118 -0
- package/tests/utils/arbitraries.ts +87 -0
- package/tests/utils/fields.ts +65 -0
- package/tests/utils/fixtures.ts +84 -0
- package/tests/utils/poll.ts +40 -0
- package/tests/utils/setup.ts +66 -10
- package/tests/utils/transactions.ts +26 -0
- package/vitest.config.ts +31 -0
- package/tests/checkout-sessions.test.ts +0 -109
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, test } from "vitest";
|
|
2
|
+
import { Gr4vy } from "../../src";
|
|
3
|
+
import { buyer, cardPaymentMethod } from "../utils/fixtures";
|
|
4
|
+
import { setupMerchant } from "../utils/setup";
|
|
5
|
+
|
|
6
|
+
let gr4vy: Gr4vy;
|
|
7
|
+
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
({ client: gr4vy } = await setupMerchant());
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const storeCard = () => gr4vy.paymentMethods.create(cardPaymentMethod());
|
|
13
|
+
|
|
14
|
+
describe("Payment Methods", () => {
|
|
15
|
+
test("create → get → list → delete", async () => {
|
|
16
|
+
const created = await storeCard();
|
|
17
|
+
expect(created.id).toBeDefined();
|
|
18
|
+
expect(created.method).toBe("card");
|
|
19
|
+
|
|
20
|
+
const fetched = await gr4vy.paymentMethods.get(created.id);
|
|
21
|
+
expect(fetched.id).toBe(created.id);
|
|
22
|
+
|
|
23
|
+
const page = await gr4vy.paymentMethods.list({});
|
|
24
|
+
expect(page).toBeDefined();
|
|
25
|
+
|
|
26
|
+
await gr4vy.paymentMethods.delete(created.id);
|
|
27
|
+
await expect(() => gr4vy.paymentMethods.get(created.id)).rejects.toThrow();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("update changes only the expiration date (partial)", async () => {
|
|
31
|
+
const created = await storeCard();
|
|
32
|
+
const updated = await gr4vy.paymentMethods.update(
|
|
33
|
+
{ expirationDate: "12/30" },
|
|
34
|
+
created.id
|
|
35
|
+
);
|
|
36
|
+
expect(updated.id).toBe(created.id);
|
|
37
|
+
expect(updated.expirationDate).toBe("12/30");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("can be stored against a buyer", async () => {
|
|
41
|
+
const owner = await gr4vy.buyers.create(buyer());
|
|
42
|
+
const method = await gr4vy.paymentMethods.create({
|
|
43
|
+
...cardPaymentMethod(),
|
|
44
|
+
buyerId: owner.id,
|
|
45
|
+
});
|
|
46
|
+
expect(method.buyer?.id).toBe(owner.id);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("network tokens", () => {
|
|
50
|
+
test("listing tokens for a fresh card returns an empty collection", async () => {
|
|
51
|
+
const card = await storeCard();
|
|
52
|
+
const tokens = await gr4vy.paymentMethods.networkTokens.list(card.id);
|
|
53
|
+
expect(Array.isArray(tokens.items)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Provisioning a network token (and the suspend/resume/delete/cryptogram
|
|
57
|
+
// operations that follow it) requires the merchant account to be onboarded
|
|
58
|
+
// with the card schemes (Visa/Mastercard requestor IDs) — not available on
|
|
59
|
+
// the mock merchant. Each call is asserted to be well-formed and rejected by
|
|
60
|
+
// the API for a real reason rather than silently skipping coverage.
|
|
61
|
+
test("provision / suspend / resume / delete / cryptogram are exercised at the request level", async () => {
|
|
62
|
+
const card = await storeCard();
|
|
63
|
+
const bogusToken = "00000000-0000-0000-0000-000000000000";
|
|
64
|
+
|
|
65
|
+
await expect(
|
|
66
|
+
gr4vy.paymentMethods.networkTokens.create(
|
|
67
|
+
{ merchantInitiated: true, isSubsequentPayment: false },
|
|
68
|
+
card.id
|
|
69
|
+
)
|
|
70
|
+
).rejects.toThrow();
|
|
71
|
+
|
|
72
|
+
await expect(
|
|
73
|
+
gr4vy.paymentMethods.networkTokens.suspend(card.id, bogusToken)
|
|
74
|
+
).rejects.toThrow();
|
|
75
|
+
await expect(
|
|
76
|
+
gr4vy.paymentMethods.networkTokens.resume(card.id, bogusToken)
|
|
77
|
+
).rejects.toThrow();
|
|
78
|
+
await expect(
|
|
79
|
+
gr4vy.paymentMethods.networkTokens.delete(card.id, bogusToken)
|
|
80
|
+
).rejects.toThrow();
|
|
81
|
+
await expect(
|
|
82
|
+
gr4vy.paymentMethods.networkTokens.cryptogram.create(
|
|
83
|
+
{ merchantInitiated: true },
|
|
84
|
+
card.id,
|
|
85
|
+
bogusToken
|
|
86
|
+
)
|
|
87
|
+
).rejects.toThrow();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("payment service tokens", () => {
|
|
92
|
+
test("listing service tokens for a fresh card returns a collection", async () => {
|
|
93
|
+
const card = await storeCard();
|
|
94
|
+
const tokens = await gr4vy.paymentMethods.paymentServiceTokens.list(
|
|
95
|
+
card.id
|
|
96
|
+
);
|
|
97
|
+
expect(Array.isArray(tokens.items)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Creating/deleting a service token requires a tokenization-capable service,
|
|
101
|
+
// which mock-card is not; exercise the call shape at the request level.
|
|
102
|
+
test("create and delete are exercised at the request level", async () => {
|
|
103
|
+
const card = await storeCard();
|
|
104
|
+
await expect(
|
|
105
|
+
gr4vy.paymentMethods.paymentServiceTokens.create(
|
|
106
|
+
{
|
|
107
|
+
paymentServiceId: "00000000-0000-0000-0000-000000000000",
|
|
108
|
+
redirectUrl: "https://example.com/return",
|
|
109
|
+
},
|
|
110
|
+
card.id
|
|
111
|
+
)
|
|
112
|
+
).rejects.toThrow();
|
|
113
|
+
await expect(
|
|
114
|
+
gr4vy.paymentMethods.paymentServiceTokens.delete(
|
|
115
|
+
card.id,
|
|
116
|
+
"00000000-0000-0000-0000-000000000000"
|
|
117
|
+
)
|
|
118
|
+
).rejects.toThrow();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fc from "fast-check";
|
|
2
|
+
import { beforeAll, describe, expect, test } from "vitest";
|
|
3
|
+
import { Gr4vy } from "../../src";
|
|
4
|
+
import { amount, currency, fcParams, metadata } from "../utils/arbitraries";
|
|
5
|
+
import { buyer, cardPaymentMethod, uniqueId } from "../utils/fixtures";
|
|
6
|
+
import { authorizeViaCheckoutSession } from "../utils/transactions";
|
|
7
|
+
import { setupMerchant } from "../utils/setup";
|
|
8
|
+
|
|
9
|
+
let gr4vy: Gr4vy;
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
({ client: gr4vy } = await setupMerchant());
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// There are two ways to start processing a payment:
|
|
16
|
+
// 1. via a checkout session (covered throughout — see the lifecycle flow and
|
|
17
|
+
// the helper authorizeViaCheckoutSession), and
|
|
18
|
+
// 2. by posting payment-method details directly to POST /transactions.
|
|
19
|
+
// This block covers entry point (2) — both raw card details and a stored token.
|
|
20
|
+
describe("Transaction entry points", () => {
|
|
21
|
+
test("creates a transaction with card details posted directly", async () => {
|
|
22
|
+
const transaction = await gr4vy.transactions.create({
|
|
23
|
+
amount: 1299,
|
|
24
|
+
currency: "USD",
|
|
25
|
+
externalIdentifier: uniqueId("txn"),
|
|
26
|
+
paymentMethod: cardPaymentMethod(),
|
|
27
|
+
});
|
|
28
|
+
expect(transaction.status).toBe("authorization_succeeded");
|
|
29
|
+
expect(transaction.amount).toBe(1299);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("direct card details can be vaulted against a buyer with store=true", async () => {
|
|
33
|
+
const owner = await gr4vy.buyers.create(buyer());
|
|
34
|
+
|
|
35
|
+
const transaction = await gr4vy.transactions.create({
|
|
36
|
+
amount: 1299,
|
|
37
|
+
currency: "USD",
|
|
38
|
+
buyerId: owner.id,
|
|
39
|
+
store: true,
|
|
40
|
+
externalIdentifier: uniqueId("txn"),
|
|
41
|
+
paymentMethod: cardPaymentMethod(),
|
|
42
|
+
});
|
|
43
|
+
expect(transaction.status).toBe("authorization_succeeded");
|
|
44
|
+
|
|
45
|
+
// The vaulted method is now reusable as a stored token.
|
|
46
|
+
const stored = await gr4vy.buyers.paymentMethods.list({ buyerId: owner.id });
|
|
47
|
+
expect(stored.items.length).toBeGreaterThanOrEqual(1);
|
|
48
|
+
|
|
49
|
+
const reused = await gr4vy.transactions.create({
|
|
50
|
+
amount: 500,
|
|
51
|
+
currency: "USD",
|
|
52
|
+
externalIdentifier: uniqueId("txn"),
|
|
53
|
+
paymentMethod: { method: "id", id: stored.items[0]!.id, securityCode: "123" },
|
|
54
|
+
});
|
|
55
|
+
expect(reused.status).toBe("authorization_succeeded");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("Transactions", () => {
|
|
60
|
+
test("get and list reflect a created transaction", async () => {
|
|
61
|
+
const created = await authorizeViaCheckoutSession(gr4vy, 1299);
|
|
62
|
+
|
|
63
|
+
const fetched = await gr4vy.transactions.get(created.id);
|
|
64
|
+
expect(fetched.id).toBe(created.id);
|
|
65
|
+
|
|
66
|
+
const page = await gr4vy.transactions.list({});
|
|
67
|
+
expect(page).toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("update overrides metadata and external identifier (partial)", async () => {
|
|
71
|
+
const created = await authorizeViaCheckoutSession(gr4vy, 1299, {
|
|
72
|
+
metadata: { original: "value" },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const newExternalIdentifier = uniqueId("txn-updated");
|
|
76
|
+
const updated = await gr4vy.transactions.update(
|
|
77
|
+
{
|
|
78
|
+
metadata: { updated: "value" },
|
|
79
|
+
externalIdentifier: newExternalIdentifier,
|
|
80
|
+
},
|
|
81
|
+
created.id
|
|
82
|
+
);
|
|
83
|
+
expect(updated.metadata?.["updated"]).toBe("value");
|
|
84
|
+
// metadata is fully replaced on update, per the API contract.
|
|
85
|
+
expect(updated.metadata?.["original"]).toBeUndefined();
|
|
86
|
+
expect(updated.externalIdentifier).toBe(newExternalIdentifier);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Property: any accepted amount/currency authorises and the response echoes
|
|
90
|
+
// the amount and currency we sent.
|
|
91
|
+
test("authorizes across a range of amounts and echoes them back", async () => {
|
|
92
|
+
await fc.assert(
|
|
93
|
+
fc.asyncProperty(amount(), currency(), async (amt, cur) => {
|
|
94
|
+
const transaction = await authorizeViaCheckoutSession(gr4vy, amt, {
|
|
95
|
+
currency: cur,
|
|
96
|
+
});
|
|
97
|
+
expect(transaction.status).toBe("authorization_succeeded");
|
|
98
|
+
expect(transaction.amount).toBe(amt);
|
|
99
|
+
expect(transaction.currency).toBe(cur);
|
|
100
|
+
}),
|
|
101
|
+
fcParams(5)
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Property: arbitrary mixed-case metadata survives the create → get round-trip.
|
|
106
|
+
test("metadata round-trips through create and get unchanged", async () => {
|
|
107
|
+
await fc.assert(
|
|
108
|
+
fc.asyncProperty(metadata(), async (meta) => {
|
|
109
|
+
const created = await authorizeViaCheckoutSession(gr4vy, 1299, {
|
|
110
|
+
metadata: meta,
|
|
111
|
+
});
|
|
112
|
+
const fetched = await gr4vy.transactions.get(created.id);
|
|
113
|
+
expect(fetched.metadata).toEqual(meta);
|
|
114
|
+
}),
|
|
115
|
+
fcParams(4)
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fc from "fast-check";
|
|
2
|
+
import { Address, BillingDetails, CartItem } from "../../src/models/components";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared fast-check parameters for E2E property tests. `numRuns` is deliberately
|
|
6
|
+
* small because every run is a real network round-trip — we want broad-ish input
|
|
7
|
+
* coverage without blowing the CI time budget. The seed is fixed (overridable via
|
|
8
|
+
* `FC_SEED`) so a failing case is reproducible from the CI logs.
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_SEED = 42;
|
|
11
|
+
|
|
12
|
+
// Use FC_SEED when it parses to a real number; otherwise fall back to the
|
|
13
|
+
// default so a non-numeric env value (common in CI templating) can't turn the
|
|
14
|
+
// seed into NaN and make fast-check behave unexpectedly.
|
|
15
|
+
const resolveSeed = (): number => {
|
|
16
|
+
const raw = process.env["FC_SEED"];
|
|
17
|
+
if (!raw) return DEFAULT_SEED;
|
|
18
|
+
const parsed = Number(raw);
|
|
19
|
+
return Number.isFinite(parsed) ? parsed : DEFAULT_SEED;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const fcParams = <Ts = unknown>(numRuns = 5): fc.Parameters<Ts> => ({
|
|
23
|
+
numRuns,
|
|
24
|
+
seed: resolveSeed(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Minor-unit amount the mock connector reliably authorises. */
|
|
28
|
+
export const amount = (): fc.Arbitrary<number> =>
|
|
29
|
+
fc.integer({ min: 50, max: 100_000 });
|
|
30
|
+
|
|
31
|
+
/** Currencies the test merchant's `mock-card` service accepts. */
|
|
32
|
+
export const currency = (): fc.Arbitrary<string> => fc.constantFrom("USD");
|
|
33
|
+
|
|
34
|
+
/** Countries the test merchant's `mock-card` service accepts. */
|
|
35
|
+
export const country = (): fc.Arbitrary<string> => fc.constantFrom("US");
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Metadata maps mixing camelCase and snake_case keys, to exercise the
|
|
39
|
+
* snakecase-keys serialisation boundary the way the auth/embed tests do. Values
|
|
40
|
+
* are always strings to match the transaction metadata type.
|
|
41
|
+
*/
|
|
42
|
+
export const metadata = (): fc.Arbitrary<Record<string, string>> =>
|
|
43
|
+
fc.dictionary(
|
|
44
|
+
fc.oneof(
|
|
45
|
+
fc.constantFrom("orderId", "campaign", "noteForOps"),
|
|
46
|
+
fc.constantFrom("order_id", "campaign_ref", "internal_note")
|
|
47
|
+
),
|
|
48
|
+
fc.string({ minLength: 1, maxLength: 40 }),
|
|
49
|
+
{ minKeys: 1, maxKeys: 4 }
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
/** Human-ish names with the odd unicode/whitespace edge case. */
|
|
53
|
+
export const name = (): fc.Arbitrary<string> =>
|
|
54
|
+
fc
|
|
55
|
+
.string({ minLength: 1, maxLength: 30 })
|
|
56
|
+
.filter((s) => s.trim().length > 0);
|
|
57
|
+
|
|
58
|
+
export const addressArb = (): fc.Arbitrary<Address> =>
|
|
59
|
+
fc.record(
|
|
60
|
+
{
|
|
61
|
+
city: fc.constantFrom("London", "Paris", "Berlin", "San Francisco"),
|
|
62
|
+
country: country(),
|
|
63
|
+
postalCode: fc.constantFrom("94110", "10001", "SW1A 1AA"),
|
|
64
|
+
state: fc.constantFrom("CA", "NY", "TX"),
|
|
65
|
+
line1: fc.string({ minLength: 1, maxLength: 50 }),
|
|
66
|
+
line2: fc.option(fc.string({ maxLength: 20 }), { nil: undefined }),
|
|
67
|
+
},
|
|
68
|
+
{ requiredKeys: ["country", "line1"] }
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
export const billingDetailsArb = (): fc.Arbitrary<BillingDetails> =>
|
|
72
|
+
fc.record(
|
|
73
|
+
{
|
|
74
|
+
firstName: name(),
|
|
75
|
+
lastName: name(),
|
|
76
|
+
emailAddress: fc.emailAddress(),
|
|
77
|
+
address: addressArb(),
|
|
78
|
+
},
|
|
79
|
+
{ requiredKeys: ["firstName", "lastName"] }
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
export const cartItemArb = (): fc.Arbitrary<CartItem> =>
|
|
83
|
+
fc.record({
|
|
84
|
+
name: name(),
|
|
85
|
+
quantity: fc.integer({ min: 1, max: 5 }),
|
|
86
|
+
unitAmount: fc.integer({ min: 1, max: 5_000 }),
|
|
87
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { CheckoutSession } from "../../src/models/components";
|
|
2
|
+
|
|
3
|
+
const FIELDS_BASE_URL = "https://api.sandbox.e2e.gr4vy.app";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Submits payment-method fields to a checkout session.
|
|
7
|
+
*
|
|
8
|
+
* `PUT /checkout/sessions/{id}/fields` is an Embed/secure-fields endpoint that is
|
|
9
|
+
* intentionally not exposed through the SDK (it is normally called by the
|
|
10
|
+
* client-side SDKs, not the server SDK). The original checkout-sessions test
|
|
11
|
+
* called it inline with a raw `fetch`; this helper centralises that so the
|
|
12
|
+
* lifecycle flows can tokenise a card into a session without duplicating it.
|
|
13
|
+
*
|
|
14
|
+
* Returns nothing; throws if the API does not return the expected `204`.
|
|
15
|
+
*/
|
|
16
|
+
const putFields = async (
|
|
17
|
+
session: CheckoutSession,
|
|
18
|
+
paymentMethod: Record<string, unknown>
|
|
19
|
+
): Promise<void> => {
|
|
20
|
+
const response = await fetch(
|
|
21
|
+
`${FIELDS_BASE_URL}/checkout/sessions/${session.id}/fields`,
|
|
22
|
+
{
|
|
23
|
+
method: "PUT",
|
|
24
|
+
headers: { "content-type": "application/json" },
|
|
25
|
+
body: JSON.stringify({ payment_method: paymentMethod }),
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (response.status !== 204) {
|
|
30
|
+
const body = await response.text().catch(() => "");
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Unexpected status ${response.status} submitting checkout session fields: ${body}`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface RawCard {
|
|
38
|
+
number: string;
|
|
39
|
+
expiration_date: string;
|
|
40
|
+
security_code: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Tokenises a raw card into the checkout session. */
|
|
44
|
+
export const putCheckoutSessionCard = (
|
|
45
|
+
session: CheckoutSession,
|
|
46
|
+
card: RawCard
|
|
47
|
+
): Promise<void> =>
|
|
48
|
+
putFields(session, {
|
|
49
|
+
method: "card",
|
|
50
|
+
number: card.number,
|
|
51
|
+
expiration_date: card.expiration_date,
|
|
52
|
+
security_code: card.security_code,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** Attaches a previously stored payment method to the checkout session. */
|
|
56
|
+
export const putCheckoutSessionStoredMethod = (
|
|
57
|
+
session: CheckoutSession,
|
|
58
|
+
paymentMethodId: string,
|
|
59
|
+
securityCode?: string
|
|
60
|
+
): Promise<void> =>
|
|
61
|
+
putFields(session, {
|
|
62
|
+
method: "id",
|
|
63
|
+
id: paymentMethodId,
|
|
64
|
+
...(securityCode ? { security_code: securityCode } : {}),
|
|
65
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import {
|
|
3
|
+
Address,
|
|
4
|
+
BillingDetails,
|
|
5
|
+
BuyerCreate,
|
|
6
|
+
CardPaymentMethodCreate,
|
|
7
|
+
CartItem,
|
|
8
|
+
Transaction,
|
|
9
|
+
} from "../../src/models/components";
|
|
10
|
+
import { RawCard } from "./fields";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Capture/void/cancel return either a bare {@link Transaction} or a wrapper that
|
|
14
|
+
* nests it under `transaction` (depending on the `Prefer` header). This narrows
|
|
15
|
+
* both shapes to the underlying transaction.
|
|
16
|
+
*/
|
|
17
|
+
export const unwrapTransaction = (
|
|
18
|
+
result: Transaction | { transaction: Transaction }
|
|
19
|
+
): Transaction => ("transaction" in result ? result.transaction : result);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generates a process-unique identifier. Namespaced with a random suffix so
|
|
23
|
+
* external identifiers never collide across parallel test files / CI shards.
|
|
24
|
+
*/
|
|
25
|
+
export const uniqueId = (prefix = "ts-e2e"): string =>
|
|
26
|
+
`${prefix}-${crypto.randomBytes(6).toString("hex")}`;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The `mock-card` connector used by the test merchant accepts this Visa test
|
|
30
|
+
* number and authorises it. Used both as raw fields (checkout sessions) and as
|
|
31
|
+
* an SDK `CardPaymentMethodCreate`.
|
|
32
|
+
*/
|
|
33
|
+
export const APPROVING_CARD: RawCard = {
|
|
34
|
+
number: "4111111111111111",
|
|
35
|
+
// Far-future expiry so the fixture never lapses and gets rejected as expired.
|
|
36
|
+
expiration_date: "12/35",
|
|
37
|
+
security_code: "123",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const cardPaymentMethod = (
|
|
41
|
+
overrides: Partial<CardPaymentMethodCreate> = {}
|
|
42
|
+
): CardPaymentMethodCreate => ({
|
|
43
|
+
method: "card",
|
|
44
|
+
number: APPROVING_CARD.number,
|
|
45
|
+
expirationDate: APPROVING_CARD.expiration_date,
|
|
46
|
+
securityCode: APPROVING_CARD.security_code,
|
|
47
|
+
...overrides,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const address = (overrides: Partial<Address> = {}): Address => ({
|
|
51
|
+
city: "London",
|
|
52
|
+
country: "US",
|
|
53
|
+
postalCode: "94110",
|
|
54
|
+
state: "CA",
|
|
55
|
+
line1: "123 Example Street",
|
|
56
|
+
line2: "Apt 4",
|
|
57
|
+
organization: "Gr4vy",
|
|
58
|
+
...overrides,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const billingDetails = (
|
|
62
|
+
overrides: Partial<BillingDetails> = {}
|
|
63
|
+
): BillingDetails => ({
|
|
64
|
+
firstName: "John",
|
|
65
|
+
lastName: "Doe",
|
|
66
|
+
emailAddress: "john.doe@example.com",
|
|
67
|
+
phoneNumber: "+14155551234",
|
|
68
|
+
address: address(),
|
|
69
|
+
...overrides,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const buyer = (overrides: Partial<BuyerCreate> = {}): BuyerCreate => ({
|
|
73
|
+
displayName: "John Doe",
|
|
74
|
+
externalIdentifier: uniqueId("buyer"),
|
|
75
|
+
billingDetails: billingDetails(),
|
|
76
|
+
...overrides,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export const cartItem = (overrides: Partial<CartItem> = {}): CartItem => ({
|
|
80
|
+
name: "Joust Duffle Bag",
|
|
81
|
+
quantity: 1,
|
|
82
|
+
unitAmount: 1299,
|
|
83
|
+
...overrides,
|
|
84
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface PollOptions {
|
|
2
|
+
/** Maximum total time to wait before giving up. Defaults to 20s. */
|
|
3
|
+
timeoutMs?: number;
|
|
4
|
+
/** Delay between attempts. Defaults to 1s. */
|
|
5
|
+
intervalMs?: number;
|
|
6
|
+
/** Label used in the timeout error message. */
|
|
7
|
+
description?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Repeatedly calls `fetchFn` until `predicate` returns true, then resolves with
|
|
12
|
+
* the last fetched value. Used for the eventually-consistent endpoints in the
|
|
13
|
+
* lifecycle flows (a capture settling, a report execution finishing, a payout
|
|
14
|
+
* status advancing) so tests don't race ahead of the backend. Bounded by
|
|
15
|
+
* `timeoutMs` so a stuck state fails fast instead of hanging the worker.
|
|
16
|
+
*/
|
|
17
|
+
export const pollUntil = async <T>(
|
|
18
|
+
fetchFn: () => Promise<T>,
|
|
19
|
+
predicate: (value: T) => boolean,
|
|
20
|
+
options: PollOptions = {}
|
|
21
|
+
): Promise<T> => {
|
|
22
|
+
const { timeoutMs = 20_000, intervalMs = 1_000, description = "condition" } =
|
|
23
|
+
options;
|
|
24
|
+
const deadline = Date.now() + timeoutMs;
|
|
25
|
+
let last: T;
|
|
26
|
+
|
|
27
|
+
do {
|
|
28
|
+
last = await fetchFn();
|
|
29
|
+
if (predicate(last)) {
|
|
30
|
+
return last;
|
|
31
|
+
}
|
|
32
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
33
|
+
} while (Date.now() < deadline);
|
|
34
|
+
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Timed out after ${timeoutMs}ms waiting for ${description}. Last value: ${JSON.stringify(
|
|
37
|
+
last!
|
|
38
|
+
)}`
|
|
39
|
+
);
|
|
40
|
+
};
|
package/tests/utils/setup.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Gr4vy, withToken } from "../../src";
|
|
|
5
5
|
import { HTTPClient } from "../../src/lib/http";
|
|
6
6
|
|
|
7
7
|
const loadPrivateKey = (): string => {
|
|
8
|
-
let privateKey = process.env
|
|
8
|
+
let privateKey = process.env["PRIVATE_KEY"];
|
|
9
9
|
|
|
10
10
|
if (!privateKey) {
|
|
11
11
|
const filename = path.resolve(__dirname, "./../../private_key.pem");
|
|
@@ -15,12 +15,44 @@ const loadPrivateKey = (): string => {
|
|
|
15
15
|
return privateKey;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
// When GR4VY_TRACK_HTTP is set (the CI coverage job sets it), record every
|
|
19
|
+
// outgoing request's method + path. scripts/endpoint-coverage.mjs uses these to
|
|
20
|
+
// compute endpoint reach from *observed HTTP calls* rather than statement
|
|
21
|
+
// coverage, so an operation only counts as reached if a request was actually
|
|
22
|
+
// sent. Each worker writes its own file to avoid cross-process races.
|
|
23
|
+
const httpLogDir = path.resolve(__dirname, "./../../coverage/http");
|
|
24
|
+
let httpLogFile: string | undefined;
|
|
25
|
+
const recordHttpCall = (method: string, url: string): void => {
|
|
26
|
+
if (!process.env["GR4VY_TRACK_HTTP"]) return;
|
|
27
|
+
try {
|
|
28
|
+
if (!httpLogFile) {
|
|
29
|
+
fs.mkdirSync(httpLogDir, { recursive: true });
|
|
30
|
+
const suffix = crypto.randomBytes(4).toString("hex");
|
|
31
|
+
httpLogFile = path.join(httpLogDir, `calls-${process.pid}-${suffix}.jsonl`);
|
|
32
|
+
}
|
|
33
|
+
const pathname = new URL(url).pathname;
|
|
34
|
+
// Fire-and-forget async append: keeps instrumentation off the request hot
|
|
35
|
+
// path (no synchronous I/O blocking the event loop). O_APPEND keeps each
|
|
36
|
+
// small line atomic, and errors are ignored — this is best-effort tracking.
|
|
37
|
+
fs.appendFile(
|
|
38
|
+
httpLogFile,
|
|
39
|
+
JSON.stringify({ method, pathname }) + "\n",
|
|
40
|
+
() => {}
|
|
41
|
+
);
|
|
42
|
+
} catch {
|
|
43
|
+
// best-effort instrumentation; never fail a test because of it
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
18
47
|
const httpClient = new HTTPClient({
|
|
19
48
|
/**
|
|
20
49
|
* Adds a custom HTTP client that inserts random fields in the response,
|
|
21
|
-
* ensuring we test for forward compatibility.
|
|
50
|
+
* ensuring we test for forward compatibility.
|
|
22
51
|
*/
|
|
23
52
|
fetcher: async (request) => {
|
|
53
|
+
if (request instanceof Request) {
|
|
54
|
+
recordHttpCall(request.method, request.url);
|
|
55
|
+
}
|
|
24
56
|
const originalResponse = await fetch(request);
|
|
25
57
|
const contentType = originalResponse.headers.get("content-type");
|
|
26
58
|
if (!contentType || !contentType.includes("application/json")) {
|
|
@@ -50,7 +82,7 @@ const httpClient = new HTTPClient({
|
|
|
50
82
|
},
|
|
51
83
|
});
|
|
52
84
|
|
|
53
|
-
const createGr4vyClient = (
|
|
85
|
+
export const createGr4vyClient = (
|
|
54
86
|
privateKey: string,
|
|
55
87
|
merchantAccountId?: string
|
|
56
88
|
): Gr4vy => {
|
|
@@ -63,8 +95,22 @@ const createGr4vyClient = (
|
|
|
63
95
|
});
|
|
64
96
|
};
|
|
65
97
|
|
|
66
|
-
export
|
|
67
|
-
|
|
98
|
+
export interface TestMerchant {
|
|
99
|
+
/** Merchant-scoped client, with a `mock-card` payment service provisioned. */
|
|
100
|
+
client: Gr4vy;
|
|
101
|
+
/** The randomly generated merchant account id this client is scoped to. */
|
|
102
|
+
merchantAccountId: string;
|
|
103
|
+
/** The private key, for spinning up additional clients in the same suite. */
|
|
104
|
+
privateKey: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Provisions an isolated merchant account with a `mock-card` payment service and
|
|
109
|
+
* returns a merchant-scoped client plus its identifiers. Each test file should
|
|
110
|
+
* call this once in `beforeAll` so files never share state and can run in
|
|
111
|
+
* parallel across vitest workers / CI shards.
|
|
112
|
+
*/
|
|
113
|
+
export const setupMerchant = async (): Promise<TestMerchant> => {
|
|
68
114
|
const privateKey = loadPrivateKey();
|
|
69
115
|
const adminClient = createGr4vyClient(privateKey);
|
|
70
116
|
const merchantAccountId = crypto.randomBytes(8).toString("hex");
|
|
@@ -72,9 +118,8 @@ export const setupEnvironment = async (): Promise<Gr4vy> => {
|
|
|
72
118
|
id: merchantAccountId,
|
|
73
119
|
displayName: merchantAccountId,
|
|
74
120
|
});
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
await merchantClient.paymentServices.create({
|
|
121
|
+
const client = createGr4vyClient(privateKey, merchantAccount.id);
|
|
122
|
+
await client.paymentServices.create({
|
|
78
123
|
acceptedCountries: ["US"],
|
|
79
124
|
acceptedCurrencies: ["USD"],
|
|
80
125
|
displayName: "Payment service",
|
|
@@ -82,9 +127,20 @@ export const setupEnvironment = async (): Promise<Gr4vy> => {
|
|
|
82
127
|
fields: [{ key: "merchant_id", value: "test" }],
|
|
83
128
|
});
|
|
84
129
|
|
|
85
|
-
return
|
|
130
|
+
return { client, merchantAccountId: merchantAccount.id, privateKey };
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Back-compat helper used by the original suites: returns only the merchant
|
|
135
|
+
* client. New suites should prefer {@link setupMerchant} when they need the
|
|
136
|
+
* merchant id (e.g. to namespace external identifiers).
|
|
137
|
+
*/
|
|
138
|
+
export const setupEnvironment = async (): Promise<Gr4vy> => {
|
|
139
|
+
const { client } = await setupMerchant();
|
|
140
|
+
return client;
|
|
86
141
|
};
|
|
87
142
|
|
|
88
143
|
export const cleanupEnvironment = async (): Promise<void> => {
|
|
89
|
-
//
|
|
144
|
+
// Merchant accounts in the sandbox are disposable and left in place; there is
|
|
145
|
+
// no public delete endpoint for them, so cleanup is intentionally a no-op.
|
|
90
146
|
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Gr4vy } from "../../src";
|
|
2
|
+
import { Transaction, TransactionCreate } from "../../src/models/components";
|
|
3
|
+
import { APPROVING_CARD, uniqueId } from "./fixtures";
|
|
4
|
+
import { putCheckoutSessionCard } from "./fields";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Authorises (or captures) a transaction the way a real integration does: open a
|
|
8
|
+
* checkout session, tokenise the approving test card into it, then create the
|
|
9
|
+
* transaction against that session. Extra `TransactionCreate` fields can be
|
|
10
|
+
* merged in via `overrides`.
|
|
11
|
+
*/
|
|
12
|
+
export const authorizeViaCheckoutSession = async (
|
|
13
|
+
client: Gr4vy,
|
|
14
|
+
amount: number,
|
|
15
|
+
overrides: Partial<TransactionCreate> = {}
|
|
16
|
+
): Promise<Transaction> => {
|
|
17
|
+
const session = await client.checkoutSessions.create();
|
|
18
|
+
await putCheckoutSessionCard(session, APPROVING_CARD);
|
|
19
|
+
return client.transactions.create({
|
|
20
|
+
amount,
|
|
21
|
+
currency: "USD",
|
|
22
|
+
externalIdentifier: uniqueId("txn"),
|
|
23
|
+
...overrides,
|
|
24
|
+
paymentMethod: { method: "checkout-session", id: session.id },
|
|
25
|
+
});
|
|
26
|
+
};
|