@12-apps/payments-frontend 1.4.0 → 1.4.2
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/package.json +13 -5
- package/eslint.config.js +0 -34
- package/src/__tests__/checkout-confirmation.test.tsx +0 -177
- package/src/__tests__/connection-state.test.tsx +0 -84
- package/src/__tests__/context.test.tsx +0 -88
- package/src/__tests__/controlled-provider.test.tsx +0 -116
- package/src/__tests__/credential-confirm.test.tsx +0 -193
- package/src/__tests__/initial-provider.test.tsx +0 -81
- package/src/__tests__/provider-priority-list.test.tsx +0 -159
- package/src/__tests__/provider-status-bar.test.tsx +0 -152
- package/src/__tests__/slugged-provider.test.tsx +0 -108
- package/src/__tests__/verification-slot.test.tsx +0 -125
- package/src/card/tokenize.test.ts +0 -194
- package/src/components/checkout/__tests__/card-3ds-handover.test.tsx +0 -147
- package/src/components/checkout/__tests__/chain-instruments.test.tsx +0 -244
- package/src/components/checkout/__tests__/clear-cart-on-paid.test.tsx +0 -64
- package/src/components/checkout/__tests__/hosted-return.test.ts +0 -109
- package/src/components/checkout/__tests__/method-capability.test.tsx +0 -120
- package/src/components/checkout/__tests__/mint-deadline.test.ts +0 -99
- package/src/components/checkout/__tests__/payments-unavailable.test.tsx +0 -53
- package/src/components/checkout/__tests__/provider-chain.test.ts +0 -161
- package/src/components/checkout/__tests__/save-on-continue.test.tsx +0 -165
- package/src/components/checkout/__tests__/second-host.test.tsx +0 -86
- package/src/components/checkout/__tests__/unresolved-charge.test.tsx +0 -155
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* FUT-563 — "o comprador digita o cartão UMA vez e a compra sobrevive ao
|
|
4
|
-
* primeiro provedor falhar": the browser half of it.
|
|
5
|
-
*
|
|
6
|
-
* A card instrument is bound to the provider that minted it, so a chain the
|
|
7
|
-
* checkout tokenized for only ONCE is a chain the gateway can attempt only
|
|
8
|
-
* once — it skips the rest rather than send a blob they cannot read. What is
|
|
9
|
-
* pinned here is that the same validated card produces one instrument per
|
|
10
|
-
* chain entry and they all travel on ONE charge, with nothing re-typed.
|
|
11
|
-
*
|
|
12
|
-
* The browser journey (the `cadeia-de-provedores` Gherkin feature)
|
|
13
|
-
* covers the happy path end to end; these are the halves it cannot stage — a
|
|
14
|
-
* tail provider whose tokenization fails while the head's works, and a store
|
|
15
|
-
* whose host published no chain at all.
|
|
16
|
-
*/
|
|
17
|
-
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
18
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
|
-
|
|
20
|
-
import type { JSX } from "react";
|
|
21
|
-
|
|
22
|
-
const client = vi.hoisted(() => ({
|
|
23
|
-
chargeCard: vi.fn(),
|
|
24
|
-
listSavedCards: vi.fn(),
|
|
25
|
-
refreshCardPublicKey: vi.fn(),
|
|
26
|
-
pollOrderStatus: vi.fn(),
|
|
27
|
-
fetchCheckoutConfig: vi.fn(),
|
|
28
|
-
}));
|
|
29
|
-
|
|
30
|
-
vi.mock("../client", () => client);
|
|
31
|
-
|
|
32
|
-
import type { CardTokenizationConfig } from "../../../card";
|
|
33
|
-
import type { CardChainLink } from "../method-capability";
|
|
34
|
-
import type { CheckoutOrder } from "../types";
|
|
35
|
-
import { useCardCheckout } from "../use-card-checkout";
|
|
36
|
-
|
|
37
|
-
const ORDER: CheckoutOrder = {
|
|
38
|
-
orderId: "o1",
|
|
39
|
-
status: "AWAITING_PAYMENT",
|
|
40
|
-
method: "CARD",
|
|
41
|
-
totalCents: 1250,
|
|
42
|
-
subtotalCents: 1250,
|
|
43
|
-
discountTotalCents: 0,
|
|
44
|
-
appliedDiscounts: [],
|
|
45
|
-
totalLabel: "R$ 12,50",
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
/** Stub-mode entries: the mock tokenizer mints without a real key or network. */
|
|
49
|
-
const HEAD: CardChainLink = {
|
|
50
|
-
provider: "pagbank",
|
|
51
|
-
publicKey: null,
|
|
52
|
-
mockTokenization: true,
|
|
53
|
-
mintable: true,
|
|
54
|
-
};
|
|
55
|
-
const TAIL: CardChainLink = {
|
|
56
|
-
provider: "stone",
|
|
57
|
-
publicKey: null,
|
|
58
|
-
mockTokenization: true,
|
|
59
|
-
mintable: true,
|
|
60
|
-
};
|
|
61
|
-
/**
|
|
62
|
-
* Declares an in-browser scheme, but this browser has none for it: no
|
|
63
|
-
* tokenizer, no key, no mock grant. The mint FAILS — a different thing from
|
|
64
|
-
* an entry that asks for no instrument.
|
|
65
|
-
*/
|
|
66
|
-
const UNMINTABLE: CardChainLink = {
|
|
67
|
-
provider: "unknown-vendor",
|
|
68
|
-
publicKey: null,
|
|
69
|
-
mockTokenization: false,
|
|
70
|
-
mintable: true,
|
|
71
|
-
};
|
|
72
|
-
/** A hosted page: its own site takes the card, so nothing is minted for it. */
|
|
73
|
-
const HOSTED: CardChainLink = {
|
|
74
|
-
provider: "infinitepay",
|
|
75
|
-
publicKey: null,
|
|
76
|
-
mockTokenization: false,
|
|
77
|
-
mintable: false,
|
|
78
|
-
};
|
|
79
|
-
/**
|
|
80
|
-
* The ACTIVE provider of a hosted-headed store, as the config's head triple:
|
|
81
|
-
* no scheme, no key, no stub grant. Nothing this browser can mint against.
|
|
82
|
-
*/
|
|
83
|
-
const HOSTED_HEAD: CardTokenizationConfig = {
|
|
84
|
-
provider: "infinitepay",
|
|
85
|
-
publicKey: null,
|
|
86
|
-
mockTokenization: false,
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const CARD = { number: "4111111111111111", holder: "VERA CADEIA", expiry: "12/34", cvv: "123" };
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Filling and paying are two BUTTONS, not one handler: `handlePay` closes over
|
|
93
|
-
* the card in state, so a submit fired in the same tick as `setCard` validates
|
|
94
|
-
* the empty form and never reaches the tokenizer.
|
|
95
|
-
*/
|
|
96
|
-
function Harness({
|
|
97
|
-
chain,
|
|
98
|
-
config = HEAD,
|
|
99
|
-
}: {
|
|
100
|
-
chain: CardChainLink[];
|
|
101
|
-
config?: CardTokenizationConfig;
|
|
102
|
-
}): JSX.Element {
|
|
103
|
-
const cc = useCardCheckout(ORDER, {}, config, vi.fn(), 10, undefined, chain);
|
|
104
|
-
return (
|
|
105
|
-
<div>
|
|
106
|
-
<button type="button" data-testid="fill" onClick={() => cc.setCard(CARD)}>
|
|
107
|
-
Preencher
|
|
108
|
-
</button>
|
|
109
|
-
<button type="button" data-testid="pay" onClick={() => void cc.handlePay()}>
|
|
110
|
-
Pagar
|
|
111
|
-
</button>
|
|
112
|
-
{cc.error ? <p data-testid="pay-error">{cc.error}</p> : null}
|
|
113
|
-
</div>
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** The `tokensByProvider` of the single charge submitted. */
|
|
118
|
-
function sentInstruments(): Record<string, string> | undefined {
|
|
119
|
-
const input = client.chargeCard.mock.calls[0]?.[0] as
|
|
120
|
-
| { tokensByProvider?: Record<string, string> }
|
|
121
|
-
| undefined;
|
|
122
|
-
return input?.tokensByProvider;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async function payWith(chain: CardChainLink[], config?: CardTokenizationConfig): Promise<void> {
|
|
126
|
-
render(<Harness chain={chain} config={config} />);
|
|
127
|
-
await waitFor(() => {
|
|
128
|
-
expect(client.listSavedCards).toHaveBeenCalled();
|
|
129
|
-
});
|
|
130
|
-
fireEvent.click(screen.getByTestId("fill"));
|
|
131
|
-
fireEvent.click(screen.getByTestId("pay"));
|
|
132
|
-
await waitFor(() => {
|
|
133
|
-
expect(client.chargeCard).toHaveBeenCalled();
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Fill + tap Pagar, then prove the submit never reached the charge route. */
|
|
138
|
-
async function expectNoCharge(
|
|
139
|
-
chain: CardChainLink[],
|
|
140
|
-
config?: CardTokenizationConfig,
|
|
141
|
-
): Promise<void> {
|
|
142
|
-
render(<Harness chain={chain} config={config} />);
|
|
143
|
-
await waitFor(() => {
|
|
144
|
-
expect(client.listSavedCards).toHaveBeenCalled();
|
|
145
|
-
});
|
|
146
|
-
fireEvent.click(screen.getByTestId("fill"));
|
|
147
|
-
fireEvent.click(screen.getByTestId("pay"));
|
|
148
|
-
// The refusal is what ends the submit, so waiting for it is what proves the
|
|
149
|
-
// charge was skipped rather than merely still in flight.
|
|
150
|
-
await waitFor(() => {
|
|
151
|
-
expect(screen.getByTestId("pay-error").textContent).toMatch(/indisponível nesta loja/i);
|
|
152
|
-
});
|
|
153
|
-
expect(client.chargeCard).not.toHaveBeenCalled();
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
beforeEach(() => {
|
|
157
|
-
vi.clearAllMocks();
|
|
158
|
-
client.listSavedCards.mockResolvedValue([]);
|
|
159
|
-
// The head is PagBank with no key, so the hook's on-demand key refresh runs
|
|
160
|
-
// on mount (FUT-174). It finds none here — stub mode is what mints the token.
|
|
161
|
-
client.refreshCardPublicKey.mockResolvedValue({ ok: true, data: { publicKey: null } });
|
|
162
|
-
client.pollOrderStatus.mockResolvedValue({ ok: true, data: "AWAITING_PAYMENT" });
|
|
163
|
-
client.chargeCard.mockResolvedValue({ ok: true, data: { status: "AWAITING_PAYMENT" } });
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
afterEach(() => {
|
|
167
|
-
cleanup();
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
describe("one instrument per provider (FUT-563)", () => {
|
|
171
|
-
it("mints for EVERY entry of the chain from one typed card", async () => {
|
|
172
|
-
await payWith([HEAD, TAIL]);
|
|
173
|
-
|
|
174
|
-
const minted = sentInstruments();
|
|
175
|
-
expect(Object.keys(minted ?? {}).sort()).toEqual(["pagbank", "stone"]);
|
|
176
|
-
// Distinct instruments: one blob reused across providers is the exact
|
|
177
|
-
// thing the gateway refuses to send.
|
|
178
|
-
expect(minted?.["pagbank"]).not.toBe(minted?.["stone"]);
|
|
179
|
-
// The buyer typed once.
|
|
180
|
-
expect(client.chargeCard).toHaveBeenCalledTimes(1);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it("keeps the head's own token as the bare `token`", async () => {
|
|
184
|
-
await payWith([HEAD, TAIL]);
|
|
185
|
-
|
|
186
|
-
const input = client.chargeCard.mock.calls[0]?.[0] as { token: string };
|
|
187
|
-
expect(sentInstruments()?.["pagbank"]).toBe(input.token);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("still pays when a TAIL provider cannot be tokenized for", async () => {
|
|
191
|
-
// That provider is one the walk will skip — strictly better than failing
|
|
192
|
-
// the whole payment because the second acquirer's key was missing. The map
|
|
193
|
-
// still travels, naming who DID mint: the server then skips the tail by
|
|
194
|
-
// name instead of reading the bare token as the head's and skipping
|
|
195
|
-
// everyone else.
|
|
196
|
-
await payWith([HEAD, UNMINTABLE]);
|
|
197
|
-
|
|
198
|
-
expect(sentInstruments()).toEqual({ pagbank: expect.any(String) });
|
|
199
|
-
expect(client.chargeCard).toHaveBeenCalledTimes(1);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it("sends the map when the TAIL is a provider that needs NO instrument", async () => {
|
|
203
|
-
// The two-provider shape this feature exists for: a card acquirer plus a
|
|
204
|
-
// hosted-page fallback. Nothing is minted for the hosted entry — its own
|
|
205
|
-
// site takes the card — so a map counted by minted keys would be dropped,
|
|
206
|
-
// the bare token read as the head's, and the fallback refused for
|
|
207
|
-
// "holding someone else's instrument". The chain has TWO entries, so the
|
|
208
|
-
// map goes.
|
|
209
|
-
await payWith([HEAD, HOSTED]);
|
|
210
|
-
|
|
211
|
-
expect(sentInstruments()).toEqual({ pagbank: expect.any(String) });
|
|
212
|
-
// And never a mocked one for the hosted provider.
|
|
213
|
-
expect(sentInstruments()).not.toHaveProperty("infinitepay");
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it("mints against the first MINTABLE entry when the HEAD is a hosted page", async () => {
|
|
217
|
-
// The store this feature creates by simply enabling a second provider:
|
|
218
|
-
// InfinitePay (REDIRECT) first, an acquirer behind it. The server asks the
|
|
219
|
-
// WHOLE chain whether anybody tokenizes in the browser, so it shows our
|
|
220
|
-
// card form — and the submit must mint against the entry the form is being
|
|
221
|
-
// shown FOR. Minting against the head refused the payment outright, with a
|
|
222
|
-
// full PAN already typed and no provider ever asked.
|
|
223
|
-
await payWith([HOSTED, HEAD], HOSTED_HEAD);
|
|
224
|
-
|
|
225
|
-
expect(client.chargeCard).toHaveBeenCalledTimes(1);
|
|
226
|
-
expect(sentInstruments()).toEqual({ pagbank: expect.any(String) });
|
|
227
|
-
// The bare token is the one that was actually minted, not the head's.
|
|
228
|
-
const input = client.chargeCard.mock.calls[0]?.[0] as { token: string };
|
|
229
|
-
expect(input.token).toBe(sentInstruments()?.["pagbank"]);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("still refuses when NO entry of the chain can be minted for", async () => {
|
|
233
|
-
// Nothing to fall back to — the buyer is told so before any charge, which
|
|
234
|
-
// is the honest answer and the one FUT-697 wrote.
|
|
235
|
-
await expectNoCharge([HOSTED, UNMINTABLE], HOSTED_HEAD);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("sends no map at all for a single-provider store", async () => {
|
|
239
|
-
// The pre-FUT-563 wire shape, unchanged: one token, no map.
|
|
240
|
-
await payWith([HEAD]);
|
|
241
|
-
|
|
242
|
-
expect(sentInstruments()).toBeUndefined();
|
|
243
|
-
});
|
|
244
|
-
});
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* A buyer who paid must not be handed their cart back.
|
|
4
|
-
*
|
|
5
|
-
* FUT-601 fixed the SERVER half: the cart is emptied inside the confirmation
|
|
6
|
-
* transaction, for the delivery that won the claim. Nothing told the SPA — its
|
|
7
|
-
* cart provider survives every checkout route change and kept the snapshot it
|
|
8
|
-
* seeded on mount, so the buyer pressed "Voltar ao cardápio" and found the
|
|
9
|
-
* badge still counting the items they had just bought.
|
|
10
|
-
*
|
|
11
|
-
* Since FUT-564 the flow lives in this package and the cart lives in the host,
|
|
12
|
-
* so the client half of that fix is a PORT: `onPaid` fires when — and only
|
|
13
|
-
* when — the order settles PAID. The host re-reads its server-emptied cart
|
|
14
|
-
* there; a payment that did not go through fires nothing, because that shopper
|
|
15
|
-
* still has a basket to retry with.
|
|
16
|
-
*/
|
|
17
|
-
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
18
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
19
|
-
|
|
20
|
-
import { useCheckoutController, type CheckoutHostPorts } from "../use-checkout-controller";
|
|
21
|
-
|
|
22
|
-
/** One test's own port set — every callback a fresh spy. */
|
|
23
|
-
function makePorts(): CheckoutHostPorts & { onPaid: ReturnType<typeof vi.fn> } {
|
|
24
|
-
return {
|
|
25
|
-
createOrder: vi.fn(),
|
|
26
|
-
saveBuyerContact: vi.fn(),
|
|
27
|
-
onExitToMenu: vi.fn(),
|
|
28
|
-
onPaid: vi.fn(),
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
afterEach(() => {
|
|
33
|
-
vi.restoreAllMocks();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe("the host's onPaid port after a checkout settles", () => {
|
|
37
|
-
it("fires once the order is PAID", async () => {
|
|
38
|
-
const ports = makePorts();
|
|
39
|
-
const { result } = renderHook(() => useCheckoutController(ports));
|
|
40
|
-
|
|
41
|
-
act(() => {
|
|
42
|
-
result.current.handleResolved("PAID");
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
await waitFor(() => {
|
|
46
|
-
expect(ports.onPaid).toHaveBeenCalledTimes(1);
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("stays silent when the payment failed", async () => {
|
|
51
|
-
const ports = makePorts();
|
|
52
|
-
const { result } = renderHook(() => useCheckoutController(ports));
|
|
53
|
-
|
|
54
|
-
act(() => {
|
|
55
|
-
result.current.handleResolved("FAILED");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// The step advanced, so the effect has had its chance to run.
|
|
59
|
-
await waitFor(() => {
|
|
60
|
-
expect(result.current.finalStatus).toBe("FAILED");
|
|
61
|
-
});
|
|
62
|
-
expect(ports.onPaid).not.toHaveBeenCalled();
|
|
63
|
-
});
|
|
64
|
-
});
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
3
|
-
|
|
4
|
-
import { rememberHostedOrder, takeHostedOrder } from "../hosted-return";
|
|
5
|
-
import type { CheckoutOrder } from "../types";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* FUT-556 — surviving the trip to a hosted checkout.
|
|
9
|
-
*
|
|
10
|
-
* A redirect provider tears the SPA down. These pin the two rules that keep the
|
|
11
|
-
* return from landing on a blank payment step, and keep an ABANDONED payment
|
|
12
|
-
* from resurfacing as if it had happened: the parked order is handed back only
|
|
13
|
-
* on a return trip, and only once.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
const ORDER: CheckoutOrder = {
|
|
17
|
-
orderId: "o1",
|
|
18
|
-
status: "AWAITING_PAYMENT",
|
|
19
|
-
method: "PIX",
|
|
20
|
-
totalCents: 550,
|
|
21
|
-
subtotalCents: 550,
|
|
22
|
-
discountTotalCents: 0,
|
|
23
|
-
appliedDiscounts: [],
|
|
24
|
-
totalLabel: "R$ 5,50",
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/** Put the tab on `search`, the way the provider's redirect leaves it. */
|
|
28
|
-
function land(search: string): void {
|
|
29
|
-
window.history.replaceState({}, "", `/future-drink/menu/checkout${search}`);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe("hosted-return", () => {
|
|
33
|
-
beforeEach(() => {
|
|
34
|
-
window.sessionStorage.clear();
|
|
35
|
-
land("");
|
|
36
|
-
});
|
|
37
|
-
afterEach(() => {
|
|
38
|
-
window.sessionStorage.clear();
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("hands the order back when the provider sent the buyer home", () => {
|
|
42
|
-
rememberHostedOrder(ORDER);
|
|
43
|
-
land("?transaction_nsu=123&slug=abc");
|
|
44
|
-
|
|
45
|
-
expect(takeHostedOrder()).toEqual(ORDER);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("recognises a return that carries only one of the markers", () => {
|
|
49
|
-
rememberHostedOrder(ORDER);
|
|
50
|
-
land("?slug=abc");
|
|
51
|
-
|
|
52
|
-
expect(takeHostedOrder()?.orderId).toBe("o1");
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("recognises a buyer coming back from a Stripe 3DS challenge (FUT-698)", () => {
|
|
56
|
-
// What Stripe appends to the card confirm's `return_url` after the issuer
|
|
57
|
-
// page: the intent id, its client secret and the redirect verdict.
|
|
58
|
-
rememberHostedOrder(ORDER);
|
|
59
|
-
land("?payment_intent=pi_1&payment_intent_client_secret=pi_1_secret&redirect_status=succeeded");
|
|
60
|
-
|
|
61
|
-
expect(takeHostedOrder()?.orderId).toBe("o1");
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("still recognises the hosted-store return exactly as before (FUT-556)", () => {
|
|
65
|
-
// The InfinitePay handoff must keep working with the Stripe markers added:
|
|
66
|
-
// "loja hospedada continua com o hand-off e retorno de hoje".
|
|
67
|
-
rememberHostedOrder(ORDER);
|
|
68
|
-
land("?transaction_nsu=123&slug=abc&order_nsu=o1");
|
|
69
|
-
|
|
70
|
-
expect(takeHostedOrder()?.orderId).toBe("o1");
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("ignores a parked order when this is not a return trip", () => {
|
|
74
|
-
rememberHostedOrder(ORDER);
|
|
75
|
-
land("");
|
|
76
|
-
|
|
77
|
-
// A buyer who abandoned the provider's page and came back to checkout later
|
|
78
|
-
// must start a fresh order, not resume one they never paid.
|
|
79
|
-
expect(takeHostedOrder()).toBeNull();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("hands the order back exactly once", () => {
|
|
83
|
-
rememberHostedOrder(ORDER);
|
|
84
|
-
land("?transaction_nsu=123");
|
|
85
|
-
|
|
86
|
-
expect(takeHostedOrder()).not.toBeNull();
|
|
87
|
-
expect(takeHostedOrder()).toBeNull();
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("answers null for a value that is not an order", () => {
|
|
91
|
-
window.sessionStorage.setItem("futurepay.checkout.hostedOrder", '{"nonsense":true}');
|
|
92
|
-
land("?transaction_nsu=123");
|
|
93
|
-
|
|
94
|
-
expect(takeHostedOrder()).toBeNull();
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("answers null for unparseable storage rather than throwing", () => {
|
|
98
|
-
window.sessionStorage.setItem("futurepay.checkout.hostedOrder", "{not json");
|
|
99
|
-
land("?transaction_nsu=123");
|
|
100
|
-
|
|
101
|
-
expect(takeHostedOrder()).toBeNull();
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("answers null when nothing was parked", () => {
|
|
105
|
-
land("?transaction_nsu=123");
|
|
106
|
-
|
|
107
|
-
expect(takeHostedOrder()).toBeNull();
|
|
108
|
-
});
|
|
109
|
-
});
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* FUT-698 — the method picker derives its options from the chain's declared
|
|
4
|
-
* capabilities (`capabilities.methods`, surfaced by `GET /api/checkout/config`).
|
|
5
|
-
*
|
|
6
|
-
* These are the browser-unreachable halves of the ticket's "método sem suporte
|
|
7
|
-
* na cadeia não aparece no picker" scenario: every adapter shipped today
|
|
8
|
-
* declares PIX and CARD, so the missing-method case only exists synthetically.
|
|
9
|
-
* Test names keep the scenario's Given/When/Then. Rendered with NO slot
|
|
10
|
-
* provider on purpose (raw-MUI defaults carry the load-bearing test ids);
|
|
11
|
-
* jest-dom is not a dependency here — DOM properties are asserted directly.
|
|
12
|
-
*/
|
|
13
|
-
import { cleanup, render, screen } from "@testing-library/react";
|
|
14
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
15
|
-
|
|
16
|
-
import { PaymentStep } from "../checkout-steps";
|
|
17
|
-
import { MethodPicker } from "../method-picker";
|
|
18
|
-
import type { CheckoutProviderConfig } from "../types";
|
|
19
|
-
|
|
20
|
-
afterEach(() => {
|
|
21
|
-
cleanup();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
function config(methods: CheckoutProviderConfig["methods"]): CheckoutProviderConfig {
|
|
25
|
-
return {
|
|
26
|
-
provider: "stone",
|
|
27
|
-
tokenization: "PUBLIC_KEY",
|
|
28
|
-
publicKey: "pk_stone",
|
|
29
|
-
mockTokenization: false,
|
|
30
|
-
methods,
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Render the Pagamento step with no order raised — just the picker. */
|
|
35
|
-
function renderPaymentStep(providerConfig: CheckoutProviderConfig | null): void {
|
|
36
|
-
render(
|
|
37
|
-
<PaymentStep
|
|
38
|
-
method={null}
|
|
39
|
-
onMethodChange={vi.fn()}
|
|
40
|
-
order={null}
|
|
41
|
-
buyer={{}}
|
|
42
|
-
creating={false}
|
|
43
|
-
createError={null}
|
|
44
|
-
errorField={null}
|
|
45
|
-
onGenerate={vi.fn()}
|
|
46
|
-
onUseEmail={vi.fn()}
|
|
47
|
-
providerConfig={providerConfig}
|
|
48
|
-
onResolved={vi.fn()}
|
|
49
|
-
/>,
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** The offered methods, read off the rendered radiogroup's testids. */
|
|
54
|
-
function offeredTestIds(): string[] {
|
|
55
|
-
return Array.from(
|
|
56
|
-
screen.getByTestId("checkout-method").querySelectorAll("[data-testid^='checkout-method-']"),
|
|
57
|
-
).map((option) => option.getAttribute("data-testid") ?? "");
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
describe("MethodPicker — method by capability (FUT-698)", () => {
|
|
61
|
-
it("given the chain cannot PIX, when the picker renders, then PIX is not offered", () => {
|
|
62
|
-
render(<MethodPicker value={null} onChange={vi.fn()} offered={["CARD"]} />);
|
|
63
|
-
|
|
64
|
-
expect(offeredTestIds()).toEqual(["checkout-method-CARD"]);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("given the chain cannot CARD, when the picker renders, then CARD is not offered", () => {
|
|
68
|
-
render(<MethodPicker value={null} onChange={vi.fn()} offered={["PIX"]} />);
|
|
69
|
-
|
|
70
|
-
expect(offeredTestIds()).toEqual(["checkout-method-PIX"]);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("given the config has not answered yet, then every method renders (fail open)", () => {
|
|
74
|
-
render(<MethodPicker value={null} onChange={vi.fn()} offered={null} />);
|
|
75
|
-
|
|
76
|
-
expect(offeredTestIds()).toEqual(["checkout-method-PIX", "checkout-method-CARD"]);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("card-path gating still wins over a chain that declares CARD", () => {
|
|
80
|
-
// The chain CAN charge cards, but this browser has no way to mint one
|
|
81
|
-
// (no scheme/key/stub) — the tile stays visible but DISABLED with a caption
|
|
82
|
-
// (FUT-697 review): a removed tile would leave an unexplained one-option
|
|
83
|
-
// "choice", while a disabled one says why.
|
|
84
|
-
render(
|
|
85
|
-
<MethodPicker value={null} onChange={vi.fn()} offered={["PIX", "CARD"]} cardUnavailable />,
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
expect(offeredTestIds()).toEqual(["checkout-method-PIX", "checkout-method-CARD"]);
|
|
89
|
-
const cardTile = screen.getByTestId("checkout-method-CARD") as HTMLButtonElement;
|
|
90
|
-
expect(cardTile.disabled).toBe(true);
|
|
91
|
-
expect(screen.getByText("Indisponível nesta loja")).toBeTruthy();
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
describe("PaymentStep — the config's methods reach the picker (FUT-698)", () => {
|
|
96
|
-
it("given a chain that only PIXes, when Pagamento renders, then card is never offered", () => {
|
|
97
|
-
renderPaymentStep(config(["PIX"]));
|
|
98
|
-
|
|
99
|
-
expect(offeredTestIds()).toEqual(["checkout-method-PIX"]);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("given a chain that declares BOLETO, then only methods the checkout can drive appear", () => {
|
|
103
|
-
renderPaymentStep(config(["PIX", "CARD", "BOLETO"]));
|
|
104
|
-
|
|
105
|
-
// Stone/Stripe declare BOLETO server-side (true), but this checkout has no
|
|
106
|
-
// boleto UI yet (phase 2) — the picker offers only what it can honour.
|
|
107
|
-
expect(screen.getByTestId("checkout-method-PIX")).toBeTruthy();
|
|
108
|
-
expect(screen.getByTestId("checkout-method-CARD")).toBeTruthy();
|
|
109
|
-
expect(screen.getByTestId("checkout-method").querySelectorAll("[role='radio']")).toHaveLength(
|
|
110
|
-
2,
|
|
111
|
-
);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("given no config yet, then Pagamento offers both methods as before", () => {
|
|
115
|
-
renderPaymentStep(null);
|
|
116
|
-
|
|
117
|
-
expect(screen.getByTestId("checkout-method-PIX")).toBeTruthy();
|
|
118
|
-
expect(screen.getByTestId("checkout-method-CARD")).toBeTruthy();
|
|
119
|
-
});
|
|
120
|
-
});
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* FUT-563 — a BACKUP acquirer nobody can reach must not hold the payment.
|
|
4
|
-
*
|
|
5
|
-
* Minting one instrument per chain entry puts the buyer's Pagar button behind
|
|
6
|
-
* every acquirer in the chain, and a tokenizer is a cross-origin POST with no
|
|
7
|
-
* deadline of its own — browser `fetch` has none either. A middlebox that
|
|
8
|
-
* accepts the socket and never answers therefore left "Pagar R$ …" spinning
|
|
9
|
-
* and disabled for as long as the OS kept the connection: the failover feature
|
|
10
|
-
* blocking on the provider it exists to fall back TO, with the head's own
|
|
11
|
-
* token already minted and nothing wrong with it.
|
|
12
|
-
*
|
|
13
|
-
* These are the halves the browser journey cannot stage — it has no way to
|
|
14
|
-
* make an acquirer stop answering.
|
|
15
|
-
*/
|
|
16
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
17
|
-
|
|
18
|
-
import { resolveNewCardToken } from "../card-instruments";
|
|
19
|
-
import type { CardChainLink } from "../method-capability";
|
|
20
|
-
|
|
21
|
-
vi.mock("../client", () => ({ refreshCardPublicKey: vi.fn() }));
|
|
22
|
-
|
|
23
|
-
const CARD = { number: "4111111111111111", holder: "VERA CADEIA", expiry: "12/34", cvv: "123" };
|
|
24
|
-
|
|
25
|
-
/** Stub mode: the head mints locally, with no network and no key. */
|
|
26
|
-
const HEAD: CardChainLink = {
|
|
27
|
-
provider: "pagbank",
|
|
28
|
-
publicKey: null,
|
|
29
|
-
mockTokenization: true,
|
|
30
|
-
mintable: true,
|
|
31
|
-
};
|
|
32
|
-
/** A real Pagar.me tokenizer — this one goes to the network. */
|
|
33
|
-
const STONE: CardChainLink = {
|
|
34
|
-
provider: "stone",
|
|
35
|
-
publicKey: "pk_stone",
|
|
36
|
-
mockTokenization: false,
|
|
37
|
-
mintable: true,
|
|
38
|
-
};
|
|
39
|
-
/** A real Stripe tokenizer — likewise. */
|
|
40
|
-
const STRIPE: CardChainLink = {
|
|
41
|
-
provider: "stripe",
|
|
42
|
-
publicKey: "pk_stripe",
|
|
43
|
-
mockTokenization: false,
|
|
44
|
-
mintable: true,
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
/** A tokenizer endpoint that accepts the request and never answers. */
|
|
48
|
-
function blackHole(): ReturnType<typeof vi.fn> {
|
|
49
|
-
const fetchMock = vi.fn(() => new Promise<Response>(() => undefined));
|
|
50
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
51
|
-
return fetchMock;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
afterEach(() => {
|
|
55
|
-
vi.unstubAllGlobals();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe("minting the chain is bounded and concurrent", () => {
|
|
59
|
-
it("charges on the head when a TAIL acquirer never answers", async () => {
|
|
60
|
-
const fetchMock = blackHole();
|
|
61
|
-
|
|
62
|
-
const resolved = await resolveNewCardToken(CARD, HEAD, "o1", vi.fn(), false, [HEAD, STONE], 20);
|
|
63
|
-
|
|
64
|
-
// The degradation this module already documents: the provider we could not
|
|
65
|
-
// mint for is one the walk will skip. The payment still goes out.
|
|
66
|
-
expect(resolved.ok).toBe(true);
|
|
67
|
-
const instruments = resolved.ok ? resolved.data : null;
|
|
68
|
-
expect(instruments?.tokensByProvider).toEqual({ pagbank: expect.any(String) });
|
|
69
|
-
// And the abandoned request is ABORTED, not merely ignored — a token that
|
|
70
|
-
// arrives after we gave up on it is one nothing will ever charge.
|
|
71
|
-
const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
|
|
72
|
-
expect(init?.signal?.aborted).toBe(true);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("asks every tail at ONCE, not one after the other", async () => {
|
|
76
|
-
// Sequentially, two unreachable backups cost the buyer two full deadlines
|
|
77
|
-
// before a charge they could have had immediately.
|
|
78
|
-
const fetchMock = blackHole();
|
|
79
|
-
const deadlineMs = 400;
|
|
80
|
-
|
|
81
|
-
const minting = resolveNewCardToken(
|
|
82
|
-
CARD,
|
|
83
|
-
HEAD,
|
|
84
|
-
"o1",
|
|
85
|
-
vi.fn(),
|
|
86
|
-
false,
|
|
87
|
-
[HEAD, STONE, STRIPE],
|
|
88
|
-
deadlineMs,
|
|
89
|
-
);
|
|
90
|
-
// Both requests are in flight well inside ONE deadline — which they could
|
|
91
|
-
// not be if the second waited for the first to time out.
|
|
92
|
-
await vi.waitFor(() => {
|
|
93
|
-
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
const resolved = await minting;
|
|
97
|
-
expect(resolved.ok).toBe(true);
|
|
98
|
-
});
|
|
99
|
-
});
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
3
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
-
|
|
5
|
-
import { PaymentsUnavailable } from "../payments-unavailable";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* A store with no active payment provider used to render the full payment step
|
|
9
|
-
* — PIX and Cartão — and fail only at order creation, telling the shopper
|
|
10
|
-
* "tente novamente em instantes": retry advice for a condition retrying never
|
|
11
|
-
* fixes. These pin the two honest answers instead.
|
|
12
|
-
*
|
|
13
|
-
* Rendered with NO slot provider on purpose: outside a
|
|
14
|
-
* `CheckoutComponentsProvider` the component falls back to the raw-MUI
|
|
15
|
-
* defaults, so this suite also pins that the default slots carry the
|
|
16
|
-
* load-bearing test ids. jest-dom is not a dependency here — DOM properties
|
|
17
|
-
* are asserted directly.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
afterEach(cleanup);
|
|
21
|
-
|
|
22
|
-
describe("checkout — store cannot charge online", () => {
|
|
23
|
-
it("offers the waiter when the shopper has a mesa", async () => {
|
|
24
|
-
const onCallWaiter = vi.fn();
|
|
25
|
-
render(<PaymentsUnavailable waiterAvailable onCallWaiter={onCallWaiter} />);
|
|
26
|
-
|
|
27
|
-
expect(screen.getByTestId("checkout-call-waiter")).toBeTruthy();
|
|
28
|
-
fireEvent.click(screen.getByTestId("checkout-call-waiter-button"));
|
|
29
|
-
expect(onCallWaiter).toHaveBeenCalledTimes(1);
|
|
30
|
-
|
|
31
|
-
// Never the dead end: the shopper is not told to retry.
|
|
32
|
-
await waitFor(() => expect(screen.queryByText(/tente novamente/i)).toBeNull());
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("says plainly that the store does not charge online with no waiter to call", async () => {
|
|
36
|
-
render(<PaymentsUnavailable waiterAvailable={false} />);
|
|
37
|
-
|
|
38
|
-
expect(screen.getByTestId("checkout-payments-disabled")).toBeTruthy();
|
|
39
|
-
// No waiter reachable — offering one would be a dead button.
|
|
40
|
-
await waitFor(() => expect(screen.queryByTestId("checkout-call-waiter-button")).toBeNull());
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("renders no waiter button when the host supplies no handler", async () => {
|
|
44
|
-
render(<PaymentsUnavailable waiterAvailable />);
|
|
45
|
-
await waitFor(() => expect(screen.queryByTestId("checkout-call-waiter-button")).toBeNull());
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("disables the waiter button while the request is in flight", () => {
|
|
49
|
-
render(<PaymentsUnavailable waiterAvailable onCallWaiter={vi.fn()} calling />);
|
|
50
|
-
const button = screen.getByTestId("checkout-call-waiter-button") as HTMLButtonElement;
|
|
51
|
-
expect(button.disabled).toBe(true);
|
|
52
|
-
});
|
|
53
|
-
});
|