@gr4vy/sdk 2.1.2 → 2.1.4

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.
Files changed (36) hide show
  1. package/README.md +8 -2
  2. package/jsr.json +1 -1
  3. package/lib/config.d.ts +3 -3
  4. package/lib/config.js +3 -3
  5. package/models/components/transactionconnectionoptions.d.ts +5 -0
  6. package/models/components/transactionconnectionoptions.d.ts.map +1 -1
  7. package/models/components/transactionconnectionoptions.js +2 -0
  8. package/models/components/transactionconnectionoptions.js.map +1 -1
  9. package/package.json +4 -2
  10. package/scripts/endpoint-coverage.mjs +136 -0
  11. package/src/lib/config.ts +3 -3
  12. package/src/models/components/transactionconnectionoptions.ts +7 -0
  13. package/tests/backoffice/account-updater.test.ts +24 -0
  14. package/tests/backoffice/audit-logs.test.ts +20 -0
  15. package/tests/backoffice/merchant-accounts.test.ts +77 -0
  16. package/tests/backoffice/payment-services.test.ts +95 -0
  17. package/tests/backoffice/payouts.test.ts +49 -0
  18. package/tests/backoffice/reports.test.ts +69 -0
  19. package/tests/backoffice/three-ds-scenarios.test.ts +34 -0
  20. package/tests/flows/buyer-lifecycle.test.ts +74 -0
  21. package/tests/flows/transaction-lifecycle.test.ts +151 -0
  22. package/tests/processing/buyers.test.ts +119 -0
  23. package/tests/processing/checkout-sessions.test.ts +99 -0
  24. package/tests/processing/digital-wallets.test.ts +154 -0
  25. package/tests/processing/gift-cards.test.ts +45 -0
  26. package/tests/processing/payment-links.test.ts +34 -0
  27. package/tests/processing/payment-methods.test.ts +121 -0
  28. package/tests/processing/transactions.test.ts +118 -0
  29. package/tests/utils/arbitraries.ts +87 -0
  30. package/tests/utils/fields.ts +65 -0
  31. package/tests/utils/fixtures.ts +84 -0
  32. package/tests/utils/poll.ts +40 -0
  33. package/tests/utils/setup.ts +66 -10
  34. package/tests/utils/transactions.ts +26 -0
  35. package/vitest.config.ts +31 -0
  36. package/tests/checkout-sessions.test.ts +0 -109
