@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,161 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* FUT-563 — the browser mints ONE instrument PER PROVIDER, so a card charge
|
|
4
|
-
* can walk the merchant's chain.
|
|
5
|
-
*
|
|
6
|
-
* A card token is bound to whoever minted it: the gateway refuses to hand
|
|
7
|
-
* provider #2 provider #1's blob and skips it instead. So the checkout's whole
|
|
8
|
-
* contribution to failover is this — take the card the buyer typed ONCE, and
|
|
9
|
-
* produce an instrument for each entry of the chain the server published.
|
|
10
|
-
*
|
|
11
|
-
* The scenarios the browser journey cannot reach live here: a chain entry with
|
|
12
|
-
* no in-browser scheme (a hosted page needs no instrument), a chain the host
|
|
13
|
-
* never published (an older server, or a fetch blip), and an entry whose
|
|
14
|
-
* tokenization fails while the others succeed.
|
|
15
|
-
*/
|
|
16
|
-
import { describe, expect, it } from "vitest";
|
|
17
|
-
|
|
18
|
-
import { cardChain, cardPathAvailable, cardTokenization } from "../method-capability";
|
|
19
|
-
import type { CheckoutProviderConfig } from "../types";
|
|
20
|
-
|
|
21
|
-
function link(
|
|
22
|
-
provider: string,
|
|
23
|
-
tokenization: CheckoutProviderConfig["tokenization"],
|
|
24
|
-
publicKey: string | null = null,
|
|
25
|
-
) {
|
|
26
|
-
return {
|
|
27
|
-
provider,
|
|
28
|
-
tokenization: tokenization as "NONE" | "PUBLIC_KEY" | "SDK" | "REDIRECT",
|
|
29
|
-
publicKey,
|
|
30
|
-
mockTokenization: false,
|
|
31
|
-
methods: ["PIX" as const, "CARD" as const],
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function config(over: Partial<CheckoutProviderConfig> = {}): CheckoutProviderConfig {
|
|
36
|
-
return {
|
|
37
|
-
provider: "pagbank",
|
|
38
|
-
tokenization: "PUBLIC_KEY",
|
|
39
|
-
publicKey: "pk_pagbank",
|
|
40
|
-
mockTokenization: false,
|
|
41
|
-
methods: ["PIX", "CARD"],
|
|
42
|
-
...over,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
describe("cardChain", () => {
|
|
47
|
-
it("gives one tokenization config per entry, in the merchant's order", async () => {
|
|
48
|
-
const chain = cardChain(
|
|
49
|
-
config({
|
|
50
|
-
chain: [link("pagbank", "PUBLIC_KEY", "pk_pagbank"), link("stone", "PUBLIC_KEY", "pk_stone")],
|
|
51
|
-
}),
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
expect(chain.map((entry) => entry.provider)).toEqual(["pagbank", "stone"]);
|
|
55
|
-
expect(chain.map((entry) => entry.publicKey)).toEqual(["pk_pagbank", "pk_stone"]);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("marks entries with no in-browser scheme unmintable instead of dropping them", async () => {
|
|
59
|
-
// A REDIRECT provider's own page takes the card, and a NONE one asks for no
|
|
60
|
-
// instrument. Minting for either would produce a fake token under stub mode
|
|
61
|
-
// and an error everywhere else — and the gateway passes an unattributed
|
|
62
|
-
// charge straight through to them anyway.
|
|
63
|
-
//
|
|
64
|
-
// They stay in the LIST because the charge walks them: the card path counts
|
|
65
|
-
// this list to decide whether `tokensByProvider` has to travel at all, and
|
|
66
|
-
// a chain counted by "who minted" hides the hosted page from the server.
|
|
67
|
-
const chain = cardChain(
|
|
68
|
-
config({
|
|
69
|
-
chain: [
|
|
70
|
-
link("pagbank", "PUBLIC_KEY", "pk_pagbank"),
|
|
71
|
-
link("infinitepay", "REDIRECT"),
|
|
72
|
-
link("nothing", "NONE"),
|
|
73
|
-
],
|
|
74
|
-
}),
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
expect(chain.map((entry) => entry.provider)).toEqual(["pagbank", "infinitepay", "nothing"]);
|
|
78
|
-
expect(chain.map((entry) => entry.mintable)).toEqual([true, false, false]);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("falls back to the HEAD alone when the host published no chain", async () => {
|
|
82
|
-
// Exactly the pre-FUT-563 behaviour: one instrument, for the active
|
|
83
|
-
// provider. A server that does not answer a chain must not break checkout.
|
|
84
|
-
expect(cardChain(config())).toEqual([{ ...cardTokenization(config()), mintable: true }]);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("has nothing to mint for a store with no provider at all", async () => {
|
|
88
|
-
expect(cardChain(null)).toEqual([]);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("never re-heads or reorders the merchant's list", async () => {
|
|
92
|
-
// The storefront is a READER of the priority list. Preferring the entry
|
|
93
|
-
// with a key, or sorting by anything, would silently override the order the
|
|
94
|
-
// owner set and the plan ceiling already truncated.
|
|
95
|
-
const chain = cardChain(
|
|
96
|
-
config({
|
|
97
|
-
provider: "stone",
|
|
98
|
-
chain: [link("stone", "PUBLIC_KEY", null), link("pagbank", "PUBLIC_KEY", "pk_pagbank")],
|
|
99
|
-
}),
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
expect(chain.map((entry) => entry.provider)).toEqual(["stone", "pagbank"]);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("counts the whole chain, hosted entries included", async () => {
|
|
106
|
-
// What `tokensByProvider`'s presence is decided on. A card acquirer plus a
|
|
107
|
-
// hosted-page fallback is TWO providers the walk may reach, even though
|
|
108
|
-
// only one of them is ever minted for.
|
|
109
|
-
const chain = cardChain(
|
|
110
|
-
config({ chain: [link("pagbank", "PUBLIC_KEY", "pk_pagbank"), link("infinitepay", "REDIRECT")] }),
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
expect(chain).toHaveLength(2);
|
|
114
|
-
expect(chain.filter((entry) => entry.mintable)).toHaveLength(1);
|
|
115
|
-
});
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* FUT-563 — the picker and the SERVER must answer the same question.
|
|
120
|
-
*
|
|
121
|
-
* `usesHostedCheckout` asks the WHOLE chain whether anybody tokenizes in the
|
|
122
|
-
* browser, so a REDIRECT head no longer means "the buyer is handed over". A
|
|
123
|
-
* client that still read the head alone offered a card the submit could not
|
|
124
|
-
* mint, and a store whose only mintable entry has no key offered one nobody
|
|
125
|
-
* could pay.
|
|
126
|
-
*/
|
|
127
|
-
describe("cardPathAvailable", () => {
|
|
128
|
-
it("offers CARD for a hosted-page store: its own site takes the card", async () => {
|
|
129
|
-
expect(cardPathAvailable(config({ chain: [link("infinitepay", "REDIRECT")] }))).toBe(true);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("offers CARD when a LATER entry can be minted for, not just the head", async () => {
|
|
133
|
-
// The server shows our form for exactly this chain, so the picker must
|
|
134
|
-
// agree — and the submit mints against the tail.
|
|
135
|
-
const mixed = config({
|
|
136
|
-
provider: "infinitepay",
|
|
137
|
-
tokenization: "REDIRECT",
|
|
138
|
-
publicKey: null,
|
|
139
|
-
chain: [link("infinitepay", "REDIRECT"), link("pagbank", "PUBLIC_KEY", "pk_pagbank")],
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
expect(cardPathAvailable(mixed)).toBe(true);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("refuses CARD when the only mintable entry has no key this browser can use", async () => {
|
|
146
|
-
// Stone with no publishable key: the form would render and the submit
|
|
147
|
-
// would die. Better a disabled tile that says so.
|
|
148
|
-
const unusable = config({
|
|
149
|
-
provider: "infinitepay",
|
|
150
|
-
tokenization: "REDIRECT",
|
|
151
|
-
publicKey: null,
|
|
152
|
-
chain: [link("infinitepay", "REDIRECT"), link("stone", "PUBLIC_KEY", null)],
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
expect(cardPathAvailable(unusable)).toBe(false);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("still fails OPEN while the config has not answered", async () => {
|
|
159
|
-
expect(cardPathAvailable(null)).toBe(true);
|
|
160
|
-
});
|
|
161
|
-
});
|
|
@@ -1,165 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* The second-host proof (FUT-564 acceptance).
|
|
4
|
-
*
|
|
5
|
-
* This package's dependency graph contains NO `@12-apps/ui` — it is not even
|
|
6
|
-
* resolvable from here — so a green run of this suite IS the proof that a host
|
|
7
|
-
* without this repo's design system can mount the buyer checkout: every pixel
|
|
8
|
-
* below renders through the raw-MUI default slots (`mui-defaults.tsx`),
|
|
9
|
-
* because no `components` prop is passed and no provider sits above the flow.
|
|
10
|
-
*
|
|
11
|
-
* The walk is the buyer's own: Dados (CPF form, consent checkbox, pay bar) →
|
|
12
|
-
* Pagamento (method picker) → the card form, with the order raised through the
|
|
13
|
-
* host port. The load-bearing test ids must survive the default slots too —
|
|
14
|
-
* they are the same hooks the storefront journeys click.
|
|
15
|
-
*/
|
|
16
|
-
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
17
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
18
|
-
|
|
19
|
-
import { CheckoutFlow } from "../checkout-flow";
|
|
20
|
-
import type { CheckoutOrder, CreateOrderRequest, CreateOrderResult } from "../types";
|
|
21
|
-
|
|
22
|
-
afterEach(cleanup);
|
|
23
|
-
|
|
24
|
-
const CARD_ORDER: CheckoutOrder = {
|
|
25
|
-
orderId: "o-2nd-host",
|
|
26
|
-
status: "AWAITING_PAYMENT",
|
|
27
|
-
method: "CARD",
|
|
28
|
-
totalCents: 1000,
|
|
29
|
-
subtotalCents: 1000,
|
|
30
|
-
discountTotalCents: 0,
|
|
31
|
-
appliedDiscounts: [],
|
|
32
|
-
totalLabel: "R$ 10,00",
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
/** A valid CPF (passes the client-side check digits). */
|
|
36
|
-
const VALID_CPF = "52998224725";
|
|
37
|
-
|
|
38
|
-
describe("CheckoutFlow with no design system present (raw-MUI default slots)", () => {
|
|
39
|
-
it("walks Dados → Pagamento → card form through the default slots", async () => {
|
|
40
|
-
const createOrder = vi.fn<(input: CreateOrderRequest) => Promise<CreateOrderResult>>(
|
|
41
|
-
async () => ({ ok: true, data: CARD_ORDER }),
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
render(
|
|
45
|
-
<CheckoutFlow
|
|
46
|
-
cart={{ empty: false, totalLabel: "R$ 10,00", totalItems: 1 }}
|
|
47
|
-
createOrder={createOrder}
|
|
48
|
-
onExitToMenu={vi.fn()}
|
|
49
|
-
/>,
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
// Dados renders through the defaults: stepper, CPF field, consent, pay bar.
|
|
53
|
-
expect(screen.getByTestId("checkout-stepper")).toBeTruthy();
|
|
54
|
-
expect(screen.getByTestId("buyer-save-profile")).toBeTruthy();
|
|
55
|
-
expect(screen.getByTestId("checkout-pay-bar")).toBeTruthy();
|
|
56
|
-
|
|
57
|
-
fireEvent.change(screen.getByTestId("buyer-cpf"), { target: { value: VALID_CPF } });
|
|
58
|
-
fireEvent.click(screen.getByTestId("checkout-continue"));
|
|
59
|
-
|
|
60
|
-
// Pagamento: the method picker, with both in-browser methods on offer.
|
|
61
|
-
await waitFor(() => expect(screen.getByTestId("checkout-method")).toBeTruthy());
|
|
62
|
-
expect(screen.getByTestId("checkout-method-PIX")).toBeTruthy();
|
|
63
|
-
|
|
64
|
-
// Choosing card auto-raises the order through the HOST port…
|
|
65
|
-
fireEvent.click(screen.getByTestId("checkout-method-CARD"));
|
|
66
|
-
await waitFor(() => expect(createOrder).toHaveBeenCalledTimes(1));
|
|
67
|
-
expect(createOrder.mock.calls[0]?.[0]).toMatchObject({ method: "CARD", saveProfile: true });
|
|
68
|
-
|
|
69
|
-
// …and the card form renders, again through the default slots.
|
|
70
|
-
await waitFor(() => expect(screen.getByTestId("card-number")).toBeTruthy());
|
|
71
|
-
expect(screen.getByTestId("card-holder")).toBeTruthy();
|
|
72
|
-
expect(screen.getByTestId("card-pay")).toBeTruthy();
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("shows the empty-cart state for a cart with nothing to pay", () => {
|
|
76
|
-
render(
|
|
77
|
-
<CheckoutFlow
|
|
78
|
-
cart={{ empty: true, totalLabel: "R$ 0,00", totalItems: 0 }}
|
|
79
|
-
createOrder={vi.fn()}
|
|
80
|
-
onExitToMenu={vi.fn()}
|
|
81
|
-
/>,
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
expect(screen.getByTestId("checkout-empty")).toBeTruthy();
|
|
85
|
-
});
|
|
86
|
-
});
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* FUT-563 — a charge NOBODY can confirm is not a charge that failed.
|
|
4
|
-
*
|
|
5
|
-
* `PAYMENT_UNRESOLVED` means some provider may be holding the buyer's money and
|
|
6
|
-
* no probe could say. It is the one refusal where inviting a retry is actively
|
|
7
|
-
* harmful, and both checkout surfaces used to do exactly that: the card view
|
|
8
|
-
* put "não pague de novo" under a danger heading reading "Não foi possível
|
|
9
|
-
* pagar", with the live "Pagar R$ …" bar directly beneath it; the Pagamento
|
|
10
|
-
* step gave the same body a solid "Tentar novamente" that mints a SECOND order
|
|
11
|
-
* at a new reference, outside the walk's re-probe of the first.
|
|
12
|
-
*
|
|
13
|
-
* What is pinned here is the presentation, not the words: the code decides it,
|
|
14
|
-
* so a copy edit cannot silently turn the affordance back on.
|
|
15
|
-
*/
|
|
16
|
-
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
17
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
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 { CardView } from "../card-view";
|
|
30
|
-
import { PaymentErrorPanel } from "../payment-error-panel";
|
|
31
|
-
import type { CheckoutOrder } from "../types";
|
|
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 UNRESOLVED =
|
|
45
|
-
"Estamos confirmando o seu pagamento com o provedor. NÃO pague de novo — se a cobrança " +
|
|
46
|
-
"foi feita, ela será confirmada sozinha.";
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* A card already in the vault. Paying with it needs no tokenizer and no typing,
|
|
50
|
-
* which keeps these tests about the RENDERING of the answer.
|
|
51
|
-
*/
|
|
52
|
-
const SAVED = {
|
|
53
|
-
id: "card_1",
|
|
54
|
-
brand: "visa",
|
|
55
|
-
last4: "1111",
|
|
56
|
-
expMonth: 12,
|
|
57
|
-
expYear: 2034,
|
|
58
|
-
holder: "VERA CADEIA",
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
beforeEach(() => {
|
|
62
|
-
vi.clearAllMocks();
|
|
63
|
-
client.listSavedCards.mockResolvedValue([SAVED]);
|
|
64
|
-
client.refreshCardPublicKey.mockResolvedValue({ ok: true, data: { publicKey: null } });
|
|
65
|
-
client.pollOrderStatus.mockResolvedValue({ ok: true, data: "AWAITING_PAYMENT" });
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
afterEach(() => {
|
|
69
|
-
cleanup();
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe("the CARD view", () => {
|
|
73
|
-
/** Render the card form and drive a submit whose charge comes back `code`d. */
|
|
74
|
-
async function submitWith(failure: { error: string; code?: string }): Promise<void> {
|
|
75
|
-
client.chargeCard.mockResolvedValue({ ok: false, ...failure });
|
|
76
|
-
render(
|
|
77
|
-
<CardView
|
|
78
|
-
order={ORDER}
|
|
79
|
-
providerConfig={{ provider: "pagbank", publicKey: null, mockTokenization: true }}
|
|
80
|
-
providerChain={[
|
|
81
|
-
{ provider: "pagbank", publicKey: null, mockTokenization: true, mintable: true },
|
|
82
|
-
]}
|
|
83
|
-
onResolved={vi.fn()}
|
|
84
|
-
/>,
|
|
85
|
-
);
|
|
86
|
-
// The saved card is preselected once the list lands, so the pay bar is
|
|
87
|
-
// everything this submit needs.
|
|
88
|
-
await waitFor(() => {
|
|
89
|
-
expect(screen.getByTestId("saved-cards")).toBeTruthy();
|
|
90
|
-
});
|
|
91
|
-
fireEvent.click(screen.getByTestId("card-pay-bar").querySelector("button") as HTMLElement);
|
|
92
|
-
await waitFor(() => {
|
|
93
|
-
expect(client.chargeCard).toHaveBeenCalled();
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
it("shows an UNRESOLVED charge as a warning, and takes the pay bar away", async () => {
|
|
98
|
-
await submitWith({ error: UNRESOLVED, code: "PAYMENT_UNRESOLVED" });
|
|
99
|
-
|
|
100
|
-
// Not "Não foi possível pagar": the buyer reads the bold title first, and
|
|
101
|
-
// that one contradicts the body's own instruction. And the one action the
|
|
102
|
-
// message forbids is not on screen at all.
|
|
103
|
-
await waitFor(() => {
|
|
104
|
-
expect(screen.getByTestId("card-unresolved")).toBeTruthy();
|
|
105
|
-
expect(screen.queryByTestId("card-error")).toBeNull();
|
|
106
|
-
expect(screen.queryByTestId("card-pay-bar")).toBeNull();
|
|
107
|
-
});
|
|
108
|
-
expect(screen.getByText("Estamos confirmando seu pagamento")).toBeTruthy();
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("still shows an ordinary failure as a failure, with the pay bar intact", async () => {
|
|
112
|
-
await submitWith({ error: "Cartão recusado.", code: "PAYMENT_UNAVAILABLE" });
|
|
113
|
-
|
|
114
|
-
await waitFor(() => {
|
|
115
|
-
expect(screen.getByTestId("card-error")).toBeTruthy();
|
|
116
|
-
});
|
|
117
|
-
expect(screen.getByTestId("card-pay-bar")).toBeTruthy();
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
describe("the Pagamento step's error panel", () => {
|
|
122
|
-
it("offers NO retry for an unresolved charge", () => {
|
|
123
|
-
const { container } = render(
|
|
124
|
-
<PaymentErrorPanel
|
|
125
|
-
message={UNRESOLVED}
|
|
126
|
-
emailFlagged={false}
|
|
127
|
-
code="PAYMENT_UNRESOLVED"
|
|
128
|
-
onUseEmail={vi.fn()}
|
|
129
|
-
onRetry={vi.fn()}
|
|
130
|
-
/>,
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
// "Tentar novamente" re-runs createOrder: a new order at a new reference,
|
|
134
|
-
// which the walk's re-probe of the old one cannot protect.
|
|
135
|
-
expect(screen.getByTestId("checkout-unresolved")).toBeTruthy();
|
|
136
|
-
// Asserted on the rendered TEXT: nothing here is asynchronous, so this is
|
|
137
|
-
// not an element that went away — the affordance was never offered.
|
|
138
|
-
expect(container.textContent).not.toContain("Tentar novamente");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("keeps the retry for every other refusal", () => {
|
|
142
|
-
render(
|
|
143
|
-
<PaymentErrorPanel
|
|
144
|
-
message="Não foi possível criar o pedido."
|
|
145
|
-
emailFlagged={false}
|
|
146
|
-
code="EMPTY_CART"
|
|
147
|
-
onUseEmail={vi.fn()}
|
|
148
|
-
onRetry={vi.fn()}
|
|
149
|
-
/>,
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
expect(screen.getByTestId("checkout-error")).toBeTruthy();
|
|
153
|
-
expect(screen.getByTestId("checkout-retry-payment")).toBeTruthy();
|
|
154
|
-
});
|
|
155
|
-
});
|