@12-apps/payments-frontend 1.0.0 → 1.2.1
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 +7 -4
- package/src/__tests__/provider-priority-list.test.tsx +2 -2
- package/src/__tests__/slugged-provider.test.tsx +108 -0
- package/src/card/cpf.ts +42 -0
- package/src/card/fields.tsx +254 -0
- package/src/card/format.ts +103 -0
- package/src/card/index.ts +42 -0
- package/src/card/stripe-token.ts +81 -0
- package/src/card/tokenize.test.ts +194 -0
- package/src/card/tokenize.ts +327 -0
- package/src/card/types.ts +54 -0
- package/src/components/PaymentProviderSettings.tsx +30 -4
- package/src/components/checkout/__tests__/card-3ds-handover.test.tsx +147 -0
- package/src/components/checkout/__tests__/clear-cart-on-paid.test.tsx +64 -0
- package/src/components/checkout/__tests__/hosted-return.test.ts +109 -0
- package/src/components/checkout/__tests__/method-capability.test.tsx +120 -0
- package/src/components/checkout/__tests__/payments-unavailable.test.tsx +53 -0
- package/src/components/checkout/__tests__/save-on-continue.test.tsx +165 -0
- package/src/components/checkout/__tests__/second-host.test.tsx +86 -0
- package/src/components/checkout/buyer-info-form.tsx +138 -0
- package/src/components/checkout/card-view.tsx +128 -0
- package/src/components/checkout/checkout-flow.tsx +201 -0
- package/src/components/checkout/checkout-steps.tsx +366 -0
- package/src/components/checkout/client.ts +157 -0
- package/src/components/checkout/hosted-return.ts +92 -0
- package/src/components/checkout/icons.tsx +61 -0
- package/src/components/checkout/method-capability.ts +69 -0
- package/src/components/checkout/method-picker.tsx +153 -0
- package/src/components/checkout/mui-defaults.tsx +218 -0
- package/src/components/checkout/payer-summary.tsx +81 -0
- package/src/components/checkout/payment-status.tsx +256 -0
- package/src/components/checkout/payments-unavailable.tsx +79 -0
- package/src/components/checkout/pix-view.tsx +179 -0
- package/src/components/checkout/types.ts +223 -0
- package/src/components/checkout/ui.tsx +171 -0
- package/src/components/checkout/use-card-checkout.ts +346 -0
- package/src/components/checkout/use-checkout-controller.ts +252 -0
- package/src/components/checkout/use-payment-polling.ts +93 -0
- package/src/components/settings-state.ts +45 -2
- package/src/index.ts +74 -1
- package/src/result.ts +11 -0
- package/src/components/CheckoutFlow.tsx +0 -169
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* FUT-698 — "Dado uma loja Stripe, quando o comprador paga com um cartão que
|
|
4
|
+
* exige 3DS, então ele é levado ao desafio" — the browser-unreachable half of
|
|
5
|
+
* that scenario (the stub adapter settles cards inline, so no e2e journey can
|
|
6
|
+
* produce a real challenge). The return half — "e volta com o pedido pago
|
|
7
|
+
* exatamente uma vez" — is the hosted-return machinery, pinned in
|
|
8
|
+
* hosted-return.test.ts and settled server-side by the idempotent webhook path.
|
|
9
|
+
*
|
|
10
|
+
* The charge answers `hostedCheckoutUrl` and the card hook must hand the buyer
|
|
11
|
+
* over exactly as a redirect provider's link does (FUT-556): park the order,
|
|
12
|
+
* navigate, and start NO poll in a tab that is being torn down.
|
|
13
|
+
*/
|
|
14
|
+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
16
|
+
|
|
17
|
+
import type { JSX } from "react";
|
|
18
|
+
|
|
19
|
+
const client = vi.hoisted(() => ({
|
|
20
|
+
chargeCard: vi.fn(),
|
|
21
|
+
listSavedCards: vi.fn(),
|
|
22
|
+
refreshCardPublicKey: vi.fn(),
|
|
23
|
+
pollOrderStatus: vi.fn(),
|
|
24
|
+
fetchCheckoutConfig: vi.fn(),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock("../client", () => client);
|
|
28
|
+
|
|
29
|
+
import type { CardTokenizationConfig } from "../../../card";
|
|
30
|
+
import type { CheckoutOrder, OrderStatus } from "../types";
|
|
31
|
+
import { useCardCheckout } from "../use-card-checkout";
|
|
32
|
+
|
|
33
|
+
const ORDER: CheckoutOrder = {
|
|
34
|
+
orderId: "o1",
|
|
35
|
+
status: "AWAITING_PAYMENT",
|
|
36
|
+
method: "CARD",
|
|
37
|
+
totalCents: 1250,
|
|
38
|
+
subtotalCents: 1250,
|
|
39
|
+
discountTotalCents: 0,
|
|
40
|
+
appliedDiscounts: [],
|
|
41
|
+
totalLabel: "R$ 12,50",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const STRIPE_CONFIG: CardTokenizationConfig = {
|
|
45
|
+
provider: "stripe",
|
|
46
|
+
publicKey: "pk_test_1",
|
|
47
|
+
mockTokenization: false,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** A saved card, so paying skips tokenization and goes straight to the charge. */
|
|
51
|
+
const SAVED_CARD = {
|
|
52
|
+
id: "card-1",
|
|
53
|
+
brand: "visa",
|
|
54
|
+
last4: "4242",
|
|
55
|
+
expMonth: 12,
|
|
56
|
+
expYear: 2033,
|
|
57
|
+
holder: "OLGA STONE",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function Harness({ onResolved }: { onResolved: (status: OrderStatus) => void }): JSX.Element {
|
|
61
|
+
const cc = useCardCheckout(ORDER, {}, STRIPE_CONFIG, onResolved, 10, "acme");
|
|
62
|
+
return (
|
|
63
|
+
<div>
|
|
64
|
+
<span data-testid="submitted">{String(cc.submitted)}</span>
|
|
65
|
+
<span data-testid="selection">{cc.selection}</span>
|
|
66
|
+
<button type="button" data-testid="pay" onClick={() => void cc.handlePay()}>
|
|
67
|
+
Pagar
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Render, wait for the saved card to be SELECTED, and tap Pagar. */
|
|
74
|
+
async function pay(onResolved = vi.fn()): Promise<void> {
|
|
75
|
+
render(<Harness onResolved={onResolved} />);
|
|
76
|
+
// Selection — not merely the fetch — is what routes handlePay past the
|
|
77
|
+
// empty new-card form; clicking earlier validates that form and stops.
|
|
78
|
+
await waitFor(() => {
|
|
79
|
+
expect(screen.getByTestId("selection").textContent).toBe("card-1");
|
|
80
|
+
});
|
|
81
|
+
fireEvent.click(screen.getByTestId("pay"));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const assign = vi.fn();
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
vi.clearAllMocks();
|
|
88
|
+
window.sessionStorage.clear();
|
|
89
|
+
client.listSavedCards.mockResolvedValue([SAVED_CARD]);
|
|
90
|
+
client.pollOrderStatus.mockResolvedValue({ ok: true, data: "AWAITING_PAYMENT" });
|
|
91
|
+
vi.stubGlobal("location", { search: "", assign });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
cleanup();
|
|
96
|
+
vi.unstubAllGlobals();
|
|
97
|
+
window.sessionStorage.clear();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("card charge → 3DS handover (FUT-698)", () => {
|
|
101
|
+
it("hands the buyer to the challenge page with the order parked for the return trip", async () => {
|
|
102
|
+
client.chargeCard.mockResolvedValue({
|
|
103
|
+
ok: true,
|
|
104
|
+
data: { status: "AWAITING_PAYMENT", hostedCheckoutUrl: "https://hooks.stripe.com/3ds/x" },
|
|
105
|
+
});
|
|
106
|
+
const onResolved = vi.fn();
|
|
107
|
+
|
|
108
|
+
await pay(onResolved);
|
|
109
|
+
|
|
110
|
+
await waitFor(() => {
|
|
111
|
+
expect(assign).toHaveBeenCalledWith("https://hooks.stripe.com/3ds/x");
|
|
112
|
+
});
|
|
113
|
+
// Parked exactly as the redirect-provider handoff parks (FUT-556), so the
|
|
114
|
+
// return trip resumes THIS order's confirmation screen.
|
|
115
|
+
const parked = window.sessionStorage.getItem("futurepay.checkout.hostedOrder");
|
|
116
|
+
expect(parked).not.toBeNull();
|
|
117
|
+
expect((JSON.parse(parked as string) as { orderId: string }).orderId).toBe("o1");
|
|
118
|
+
// The tab is navigating away: nothing resolved, no poll started here.
|
|
119
|
+
expect(onResolved).not.toHaveBeenCalled();
|
|
120
|
+
expect(client.pollOrderStatus).not.toHaveBeenCalled();
|
|
121
|
+
expect(screen.getByTestId("submitted").textContent).toBe("false");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("an accepted charge WITHOUT a challenge begins polling instead of navigating", async () => {
|
|
125
|
+
client.chargeCard.mockResolvedValue({ ok: true, data: { status: "AWAITING_PAYMENT" } });
|
|
126
|
+
|
|
127
|
+
await pay();
|
|
128
|
+
|
|
129
|
+
await waitFor(() => {
|
|
130
|
+
expect(screen.getByTestId("submitted").textContent).toBe("true");
|
|
131
|
+
});
|
|
132
|
+
expect(assign).not.toHaveBeenCalled();
|
|
133
|
+
expect(window.sessionStorage.getItem("futurepay.checkout.hostedOrder")).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("a decline resolves to the status screen, never a handover", async () => {
|
|
137
|
+
client.chargeCard.mockResolvedValue({ ok: true, data: { status: "FAILED" } });
|
|
138
|
+
const onResolved = vi.fn();
|
|
139
|
+
|
|
140
|
+
await pay(onResolved);
|
|
141
|
+
|
|
142
|
+
await waitFor(() => {
|
|
143
|
+
expect(onResolved).toHaveBeenCalledWith("FAILED");
|
|
144
|
+
});
|
|
145
|
+
expect(assign).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The buyer's details must be persisted when they press "Continuar" on the
|
|
4
|
+
* "Dados" step — NOT when a payment is raised.
|
|
5
|
+
*
|
|
6
|
+
* This is a regression test with a history: the save used to live only in the
|
|
7
|
+
* order-creation path, which runs a whole step later, only once a payment
|
|
8
|
+
* method is chosen, and which deliberately never writes the CPF. A buyer who
|
|
9
|
+
* filled the form and then hit anything at all on the payment screen — no
|
|
10
|
+
* provider configured, a declined card, an abandoned PIX, a closed tab — had
|
|
11
|
+
* nothing stored.
|
|
12
|
+
*
|
|
13
|
+
* Since FUT-564 the write itself belongs to the host (`saveBuyerContact` port;
|
|
14
|
+
* the storefront's implementation owns the wire shape and the blank-CPF-never-
|
|
15
|
+
* clears rule, pinned in its own suite). What THIS suite pins is the flow's
|
|
16
|
+
* side of the contract — TIMING, CONSENT and INDEPENDENCE: the port fires on
|
|
17
|
+
* "Continuar", carries the CPF, respects the LGPD checkbox, and the step
|
|
18
|
+
* advances no matter what the host does with the call.
|
|
19
|
+
*/
|
|
20
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
21
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
22
|
+
|
|
23
|
+
import { useCheckoutController, type CheckoutHostPorts } from "../use-checkout-controller";
|
|
24
|
+
import type { BuyerContact } from "../types";
|
|
25
|
+
|
|
26
|
+
/** A valid CPF (passes the client-side check digits). */
|
|
27
|
+
const VALID_CPF = "52998224725";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One test's own port set + recorder. Everything is a local — no module-level
|
|
31
|
+
* mutable state, so the tests carry no order dependency between them
|
|
32
|
+
* (`test-flakiness/no-test-isolation`). The stub only ever PUSHES onto its
|
|
33
|
+
* container, never reassigns a closed-over binding (`no-global-state-mutation`).
|
|
34
|
+
*/
|
|
35
|
+
function makePorts(): {
|
|
36
|
+
ports: CheckoutHostPorts;
|
|
37
|
+
savedContacts: () => BuyerContact[];
|
|
38
|
+
orderRequested: () => boolean;
|
|
39
|
+
} {
|
|
40
|
+
const recorded: BuyerContact[] = [];
|
|
41
|
+
const createOrder = vi.fn();
|
|
42
|
+
return {
|
|
43
|
+
ports: {
|
|
44
|
+
createOrder,
|
|
45
|
+
saveBuyerContact: (contact) => {
|
|
46
|
+
recorded.push(contact);
|
|
47
|
+
},
|
|
48
|
+
onExitToMenu: vi.fn(),
|
|
49
|
+
},
|
|
50
|
+
savedContacts: () => recorded,
|
|
51
|
+
orderRequested: () => createOrder.mock.calls.length > 0,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
vi.restoreAllMocks();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('"Dados" step persists the buyer on Continuar', () => {
|
|
60
|
+
it("saves name, phone and CPF the moment Continuar is pressed", async () => {
|
|
61
|
+
const { ports, savedContacts, orderRequested } = makePorts();
|
|
62
|
+
const { result } = renderHook(() => useCheckoutController(ports));
|
|
63
|
+
|
|
64
|
+
act(() => {
|
|
65
|
+
result.current.setBuyer({ name: "Ana", phone: "11999999999", taxId: VALID_CPF });
|
|
66
|
+
});
|
|
67
|
+
act(() => {
|
|
68
|
+
result.current.goToPayment();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await waitFor(() => expect(savedContacts()).toHaveLength(1));
|
|
72
|
+
expect(savedContacts()[0]).toEqual({
|
|
73
|
+
name: "Ana",
|
|
74
|
+
phone: "11999999999",
|
|
75
|
+
taxId: VALID_CPF,
|
|
76
|
+
});
|
|
77
|
+
// The whole point: no order was created, and none needed to be.
|
|
78
|
+
expect(orderRequested()).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("advances to Pagamento without waiting on the save", async () => {
|
|
82
|
+
const { ports, savedContacts } = makePorts();
|
|
83
|
+
// A host whose save blows up must not trap the buyer on the Dados step —
|
|
84
|
+
// the port is fire-and-forget, so the flow never sees the failure at all.
|
|
85
|
+
const { result } = renderHook(() => useCheckoutController(ports));
|
|
86
|
+
|
|
87
|
+
act(() => {
|
|
88
|
+
result.current.setBuyer({ name: "Ana", taxId: VALID_CPF });
|
|
89
|
+
});
|
|
90
|
+
act(() => {
|
|
91
|
+
result.current.goToPayment();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await waitFor(() => expect(savedContacts()).toHaveLength(1));
|
|
95
|
+
expect(result.current.step).toBe("payment");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does not save when the buyer declined the consent checkbox", async () => {
|
|
99
|
+
const { ports, savedContacts } = makePorts();
|
|
100
|
+
const { result } = renderHook(() => useCheckoutController(ports));
|
|
101
|
+
|
|
102
|
+
act(() => {
|
|
103
|
+
result.current.setBuyer({ name: "Ana", taxId: VALID_CPF });
|
|
104
|
+
result.current.setSaveProfile(false);
|
|
105
|
+
});
|
|
106
|
+
act(() => {
|
|
107
|
+
result.current.goToPayment();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await waitFor(() => expect(result.current.step).toBe("payment"));
|
|
111
|
+
expect(savedContacts()).toHaveLength(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("does not save — or advance — when the CPF is invalid", async () => {
|
|
115
|
+
const { ports, savedContacts } = makePorts();
|
|
116
|
+
const { result } = renderHook(() => useCheckoutController(ports));
|
|
117
|
+
|
|
118
|
+
act(() => {
|
|
119
|
+
result.current.setBuyer({ name: "Ana", taxId: "11111111111" });
|
|
120
|
+
});
|
|
121
|
+
act(() => {
|
|
122
|
+
result.current.goToPayment();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await waitFor(() => expect(result.current.errorField).toBe("cpf"));
|
|
126
|
+
expect(result.current.step).toBe("dados");
|
|
127
|
+
expect(savedContacts()).toHaveLength(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("lets a buyer with a CPF on file continue without retyping it", async () => {
|
|
131
|
+
const { ports, savedContacts } = makePorts();
|
|
132
|
+
// Third argument: the buyer already gave the store a CPF, so Dados was
|
|
133
|
+
// skipped and they reopened it through "Alterar". The field starts empty
|
|
134
|
+
// (the client never receives the saved value) — requiring one here stranded
|
|
135
|
+
// them on a form they had no way to satisfy.
|
|
136
|
+
const { result } = renderHook(() => useCheckoutController(ports, undefined, true));
|
|
137
|
+
|
|
138
|
+
act(() => {
|
|
139
|
+
result.current.setStep("dados");
|
|
140
|
+
});
|
|
141
|
+
act(() => {
|
|
142
|
+
result.current.goToPayment();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await waitFor(() => expect(result.current.step).toBe("payment"));
|
|
146
|
+
expect(result.current.errorField).toBeNull();
|
|
147
|
+
// Nothing typed ⇒ the port carries no values at all. What the HOST must do
|
|
148
|
+
// with a blank CPF (omit it so it can never clear the stored one) is the
|
|
149
|
+
// host's contract, pinned beside its `saveBuyerContact` implementation.
|
|
150
|
+
expect(savedContacts()[0]).toEqual({ name: undefined, phone: undefined, taxId: undefined });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("still demands a CPF from a buyer who has none on file", async () => {
|
|
154
|
+
const { ports, savedContacts } = makePorts();
|
|
155
|
+
const { result } = renderHook(() => useCheckoutController(ports));
|
|
156
|
+
|
|
157
|
+
act(() => {
|
|
158
|
+
result.current.goToPayment();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await waitFor(() => expect(result.current.errorField).toBe("cpf"));
|
|
162
|
+
expect(result.current.step).toBe("dados");
|
|
163
|
+
expect(savedContacts()).toHaveLength(0);
|
|
164
|
+
});
|
|
165
|
+
});
|