@@ -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
+ };
@@ -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.PRIVATE_KEY;
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 const setupEnvironment = async (): Promise<Gr4vy> => {
67
- // Create a merchant account
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 merchantClient = createGr4vyClient(privateKey, merchantAccount.id);
76
- // Setup a payment service
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 merchantClient;
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
- // no-op
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
+ };
@@ -0,0 +1,31 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ // E2E suites talk to the real sandbox at api.sandbox.e2e.gr4vy.app. Each test
4
+ // file provisions its own merchant account (see tests/utils/setup.ts), so files
5
+ // are fully isolated and safe to run in parallel across worker threads. CI shards
6
+ // the files across jobs with `vitest run --shard=$i/$n`.
7
+ export default defineConfig({
8
+ test: {
9
+ // Run test files concurrently across worker threads. Tests *within* a file
10
+ // still run sequentially, which keeps each file's merchant-scoped flow ordered.
11
+ fileParallelism: true,
12
+ // Network calls to a shared sandbox occasionally blip; a couple of retries
13
+ // keeps the auto-merge SDK-regen pipeline from flaking on transient errors.
14
+ retry: 2,
15
+ // Generous timeouts for the real network + eventually-consistent endpoints
16
+ // (capture settling, report execution). Mirrors the CI invocation flags.
17
+ testTimeout: 30_000,
18
+ hookTimeout: 30_000,
19
+ // Keep the offline unit tests (auth, webhook) and the live E2E suites together.
20
+ include: ["tests/**/*.test.{ts,js}"],
21
+ coverage: {
22
+ provider: "v8",
23
+ // `src/funcs/*` is one file per API operation, so coverage over this
24
+ // directory doubles as an "endpoints reached" metric (see
25
+ // scripts/endpoint-coverage.mjs). `src/sdk/*` are the namespace wrappers.
26
+ include: ["src/funcs/**", "src/sdk/**"],
27
+ reporter: ["text-summary", "json-summary", "json"],
28
+ reportsDirectory: "coverage",
29
+ },
30
+ },
31
+ });
@@ -1,109 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, test } from 'vitest';
2
- import { CardPaymentMethodCreate } from '../src/models/components';
3
- import { cleanupEnvironment, setupEnvironment } from './utils/setup';
4
-
5
- let gr4vy;
6
-
7
- beforeAll(async () => {
8
- gr4vy = await setupEnvironment()
9
- });
10
-
11
- afterAll(async () => {
12
- await cleanupEnvironment()
13
- });
14
-
15
- describe("Checkout Sessions", () => {
16
- test("should process a payment with a checkout session", async () => {
17
- const checkoutSession = await gr4vy.checkoutSessions.create()
18
- expect(checkoutSession.id).toBeDefined();
19
-
20
- // We do not use the SDK here as we don't want to make this feature public
21
- const response = await fetch(`https://api.sandbox.e2e.gr4vy.app/checkout/sessions/${checkoutSession.id}/fields`, {
22
- method: "PUT",
23
- headers: {
24
- 'content-type': 'application/json',
25
- },
26
- body: JSON.stringify({
27
- "payment_method": {
28
- "method": "card",
29
- "number": "4111111111111111",
30
- "expiration_date": "11/25",
31
- "security_code": "123",
32
- }
33
- })
34
- })
35
- expect(response.status).toBe(204);
36
-
37
- const transaction = await gr4vy.transactions.create({
38
- amount: 1299,
39
- currency: "USD",
40
- paymentMethod: {
41
- method: "checkout-session",
42
- id: checkoutSession.id
43
- }
44
- })
45
-
46
- expect(transaction.id).toBeDefined();
47
- expect(transaction.status).toBe("authorization_succeeded");
48
- expect(transaction.amount).toBe(1299);
49
- });
50
-
51
- test("should handle the error raised on missing card data", async () => {
52
- const checkoutSession = await gr4vy.checkoutSessions.create()
53
- expect(checkoutSession.id).toBeDefined();
54
-
55
- const request = {
56
- amount: 1299,
57
- currency: "USD",
58
- paymentMethod: {
59
- method: "checkout-session",
60
- id: checkoutSession.id
61
- }
62
- }
63
- await expect(() => gr4vy.transactions.create(request)).rejects.toThrowError("Request failed validation")
64
- });
65
-
66
- test("should handle a stored payment method", async () => {
67
- const request: CardPaymentMethodCreate = {
68
- method: "card",
69
- number: "4111111111111111",
70
- expirationDate: "11/25",
71
- securityCode: "123",
72
- };
73
-
74
- const paymentMethod = await gr4vy.paymentMethods.create(request)
75
- expect(paymentMethod.id).toBeDefined()
76
-
77
- const checkoutSession = await gr4vy.checkoutSessions.create()
78
- expect(checkoutSession.id).toBeDefined();
79
-
80
- // We do not use the SDK here as we don't want to make this feature public
81
- const response = await fetch(`https://api.sandbox.e2e.gr4vy.app/checkout/sessions/${checkoutSession.id}/fields`, {
82
- method: "PUT",
83
- headers: {
84
- 'content-type': 'application/json',
85
- },
86
- body: JSON.stringify({
87
- "payment_method": {
88
- "method": "id",
89
- "id": paymentMethod.id,
90
- "security_code": "123",
91
- }
92
- })
93
- })
94
- expect(response.status).toBe(204);
95
-
96
- const transaction = await gr4vy.transactions.create({
97
- amount: 1299,
98
- currency: "USD",
99
- paymentMethod: {
100
- method: "checkout-session",
101
- id: checkoutSession.id
102
- }
103
- })
104
-
105
- expect(transaction.id).toBeDefined();
106
- expect(transaction.status).toBe("authorization_succeeded");
107
- expect(transaction.amount).toBe(1299);
108
- });
109
- });