@12-apps/payments-frontend 1.3.1 → 1.4.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 +13 -5
- package/src/card/stripe-token.ts +3 -0
- package/src/card/tokenize.ts +17 -3
- package/src/components/checkout/card-instruments.ts +222 -0
- package/src/components/checkout/card-view.tsx +56 -6
- package/src/components/checkout/checkout-flow.tsx +1 -0
- package/src/components/checkout/checkout-steps.tsx +13 -54
- package/src/components/checkout/client.ts +21 -3
- package/src/components/checkout/failure-codes.ts +17 -0
- package/src/components/checkout/method-capability.ts +83 -9
- package/src/components/checkout/payment-error-panel.tsx +97 -0
- package/src/components/checkout/types.ts +39 -4
- package/src/components/checkout/use-card-checkout.ts +77 -91
- package/src/components/checkout/use-checkout-controller.ts +33 -6
- package/src/result.ts +15 -2
- 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__/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__/payments-unavailable.test.tsx +0 -53
- package/src/components/checkout/__tests__/save-on-continue.test.tsx +0 -165
- package/src/components/checkout/__tests__/second-host.test.tsx +0 -86
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
-
|
|
5
|
-
import type { MerchantSettingsView, ProviderSetupGuide } from '@12-apps/payments-backend';
|
|
6
|
-
|
|
7
|
-
import type { PaymentsSettingsClient } from '../client';
|
|
8
|
-
import { PaymentProviderSettings } from '../components/PaymentProviderSettings';
|
|
9
|
-
import { CHECKOUT_CONFIRM_ACTION } from '../components/SetupGuideSection';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* The setup step no API can answer, and what it holds back.
|
|
13
|
-
*
|
|
14
|
-
* InfinitePay ships Checkout Integrado disabled and publishes nothing that
|
|
15
|
-
* reports its state, so a store can pass the probe, read `CONEXÃO OK`, and
|
|
16
|
-
* still be unable to mint a single payment link. The only reading available is
|
|
17
|
-
* the owner's.
|
|
18
|
-
*
|
|
19
|
-
* Until they give it, the walkthrough stays on that step and the activation
|
|
20
|
-
* step is not on screen at all — one step at a time is the whole shape of this
|
|
21
|
-
* flow, and a card offering to charge R$ 1,01 sitting under "Passo 2" is two
|
|
22
|
-
* steps at once, the costly one unasked for.
|
|
23
|
-
*
|
|
24
|
-
* The exception is the case that must never regress: a store that has ALREADY
|
|
25
|
-
* paid sees the step regardless of what the guide would show, because that
|
|
26
|
-
* panel is what reports the confirmed charge — and, on a return trip, what
|
|
27
|
-
* reads the `transaction_nsu` the provider sends the payer back with.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
const GUIDE: ProviderSetupGuide = {
|
|
31
|
-
stages: [
|
|
32
|
-
{ id: 'handle', label: 'Informar InfiniteTag' },
|
|
33
|
-
{ id: 'enable', label: 'Habilitar o Checkout' },
|
|
34
|
-
{ id: 'activate', label: 'Ativar vendas' },
|
|
35
|
-
],
|
|
36
|
-
sections: [
|
|
37
|
-
{
|
|
38
|
-
id: 'enable',
|
|
39
|
-
title: 'Habilitar o Checkout Integrado',
|
|
40
|
-
steps: [{ text: 'Confirme que está ligado.', action: CHECKOUT_CONFIRM_ACTION }],
|
|
41
|
-
},
|
|
42
|
-
],
|
|
43
|
-
activeStage: 2,
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
function viewWith(chargeVerifiedAt: string | null): MerchantSettingsView {
|
|
47
|
-
return {
|
|
48
|
-
providers: [
|
|
49
|
-
{
|
|
50
|
-
name: 'infinitepay',
|
|
51
|
-
displayName: 'InfinitePay',
|
|
52
|
-
authMode: 'credentials',
|
|
53
|
-
credentialSchema: [
|
|
54
|
-
{ key: 'handle', label: 'InfiniteTag ($usuario)', secret: false, required: true },
|
|
55
|
-
],
|
|
56
|
-
},
|
|
57
|
-
],
|
|
58
|
-
configs: [
|
|
59
|
-
{
|
|
60
|
-
provider: 'infinitepay',
|
|
61
|
-
status: 'VERIFIED',
|
|
62
|
-
enabled: false,
|
|
63
|
-
chargeVerifiedAt,
|
|
64
|
-
environment: 'SANDBOX',
|
|
65
|
-
environments: { SANDBOX: { handle: { configured: true, hint: '$loja' } } },
|
|
66
|
-
},
|
|
67
|
-
],
|
|
68
|
-
activeProvider: null,
|
|
69
|
-
} as unknown as MerchantSettingsView;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function fakeClient(view: MerchantSettingsView): PaymentsSettingsClient {
|
|
73
|
-
return {
|
|
74
|
-
baseUrl: '/api/admin/acme/payments',
|
|
75
|
-
getSettings: vi.fn().mockResolvedValue(view),
|
|
76
|
-
getSetupGuide: vi.fn().mockResolvedValue(GUIDE),
|
|
77
|
-
setEnabled: vi.fn(),
|
|
78
|
-
saveCredentials: vi.fn(),
|
|
79
|
-
} as unknown as PaymentsSettingsClient;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** The host's step 3, reduced to the two facts this file is about. */
|
|
83
|
-
function renderSettings(chargeVerifiedAt: string | null = null) {
|
|
84
|
-
const seen: Array<{ blocked: boolean; hidden: boolean }> = [];
|
|
85
|
-
render(
|
|
86
|
-
<PaymentProviderSettings
|
|
87
|
-
client={fakeClient(viewWith(chargeVerifiedAt))}
|
|
88
|
-
initialProvider="infinitepay"
|
|
89
|
-
renderVerification={({ blocked, hidden }) => {
|
|
90
|
-
seen.push({ blocked, hidden });
|
|
91
|
-
return (
|
|
92
|
-
<div data-testid="step-three" data-hidden={hidden}>
|
|
93
|
-
{blocked ? 'blocked' : 'ready'}
|
|
94
|
-
</div>
|
|
95
|
-
);
|
|
96
|
-
}}
|
|
97
|
-
/>,
|
|
98
|
-
);
|
|
99
|
-
return seen;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* How long the FIRST assertion of a test may take.
|
|
104
|
-
*
|
|
105
|
-
* It is not asserting a render — it is absorbing the screen's whole async
|
|
106
|
-
* boot: the settings fetch, then the setup-guide fetch keyed on what that
|
|
107
|
-
* returned, then the effects both settle. Every later assertion in the same
|
|
108
|
-
* test is a warm re-render and keeps the default.
|
|
109
|
-
*
|
|
110
|
-
* The default 1000ms was enough on a quiet machine and not on CI, where this
|
|
111
|
-
* package's tests run beside twenty others' — the lane went red on the landing
|
|
112
|
-
* line with the correct DOM already in the failure dump, which is the exact
|
|
113
|
-
* signature of a budget that is too tight rather than a screen that is wrong.
|
|
114
|
-
*/
|
|
115
|
-
const LANDED = { timeout: 10_000 };
|
|
116
|
-
|
|
117
|
-
beforeEach(() => window.localStorage.clear());
|
|
118
|
-
afterEach(cleanup);
|
|
119
|
-
|
|
120
|
-
describe('the step only the owner can report', () => {
|
|
121
|
-
/**
|
|
122
|
-
* The step is told it is not the current one; it is not UNMOUNTED.
|
|
123
|
-
*
|
|
124
|
-
* The difference is load-bearing and was learned the hard way. A provider
|
|
125
|
-
* refusing to mint a link is the evidence that withdraws the owner's
|
|
126
|
-
* "Checkout Integrado is on" — so the guide goes back a step in the same
|
|
127
|
-
* render that produced the explanation, and unmounting the panel took the
|
|
128
|
-
* explanation off screen with it. The owner was returned to a step they
|
|
129
|
-
* believed they had finished, with nothing saying why.
|
|
130
|
-
*
|
|
131
|
-
* So the host is handed `hidden` and decides. With nothing settled it draws
|
|
132
|
-
* nothing (the pay button must not appear); with a refusal in hand it keeps
|
|
133
|
-
* saying so.
|
|
134
|
-
*/
|
|
135
|
-
it('tells the activation step it is not the current one until Step 2 is confirmed', async () => {
|
|
136
|
-
renderSettings();
|
|
137
|
-
|
|
138
|
-
await screen.findByTestId('payments-setup-section-enable', undefined, LANDED);
|
|
139
|
-
const step = await screen.findByTestId('step-three', undefined, LANDED);
|
|
140
|
-
await waitFor(() => expect(step.dataset['hidden']).toBe('true'));
|
|
141
|
-
// And blocked with it: not the current step AND no pay button, which are
|
|
142
|
-
// two different withholdings that happen to coincide here.
|
|
143
|
-
expect(step.textContent).toBe('blocked');
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it('releases the charge once the owner confirms, and remembers it', async () => {
|
|
147
|
-
renderSettings();
|
|
148
|
-
|
|
149
|
-
await screen.findByTestId('payments-setup-section-enable', undefined, LANDED);
|
|
150
|
-
fireEvent.click(screen.getByRole('button', { name: /Já habilitei o Checkout Integrado/i }));
|
|
151
|
-
|
|
152
|
-
await waitFor(() => expect(screen.getByTestId('step-three').textContent).toBe('ready'));
|
|
153
|
-
expect(screen.getByTestId('step-three').dataset['hidden']).toBe('false');
|
|
154
|
-
// Collapsed to a row rather than removed: it is the claim step 3 is about
|
|
155
|
-
// to test, and the owner needs somewhere to press Revisar when it fails.
|
|
156
|
-
expect(screen.getByTestId('payments-setup-confirmed')).toBeTruthy();
|
|
157
|
-
|
|
158
|
-
cleanup();
|
|
159
|
-
// The flow deliberately LEAVES the page — the owner pays on the provider's
|
|
160
|
-
// site and comes back. An answer that did not survive that round trip would
|
|
161
|
-
// bounce them to step 2, hiding the panel polling the payment they just
|
|
162
|
-
// made.
|
|
163
|
-
renderSettings();
|
|
164
|
-
await waitFor(() => expect(screen.getByTestId('step-three').textContent).toBe('ready'), LANDED);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* A charge that landed proves the setting is on far better than the owner's
|
|
169
|
-
* word could, so a store reconnecting from a machine with no stored answer is
|
|
170
|
-
* never sent back through step 2.
|
|
171
|
-
*/
|
|
172
|
-
it('never asks a store that has already been paid through', async () => {
|
|
173
|
-
renderSettings('2026-07-30T12:00:00.000Z');
|
|
174
|
-
|
|
175
|
-
await waitFor(() => expect(screen.getByTestId('step-three').textContent).toBe('ready'), LANDED);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { cleanup, render, screen } from '@testing-library/react';
|
|
3
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
-
|
|
5
|
-
import type { MaskedProviderConfig, MerchantSettingsView } from '@12-apps/payments-backend';
|
|
6
|
-
|
|
7
|
-
import type { PaymentsSettingsClient } from '../client';
|
|
8
|
-
import { connectionBadge, isConnected } from '../components/connection-state';
|
|
9
|
-
import { ProviderList } from '../components/ProviderList';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* What "connected" means, in exactly one place.
|
|
13
|
-
*
|
|
14
|
-
* The screen shipped with two answers. Disconnecting does not delete the
|
|
15
|
-
* provider row — it empties every environment's credentials and resets the
|
|
16
|
-
* status — so the list, which only asked whether a row existed, kept reporting
|
|
17
|
-
* `Conectado` for a store that had just disconnected, while the panel one click
|
|
18
|
-
* away correctly offered `Conectar com PagBank`.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
function config(over: Partial<MaskedProviderConfig>): MaskedProviderConfig {
|
|
22
|
-
return { provider: 'pagbank', status: 'UNVERIFIED', enabled: false, ...over } as MaskedProviderConfig;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe('isConnected', () => {
|
|
26
|
-
/** The reported bug: the row survives a disconnect, emptied. */
|
|
27
|
-
it('is false for the emptied row a disconnect leaves behind', () => {
|
|
28
|
-
expect(isConnected(config({ status: 'UNVERIFIED', enabled: false }))).toBe(false);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('is false when no row exists at all', () => {
|
|
32
|
-
expect(isConnected(null)).toBe(false);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('is true once the provider is verified', () => {
|
|
36
|
-
expect(isConnected(config({ status: 'VERIFIED' }))).toBe(true);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
/** That store worked and still holds credentials; the grant is what lapsed. */
|
|
40
|
-
it('is true when a working connection needs reauthorizing', () => {
|
|
41
|
-
expect(isConnected(config({ status: 'RECONNECT_REQUIRED', enabled: true }))).toBe(true);
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
describe('connectionBadge', () => {
|
|
46
|
-
it('names a disconnected store as not connected', () => {
|
|
47
|
-
expect(connectionBadge(config({ status: 'UNVERIFIED' })).label).toBe('Não conectado');
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('leads with Ativo, the only state where money can move', () => {
|
|
51
|
-
expect(connectionBadge(config({ status: 'VERIFIED', enabled: true })).label).toBe('Ativo');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* A lapsed grant is its own state. Folding it into `Conectado` hid the one
|
|
56
|
-
* card an owner needs to open.
|
|
57
|
-
*/
|
|
58
|
-
it('calls out a lapsed authorization instead of calling it connected', () => {
|
|
59
|
-
expect(connectionBadge(config({ status: 'RECONNECT_REQUIRED' })).label).toBe('Reconectar');
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
const VIEW = (configs: MaskedProviderConfig[]): MerchantSettingsView =>
|
|
64
|
-
({
|
|
65
|
-
providers: [{ name: 'pagbank', displayName: 'PagBank', authMode: 'oauth', credentialSchema: [] }],
|
|
66
|
-
configs,
|
|
67
|
-
activeProvider: null,
|
|
68
|
-
}) as unknown as MerchantSettingsView;
|
|
69
|
-
|
|
70
|
-
afterEach(cleanup);
|
|
71
|
-
|
|
72
|
-
describe('ProviderList — the card badge', () => {
|
|
73
|
-
it('reports a disconnected provider as not connected', () => {
|
|
74
|
-
render(
|
|
75
|
-
<ProviderList
|
|
76
|
-
view={VIEW([config({ status: 'UNVERIFIED', enabled: false })])}
|
|
77
|
-
client={{} as unknown as PaymentsSettingsClient}
|
|
78
|
-
reload={vi.fn()}
|
|
79
|
-
onSelect={vi.fn()}
|
|
80
|
-
/>,
|
|
81
|
-
);
|
|
82
|
-
expect(screen.getByTestId('payments-provider-badge-pagbank').textContent).toBe('Não conectado');
|
|
83
|
-
});
|
|
84
|
-
});
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
// fireEvent/render from @testing-library/react (act()-wrapped) — same choice
|
|
3
|
-
// and reasoning as @12-apps/entitlements' react tests: user-event is not a
|
|
4
|
-
// dependency of this package.
|
|
5
|
-
import { render, screen, waitFor } from '@testing-library/react';
|
|
6
|
-
import { useEffect } from 'react';
|
|
7
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
8
|
-
|
|
9
|
-
import type { ClientChargeView } from '@12-apps/payments-backend';
|
|
10
|
-
import type { PaymentsClient } from '../client';
|
|
11
|
-
import { PaymentsProvider, useChargeStatus, useCreateCharge } from '../context';
|
|
12
|
-
|
|
13
|
-
function chargeView(status: ClientChargeView['status']): ClientChargeView {
|
|
14
|
-
return {
|
|
15
|
-
provider: 'stone',
|
|
16
|
-
providerChargeId: 'chg-1',
|
|
17
|
-
status,
|
|
18
|
-
amount: { amountCents: 12_50, currency: 'BRL' },
|
|
19
|
-
method: 'PIX',
|
|
20
|
-
pix: { qrText: 'qr-payload' },
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function fakeClient(overrides: Partial<PaymentsClient> = {}): PaymentsClient {
|
|
25
|
-
return {
|
|
26
|
-
getConfig: vi.fn().mockResolvedValue(null),
|
|
27
|
-
createCharge: vi.fn().mockResolvedValue(chargeView('PENDING')),
|
|
28
|
-
getCharge: vi.fn().mockResolvedValue(chargeView('PAID')),
|
|
29
|
-
...overrides,
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function CreateProbe() {
|
|
34
|
-
const { charge, create } = useCreateCharge();
|
|
35
|
-
useEffect(() => {
|
|
36
|
-
void create({
|
|
37
|
-
method: 'PIX',
|
|
38
|
-
customer: { name: 'Ana', email: 'ana@example.com' },
|
|
39
|
-
orderRef: 'order-1',
|
|
40
|
-
});
|
|
41
|
-
}, [create]);
|
|
42
|
-
if (!charge) return <span>creating</span>;
|
|
43
|
-
return <span>status:{charge.status}</span>;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function StatusProbe() {
|
|
47
|
-
const { charge, settled } = useChargeStatus({ provider: 'stone', providerChargeId: 'chg-1' }, { intervalMs: 5 });
|
|
48
|
-
return (
|
|
49
|
-
<span>
|
|
50
|
-
poll:{charge?.status ?? 'none'}:{settled ? 'settled' : 'waiting'}
|
|
51
|
-
</span>
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
describe('payments react bindings', () => {
|
|
56
|
-
it('useCreateCharge creates via the client and exposes the charge', async () => {
|
|
57
|
-
const client = fakeClient();
|
|
58
|
-
render(
|
|
59
|
-
<PaymentsProvider client={client}>
|
|
60
|
-
<CreateProbe />
|
|
61
|
-
</PaymentsProvider>,
|
|
62
|
-
);
|
|
63
|
-
await waitFor(() => {
|
|
64
|
-
expect(screen.getByText('status:PENDING')).toBeDefined();
|
|
65
|
-
});
|
|
66
|
-
expect(client.createCharge).toHaveBeenCalledTimes(1);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('useChargeStatus polls until the charge settles', async () => {
|
|
70
|
-
const client = fakeClient();
|
|
71
|
-
render(
|
|
72
|
-
<PaymentsProvider client={client}>
|
|
73
|
-
<StatusProbe />
|
|
74
|
-
</PaymentsProvider>,
|
|
75
|
-
);
|
|
76
|
-
await waitFor(() => {
|
|
77
|
-
expect(screen.getByText('poll:PAID:settled')).toBeDefined();
|
|
78
|
-
});
|
|
79
|
-
expect(client.getCharge).toHaveBeenCalledWith('stone', 'chg-1');
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('hooks throw outside a <PaymentsProvider>', () => {
|
|
83
|
-
// Silence React's error boundary logging for the expected throw.
|
|
84
|
-
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
85
|
-
expect(() => render(<StatusProbe />)).toThrow(/PaymentsProvider/);
|
|
86
|
-
spy.mockRestore();
|
|
87
|
-
});
|
|
88
|
-
});
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
-
|
|
5
|
-
import type { MerchantSettingsView } from '@12-apps/payments-backend';
|
|
6
|
-
|
|
7
|
-
import type { PaymentsSettingsClient } from '../client';
|
|
8
|
-
import { PaymentProviderSettings } from '../components/PaymentProviderSettings';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Controlled selection — the mode a host uses when the open provider lives in
|
|
12
|
-
* the URL, so each one is its own page.
|
|
13
|
-
*
|
|
14
|
-
* The failure this guards is the component quietly keeping its own copy of the
|
|
15
|
-
* selection: the screen would still LOOK right on a click (internal state moved
|
|
16
|
-
* too), and only a reload, a shared link or the browser's back button would
|
|
17
|
-
* show the two had drifted. So these assert on the direction of travel — the
|
|
18
|
-
* host's value wins, and the component only ever ASKS to change it.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
const VIEW: MerchantSettingsView = {
|
|
22
|
-
providers: [
|
|
23
|
-
{ name: 'pagbank', displayName: 'PagBank', authMode: 'oauth', credentialSchema: [] },
|
|
24
|
-
{ name: 'stripe', displayName: 'Stripe', authMode: 'credentials', credentialSchema: [] },
|
|
25
|
-
],
|
|
26
|
-
configs: [],
|
|
27
|
-
activeProvider: null,
|
|
28
|
-
} as unknown as MerchantSettingsView;
|
|
29
|
-
|
|
30
|
-
function fakeClient(): PaymentsSettingsClient {
|
|
31
|
-
return {
|
|
32
|
-
getSettings: vi.fn().mockResolvedValue(VIEW),
|
|
33
|
-
getSetupGuide: vi.fn().mockResolvedValue(null),
|
|
34
|
-
setEnabled: vi.fn(),
|
|
35
|
-
saveCredentials: vi.fn(),
|
|
36
|
-
} as unknown as PaymentsSettingsClient;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
describe('PaymentProviderSettings — host-controlled selection', () => {
|
|
40
|
-
it('opens the provider the host names, on the first render', async () => {
|
|
41
|
-
render(<PaymentProviderSettings client={fakeClient()} selectedProvider="pagbank" />);
|
|
42
|
-
|
|
43
|
-
// No handover tick to wait for: a controlled host has the answer at mount.
|
|
44
|
-
expect(await screen.findByTestId('payments-provider-back')).toBeDefined();
|
|
45
|
-
await waitFor(() => expect(screen.queryByTestId('payments-provider-picker')).toBeNull());
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* `null` is an answer, not a missing prop. Read as "unset" it would fall back
|
|
50
|
-
* to internal state and strand the owner in the provider they last opened.
|
|
51
|
-
*/
|
|
52
|
-
it('shows the list when the host says null', async () => {
|
|
53
|
-
render(<PaymentProviderSettings client={fakeClient()} selectedProvider={null} />);
|
|
54
|
-
expect(await screen.findByTestId('payments-provider-picker')).toBeDefined();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* The core of controlled mode: a click must not move the view on its own.
|
|
59
|
-
* If it does, the URL and the screen disagree the moment either is reloaded.
|
|
60
|
-
*/
|
|
61
|
-
it('does not change the open provider by itself — it asks', async () => {
|
|
62
|
-
const onProviderChange = vi.fn();
|
|
63
|
-
render(
|
|
64
|
-
<PaymentProviderSettings
|
|
65
|
-
client={fakeClient()}
|
|
66
|
-
selectedProvider="pagbank"
|
|
67
|
-
onProviderChange={onProviderChange}
|
|
68
|
-
/>,
|
|
69
|
-
);
|
|
70
|
-
const back = await screen.findByTestId('payments-provider-back');
|
|
71
|
-
|
|
72
|
-
back.click();
|
|
73
|
-
|
|
74
|
-
await waitFor(() => expect(onProviderChange).toHaveBeenCalledWith(null));
|
|
75
|
-
// Still on pagbank: the host has not said otherwise yet.
|
|
76
|
-
expect(screen.getByTestId('payments-provider-back')).toBeDefined();
|
|
77
|
-
await waitFor(() => expect(screen.queryByTestId('payments-provider-picker')).toBeNull());
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
/** The host's later value is honoured every time — not once, as with initialProvider. */
|
|
81
|
-
it('follows the host on each change, in both directions', async () => {
|
|
82
|
-
const client = fakeClient();
|
|
83
|
-
const { rerender } = render(
|
|
84
|
-
<PaymentProviderSettings client={client} selectedProvider="pagbank" />,
|
|
85
|
-
);
|
|
86
|
-
await screen.findByTestId('payments-provider-back');
|
|
87
|
-
|
|
88
|
-
rerender(<PaymentProviderSettings client={client} selectedProvider={null} />);
|
|
89
|
-
expect(await screen.findByTestId('payments-provider-picker')).toBeDefined();
|
|
90
|
-
|
|
91
|
-
rerender(<PaymentProviderSettings client={client} selectedProvider="stripe" />);
|
|
92
|
-
await waitFor(() => expect(screen.queryByTestId('payments-provider-picker')).toBeNull());
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Uncontrolled hosts predate this prop and must be untouched: selection stays
|
|
97
|
-
* internal, and `onProviderChange` is only an observer.
|
|
98
|
-
*/
|
|
99
|
-
it('still keeps its own selection when the host does not control it', async () => {
|
|
100
|
-
const onProviderChange = vi.fn();
|
|
101
|
-
render(
|
|
102
|
-
<PaymentProviderSettings
|
|
103
|
-
client={fakeClient()}
|
|
104
|
-
initialProvider="pagbank"
|
|
105
|
-
onProviderChange={onProviderChange}
|
|
106
|
-
/>,
|
|
107
|
-
);
|
|
108
|
-
const back = await screen.findByTestId('payments-provider-back');
|
|
109
|
-
|
|
110
|
-
back.click();
|
|
111
|
-
|
|
112
|
-
const picker = await screen.findByTestId('payments-provider-picker');
|
|
113
|
-
expect(picker).toBeDefined();
|
|
114
|
-
await waitFor(() => expect(onProviderChange).toHaveBeenCalledWith(null));
|
|
115
|
-
});
|
|
116
|
-
});
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
-
|
|
6
|
-
import type { MaskedProviderConfig, ProviderDescriptor } from '@12-apps/payments-backend';
|
|
7
|
-
|
|
8
|
-
import type { PaymentsSettingsClient } from '../client';
|
|
9
|
-
import { ProviderForm } from '../components/ProviderCredentialForm';
|
|
10
|
-
import { statusBadge } from '../components/ProviderStatusBar';
|
|
11
|
-
|
|
12
|
-
// jest-dom is not a dependency here — assert DOM properties directly.
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The last look at the value that decides who gets paid, and the words the
|
|
16
|
-
* screen uses for how far a store has actually got.
|
|
17
|
-
*
|
|
18
|
-
* Both exist because of the same mistake in two forms: a screen saying more
|
|
19
|
-
* than it can back. `VERIFIED` was a claim about credentials authenticating,
|
|
20
|
-
* read by owners as "my store can take money"; and Salvar wrote an InfiniteTag
|
|
21
|
-
* — one unchecksummed string that routes every payment the store will ever
|
|
22
|
-
* receive — with no more ceremony than a display name.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
const DESCRIPTOR = {
|
|
26
|
-
name: 'infinitepay',
|
|
27
|
-
displayName: 'InfinitePay',
|
|
28
|
-
authMode: 'credentials',
|
|
29
|
-
credentialSchema: [
|
|
30
|
-
{
|
|
31
|
-
key: 'handle',
|
|
32
|
-
label: 'InfiniteTag ($usuario)',
|
|
33
|
-
secret: false,
|
|
34
|
-
required: true,
|
|
35
|
-
mono: true,
|
|
36
|
-
confirmOnSave: true,
|
|
37
|
-
},
|
|
38
|
-
],
|
|
39
|
-
} as unknown as ProviderDescriptor;
|
|
40
|
-
|
|
41
|
-
function configWith(hint: string | null, status = 'UNVERIFIED'): MaskedProviderConfig {
|
|
42
|
-
return {
|
|
43
|
-
provider: 'infinitepay',
|
|
44
|
-
status,
|
|
45
|
-
enabled: false,
|
|
46
|
-
chargeVerifiedAt: null,
|
|
47
|
-
environment: 'SANDBOX',
|
|
48
|
-
environments: {
|
|
49
|
-
SANDBOX: hint ? { handle: { configured: true, hint } } : {},
|
|
50
|
-
PRODUCTION: {},
|
|
51
|
-
},
|
|
52
|
-
} as unknown as MaskedProviderConfig;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function fakeClient(): PaymentsSettingsClient {
|
|
56
|
-
return {
|
|
57
|
-
baseUrl: '/api/admin/acme/payments',
|
|
58
|
-
saveCredentials: vi.fn().mockResolvedValue(configWith('$loja')),
|
|
59
|
-
verify: vi.fn(),
|
|
60
|
-
} as unknown as PaymentsSettingsClient;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* `editing` is owned by the PANEL in the real screen — reopening a finished
|
|
65
|
-
* step also has to take step 3 off the page — so the harness plays that part.
|
|
66
|
-
*/
|
|
67
|
-
function Harness({
|
|
68
|
-
config,
|
|
69
|
-
client,
|
|
70
|
-
}: {
|
|
71
|
-
config: MaskedProviderConfig | null;
|
|
72
|
-
client: PaymentsSettingsClient;
|
|
73
|
-
}) {
|
|
74
|
-
const [editing, setEditing] = useState(false);
|
|
75
|
-
return (
|
|
76
|
-
<ProviderForm
|
|
77
|
-
descriptor={DESCRIPTOR}
|
|
78
|
-
config={config}
|
|
79
|
-
client={client}
|
|
80
|
-
environment="SANDBOX"
|
|
81
|
-
editing={editing}
|
|
82
|
-
onEditingChange={setEditing}
|
|
83
|
-
onSaved={() => undefined}
|
|
84
|
-
/>
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function renderForm(config: MaskedProviderConfig | null, client = fakeClient()) {
|
|
89
|
-
render(<Harness config={config} client={client} />);
|
|
90
|
-
return client;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
afterEach(cleanup);
|
|
94
|
-
|
|
95
|
-
describe('saving a credential that decides where the money goes', () => {
|
|
96
|
-
it('quotes the new value back before writing it', async () => {
|
|
97
|
-
const client = renderForm(configWith(null));
|
|
98
|
-
|
|
99
|
-
fireEvent.change(screen.getByLabelText(/^InfiniteTag/), { target: { value: '$loja-nova' } });
|
|
100
|
-
fireEvent.click(screen.getByTestId('payments-save'));
|
|
101
|
-
|
|
102
|
-
// Nothing is written while the question is open — the point is the reading,
|
|
103
|
-
// and a dialog that appears after the write would be decoration.
|
|
104
|
-
expect(screen.getByTestId('payments-confirm-credential-value').textContent).toBe('$loja-nova');
|
|
105
|
-
expect(client.saveCredentials).not.toHaveBeenCalled();
|
|
106
|
-
|
|
107
|
-
fireEvent.click(screen.getByTestId('payments-confirm-credential-confirm'));
|
|
108
|
-
await waitFor(() => expect(client.saveCredentials).toHaveBeenCalled());
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('lets the owner back out without writing anything', async () => {
|
|
112
|
-
const client = renderForm(configWith(null));
|
|
113
|
-
|
|
114
|
-
fireEvent.change(screen.getByLabelText(/^InfiniteTag/), { target: { value: '$errada' } });
|
|
115
|
-
fireEvent.click(screen.getByTestId('payments-save'));
|
|
116
|
-
fireEvent.click(screen.getByTestId('payments-confirm-credential-cancel'));
|
|
117
|
-
|
|
118
|
-
await waitFor(() =>
|
|
119
|
-
expect(screen.queryByTestId('payments-confirm-credential-value')).toBeNull(),
|
|
120
|
-
);
|
|
121
|
-
expect(client.saveCredentials).not.toHaveBeenCalled();
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* The exemption that keeps the dialog meaningful.
|
|
126
|
-
*
|
|
127
|
-
* Re-typing the value already stored cannot introduce a wrong one, so there
|
|
128
|
-
* is nothing to re-read. Asking anyway would train the owner to click through
|
|
129
|
-
* it, which is the one way to make a confirmation useless on the occasion it
|
|
130
|
-
* matters.
|
|
131
|
-
*/
|
|
132
|
-
it('does not ask when the value is not changing', async () => {
|
|
133
|
-
const client = renderForm(configWith('$loja'));
|
|
134
|
-
|
|
135
|
-
const field = screen.getByLabelText(/^InfiniteTag/);
|
|
136
|
-
fireEvent.change(field, { target: { value: '$loj' } });
|
|
137
|
-
fireEvent.change(field, { target: { value: '$loja' } });
|
|
138
|
-
fireEvent.click(screen.getByTestId('payments-save'));
|
|
139
|
-
|
|
140
|
-
await waitFor(() => expect(client.saveCredentials).toHaveBeenCalled());
|
|
141
|
-
await waitFor(() =>
|
|
142
|
-
expect(screen.queryByTestId('payments-confirm-credential-value')).toBeNull(),
|
|
143
|
-
);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* A finished step is a line, not a form. Leaving the field editable meant the
|
|
148
|
-
* value that routes the store's money sat one stray keystroke from changing,
|
|
149
|
-
* in front of an owner who had come back to check something else.
|
|
150
|
-
*/
|
|
151
|
-
it('folds a proven-connection credential into a summary row', async () => {
|
|
152
|
-
renderForm(configWith('$loja', 'VERIFIED'));
|
|
153
|
-
|
|
154
|
-
const summary = await screen.findByTestId('payments-credential-summary');
|
|
155
|
-
expect(summary.textContent).toContain('$loja');
|
|
156
|
-
await waitFor(() => expect(screen.queryByLabelText(/^InfiniteTag/)).toBeNull());
|
|
157
|
-
|
|
158
|
-
fireEvent.click(screen.getByTestId('payments-credential-summary-edit'));
|
|
159
|
-
expect(await screen.findByLabelText(/^InfiniteTag/)).toBeTruthy();
|
|
160
|
-
});
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Three different facts used to share one word, and the green one was the
|
|
165
|
-
* least earned. `VERIFIED` comes from the credential probe: PagBank answers
|
|
166
|
-
* yes to it while refusing every charge until homologação, and InfinitePay
|
|
167
|
-
* answers yes with Checkout Integrado switched off, which mints no links at
|
|
168
|
-
* all. Only `chargeVerifiedAt` is a payment that actually landed.
|
|
169
|
-
*/
|
|
170
|
-
describe('what the status chip is allowed to claim', () => {
|
|
171
|
-
it('separates reaching the account from being able to charge', () => {
|
|
172
|
-
expect(statusBadge(null).label).toBe('NÃO VERIFICADO');
|
|
173
|
-
expect(statusBadge(configWith('$loja')).label).toBe('NÃO VERIFICADO');
|
|
174
|
-
|
|
175
|
-
const probed = statusBadge(configWith('$loja', 'VERIFIED'));
|
|
176
|
-
expect(probed.label).toBe('CONEXÃO OK');
|
|
177
|
-
expect(probed.color).not.toBe('success');
|
|
178
|
-
|
|
179
|
-
const paid = {
|
|
180
|
-
...configWith('$loja', 'VERIFIED'),
|
|
181
|
-
chargeVerifiedAt: '2026-07-30T12:00:00.000Z',
|
|
182
|
-
} as MaskedProviderConfig;
|
|
183
|
-
expect(statusBadge(paid)).toEqual({ label: 'VERIFICADO', color: 'success' });
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it('says RECONECTAR rather than a bare failure when a grant lapsed', () => {
|
|
187
|
-
expect(statusBadge(configWith('$loja', 'RECONNECT_REQUIRED')).label).toBe('RECONECTAR');
|
|
188
|
-
// A failed probe is NOT a third state: the store cannot take money yet,
|
|
189
|
-
// which is what NÃO VERIFICADO already says. The probe's own sentence
|
|
190
|
-
// beneath the chip is what names the tag and the fix.
|
|
191
|
-
expect(statusBadge(configWith('$loja', 'FAILED')).label).toBe('NÃO VERIFICADO');
|
|
192
|
-
});
|
|
193
|
-
});
|