@12-apps/payments-frontend 1.0.0
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/eslint.config.js +34 -0
- package/package.json +63 -0
- package/src/__tests__/checkout-confirmation.test.tsx +177 -0
- package/src/__tests__/connection-state.test.tsx +84 -0
- package/src/__tests__/context.test.tsx +88 -0
- package/src/__tests__/controlled-provider.test.tsx +116 -0
- package/src/__tests__/credential-confirm.test.tsx +193 -0
- package/src/__tests__/initial-provider.test.tsx +81 -0
- package/src/__tests__/provider-priority-list.test.tsx +159 -0
- package/src/__tests__/provider-status-bar.test.tsx +152 -0
- package/src/__tests__/verification-slot.test.tsx +125 -0
- package/src/client.ts +200 -0
- package/src/components/CheckoutFlow.tsx +169 -0
- package/src/components/CheckoutPayment.tsx +379 -0
- package/src/components/ConfirmCredentialSave.tsx +106 -0
- package/src/components/CredentialFields.tsx +158 -0
- package/src/components/CredentialFormAlerts.tsx +144 -0
- package/src/components/EnvironmentTabs.tsx +109 -0
- package/src/components/PaymentProviderSettings.tsx +267 -0
- package/src/components/ProviderConnection.tsx +251 -0
- package/src/components/ProviderCredentialForm.tsx +387 -0
- package/src/components/ProviderList.tsx +126 -0
- package/src/components/ProviderPanel.tsx +300 -0
- package/src/components/ProviderPriorityList.tsx +293 -0
- package/src/components/ProviderSetupGuide.tsx +263 -0
- package/src/components/ProviderStatusBar.tsx +192 -0
- package/src/components/SetupGuideSection.tsx +190 -0
- package/src/components/checkout-ack.ts +106 -0
- package/src/components/connection-state.ts +66 -0
- package/src/components/credential-rules.ts +120 -0
- package/src/components/rich-text.tsx +27 -0
- package/src/components/settings-state.ts +211 -0
- package/src/context.tsx +144 -0
- package/src/index.ts +69 -0
|
@@ -0,0 +1,193 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
* Landing after an OAuth connect.
|
|
12
|
+
*
|
|
13
|
+
* This shipped broken TWICE, and the second time only because the test was
|
|
14
|
+
* shaped around the code instead of the failure. The host reads the connected
|
|
15
|
+
* provider from the callback's query string in an EFFECT, so it is null on
|
|
16
|
+
* first render and arrives a tick later — and `useState(initialProvider)` keeps
|
|
17
|
+
* the null forever. A test that renders with the prop already set passes
|
|
18
|
+
* happily and proves nothing; the prop has to ARRIVE.
|
|
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 — landing after a connect', () => {
|
|
40
|
+
it('lands on the provider list when nothing was just connected', async () => {
|
|
41
|
+
render(<PaymentProviderSettings client={fakeClient()} />);
|
|
42
|
+
expect(await screen.findByTestId('payments-provider-picker')).toBeDefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The regression: the prop is null at mount and supplied afterwards, exactly
|
|
47
|
+
* as the host does it. Seeding `useState` with it passes the OTHER test and
|
|
48
|
+
* fails this one.
|
|
49
|
+
*/
|
|
50
|
+
it('opens the provider when the host supplies it AFTER mount', async () => {
|
|
51
|
+
const client = fakeClient();
|
|
52
|
+
const { rerender } = render(<PaymentProviderSettings client={client} initialProvider={null} />);
|
|
53
|
+
await screen.findByTestId('payments-provider-picker');
|
|
54
|
+
|
|
55
|
+
rerender(<PaymentProviderSettings client={client} initialProvider="pagbank" />);
|
|
56
|
+
|
|
57
|
+
// The picker is replaced by that provider's own panel.
|
|
58
|
+
await waitFor(() => expect(screen.queryByTestId('payments-provider-picker')).toBeNull());
|
|
59
|
+
expect(screen.getByTestId('payments-provider-back')).toBeDefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Applied once, not on every render: the host holds the outcome for the
|
|
64
|
+
* page's lifetime, so re-applying would make "Voltar aos provedores" bounce
|
|
65
|
+
* straight back into the provider and trap the owner on one screen.
|
|
66
|
+
*/
|
|
67
|
+
it('does not re-open the provider after the owner goes back', async () => {
|
|
68
|
+
const client = fakeClient();
|
|
69
|
+
const { rerender } = render(<PaymentProviderSettings client={client} initialProvider={null} />);
|
|
70
|
+
await screen.findByTestId('payments-provider-picker');
|
|
71
|
+
|
|
72
|
+
rerender(<PaymentProviderSettings client={client} initialProvider="pagbank" />);
|
|
73
|
+
const back = await screen.findByTestId('payments-provider-back');
|
|
74
|
+
|
|
75
|
+
back.click();
|
|
76
|
+
|
|
77
|
+
// A rerender with the SAME still-set prop must not drag them back in.
|
|
78
|
+
rerender(<PaymentProviderSettings client={client} initialProvider="pagbank" />);
|
|
79
|
+
expect(await screen.findByTestId('payments-provider-picker')).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// fireEvent/render from @testing-library/react (act()-wrapped) — same choice
|
|
3
|
+
// and reasoning as the context tests: user-event is not a dependency here.
|
|
4
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import type { MaskedProviderConfig, MerchantSettingsView } from '@12-apps/payments-backend';
|
|
8
|
+
|
|
9
|
+
import type { PaymentsSettingsClient } from '../client';
|
|
10
|
+
import { ProviderPriorityList, chainOf } from '../components/ProviderPriorityList';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The failover-chain editor. What matters here is not the drag mechanics but
|
|
14
|
+
* that the order the merchant sees is the order checkout will use, and that a
|
|
15
|
+
* failed save never leaves the screen claiming an order the server rejected.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function config(provider: string, enabled: boolean, priority: number): MaskedProviderConfig {
|
|
19
|
+
return {
|
|
20
|
+
provider,
|
|
21
|
+
enabled,
|
|
22
|
+
priority,
|
|
23
|
+
environment: 'SANDBOX',
|
|
24
|
+
status: 'VERIFIED',
|
|
25
|
+
lastVerifiedAt: null,
|
|
26
|
+
// Every row in the chain editor is by definition already proven — only a
|
|
27
|
+
// successful charge can put a provider in the chain at all.
|
|
28
|
+
chargeVerifiedAt: '2026-07-30T12:00:00.000Z',
|
|
29
|
+
expiresAt: null,
|
|
30
|
+
stub: false,
|
|
31
|
+
environments: { SANDBOX: {}, PRODUCTION: {} },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function viewOf(configs: MaskedProviderConfig[]): MerchantSettingsView {
|
|
36
|
+
const chain = configs.filter((c) => c.enabled).map((c) => c.provider);
|
|
37
|
+
return {
|
|
38
|
+
providers: [
|
|
39
|
+
{ name: 'stone', displayName: 'Stone', authMode: 'credentials', capabilities: CAPS, credentialSchema: [] },
|
|
40
|
+
{ name: 'stripe', displayName: 'Stripe', authMode: 'oauth', capabilities: CAPS, credentialSchema: [] },
|
|
41
|
+
],
|
|
42
|
+
configs,
|
|
43
|
+
providerChain: chain,
|
|
44
|
+
activeProvider: chain[0] ?? null,
|
|
45
|
+
failoverPolicy: 'TECHNICAL',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const CAPS = {
|
|
50
|
+
methods: ['PIX'] as const,
|
|
51
|
+
savedCards: false,
|
|
52
|
+
refunds: false,
|
|
53
|
+
partialRefunds: false,
|
|
54
|
+
splits: false,
|
|
55
|
+
webhooks: true,
|
|
56
|
+
tokenization: 'NONE' as const,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function fakeClient(overrides: Partial<PaymentsSettingsClient> = {}): PaymentsSettingsClient {
|
|
60
|
+
return {
|
|
61
|
+
getSettings: vi.fn(),
|
|
62
|
+
saveCredentials: vi.fn(),
|
|
63
|
+
setEnabled: vi.fn(),
|
|
64
|
+
setPriorities: vi.fn(),
|
|
65
|
+
setFailoverPolicy: vi.fn(),
|
|
66
|
+
verify: vi.fn(),
|
|
67
|
+
getSetupGuide: vi.fn(),
|
|
68
|
+
beginOAuth: vi.fn(),
|
|
69
|
+
disconnectOAuth: vi.fn(),
|
|
70
|
+
...overrides,
|
|
71
|
+
} as PaymentsSettingsClient;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The rendered chain order, read off the list rows. */
|
|
75
|
+
function rowOrder(): string[] {
|
|
76
|
+
return Array.from(document.querySelectorAll('[data-testid^="payments-priority-item-"]')).map(
|
|
77
|
+
(el) => el.getAttribute('data-testid')!.replace('payments-priority-item-', ''),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('chainOf', () => {
|
|
82
|
+
it('derives the chain from configs, ordered by rank', () => {
|
|
83
|
+
const view = viewOf([config('stripe', true, 1), config('stone', true, 0)]);
|
|
84
|
+
expect(chainOf(view)).toEqual(['stone', 'stripe']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('ignores providers that are configured but out of rotation', () => {
|
|
88
|
+
const view = viewOf([config('stone', true, 0), config('stripe', false, 0)]);
|
|
89
|
+
expect(chainOf(view)).toEqual(['stone']);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('ProviderPriorityList', () => {
|
|
94
|
+
it('names the provider checkout will try first', () => {
|
|
95
|
+
render(
|
|
96
|
+
<ProviderPriorityList
|
|
97
|
+
view={viewOf([config('stone', true, 0), config('stripe', true, 1)])}
|
|
98
|
+
client={fakeClient()}
|
|
99
|
+
/>,
|
|
100
|
+
);
|
|
101
|
+
// The intro names it in prose AND the row is first in the list; assert on
|
|
102
|
+
// the list, which is the thing that actually encodes routing order.
|
|
103
|
+
expect(screen.getByTestId('payments-priority-first')).toBeTruthy();
|
|
104
|
+
expect(rowOrder()).toEqual(['stone', 'stripe']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('warns when nothing is in rotation, because checkout cannot charge', () => {
|
|
108
|
+
render(<ProviderPriorityList view={viewOf([config('stone', false, 0)])} client={fakeClient()} />);
|
|
109
|
+
expect(screen.getByTestId('payments-priority-empty')).toBeTruthy();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('saves the WHOLE reordered chain, not a per-provider delta', async () => {
|
|
113
|
+
const reordered = viewOf([config('stripe', true, 0), config('stone', true, 1)]);
|
|
114
|
+
const setPriorities = vi.fn().mockResolvedValue(reordered);
|
|
115
|
+
render(
|
|
116
|
+
<ProviderPriorityList
|
|
117
|
+
view={viewOf([config('stone', true, 0), config('stripe', true, 1)])}
|
|
118
|
+
client={fakeClient({ setPriorities })}
|
|
119
|
+
/>,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
fireEvent.click(screen.getByLabelText('Mover Stone para baixo'));
|
|
123
|
+
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
expect(setPriorities).toHaveBeenCalledWith(['stripe', 'stone']);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('rolls the list back when the save fails, and says why', async () => {
|
|
130
|
+
const setPriorities = vi.fn().mockRejectedValue(new Error('conflito de ordem'));
|
|
131
|
+
render(
|
|
132
|
+
<ProviderPriorityList
|
|
133
|
+
view={viewOf([config('stone', true, 0), config('stripe', true, 1)])}
|
|
134
|
+
client={fakeClient({ setPriorities })}
|
|
135
|
+
/>,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
fireEvent.click(screen.getByLabelText('Mover Stone para baixo'));
|
|
139
|
+
|
|
140
|
+
await waitFor(() => {
|
|
141
|
+
expect(screen.getByTestId('payments-priority-error').textContent).toContain(
|
|
142
|
+
'conflito de ordem',
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
// The screen must not keep showing an order the server refused.
|
|
146
|
+
expect(rowOrder()).toEqual(['stone', 'stripe']);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('cannot move the first provider up or the last one down', () => {
|
|
150
|
+
render(
|
|
151
|
+
<ProviderPriorityList
|
|
152
|
+
view={viewOf([config('stone', true, 0), config('stripe', true, 1)])}
|
|
153
|
+
client={fakeClient()}
|
|
154
|
+
/>,
|
|
155
|
+
);
|
|
156
|
+
expect(screen.getByLabelText('Mover Stone para cima').hasAttribute('disabled')).toBe(true);
|
|
157
|
+
expect(screen.getByLabelText('Mover Stripe para baixo').hasAttribute('disabled')).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
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, ProviderDescriptor } from '@12-apps/payments-backend';
|
|
6
|
+
|
|
7
|
+
import { ProviderStatusBar } from '../components/ProviderStatusBar';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* "Ativo" decides whether checkout routes real buyers to this provider, so it
|
|
11
|
+
* must not be offerable before there is anything to route to. The screen shipped
|
|
12
|
+
* showing `PagBank [UNVERIFIED] [on] Ativo` — a provider that had never proved
|
|
13
|
+
* it could charge, presented as live.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A provider that CAN run the activation charge, so the strict rule applies. */
|
|
17
|
+
const DESCRIPTOR = {
|
|
18
|
+
name: 'pagbank',
|
|
19
|
+
displayName: 'PagBank',
|
|
20
|
+
capabilities: { activationCharge: true },
|
|
21
|
+
} as unknown as ProviderDescriptor;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One that cannot: no browser tokenization is written for it, so there is no
|
|
25
|
+
* way to earn `chargeVerifiedAt`. Holding it to the charge would not make
|
|
26
|
+
* "Ativo" honest — it would make the provider permanently unactivatable.
|
|
27
|
+
*/
|
|
28
|
+
const NO_CHARGE_PATH = {
|
|
29
|
+
name: 'stripe',
|
|
30
|
+
displayName: 'Stripe',
|
|
31
|
+
capabilities: { activationCharge: false },
|
|
32
|
+
} as unknown as ProviderDescriptor;
|
|
33
|
+
|
|
34
|
+
function configWith(
|
|
35
|
+
status: string,
|
|
36
|
+
enabled = false,
|
|
37
|
+
chargeVerifiedAt: string | null = null,
|
|
38
|
+
): MaskedProviderConfig {
|
|
39
|
+
return {
|
|
40
|
+
provider: 'pagbank',
|
|
41
|
+
status,
|
|
42
|
+
enabled,
|
|
43
|
+
chargeVerifiedAt,
|
|
44
|
+
} as unknown as MaskedProviderConfig;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const PROVEN = '2026-07-30T12:00:00.000Z';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* jest-dom is not a dependency here, so assert the DOM property directly. The
|
|
51
|
+
* testid lands on MUI's Switch ROOT; the thing that carries `disabled` is the
|
|
52
|
+
* checkbox input inside it.
|
|
53
|
+
*/
|
|
54
|
+
function toggle(): HTMLInputElement {
|
|
55
|
+
const root = screen.getByTestId('payments-enabled-toggle');
|
|
56
|
+
const input = root.querySelector('input[type="checkbox"]');
|
|
57
|
+
if (!input) throw new Error('switch input not found');
|
|
58
|
+
return input as HTMLInputElement;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
afterEach(cleanup);
|
|
62
|
+
|
|
63
|
+
describe('ProviderStatusBar — the Ativo switch', () => {
|
|
64
|
+
it('cannot be switched on with no connection at all', () => {
|
|
65
|
+
render(<ProviderStatusBar descriptor={DESCRIPTOR} config={null} busy={false} onToggle={vi.fn()} />);
|
|
66
|
+
expect(toggle().disabled).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('cannot be switched on while the provider is UNVERIFIED', () => {
|
|
70
|
+
render(
|
|
71
|
+
<ProviderStatusBar
|
|
72
|
+
descriptor={DESCRIPTOR}
|
|
73
|
+
config={configWith('UNVERIFIED')}
|
|
74
|
+
busy={false}
|
|
75
|
+
onToggle={vi.fn()}
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
expect(toggle().disabled).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The one this exists for. `VERIFIED` comes from the credential probe, which
|
|
83
|
+
* only asks whether the keys authenticate — PagBank answers yes to that and
|
|
84
|
+
* still refuses every real charge until the integration is homologated. A
|
|
85
|
+
* store sat at `PagBank [VERIFIED] [on] Ativo` while declining every shopper.
|
|
86
|
+
*/
|
|
87
|
+
it('stays locked at VERIFIED — authenticating is not being able to charge', () => {
|
|
88
|
+
render(
|
|
89
|
+
<ProviderStatusBar
|
|
90
|
+
descriptor={DESCRIPTOR}
|
|
91
|
+
config={configWith('VERIFIED')}
|
|
92
|
+
busy={false}
|
|
93
|
+
onToggle={vi.fn()}
|
|
94
|
+
/>,
|
|
95
|
+
);
|
|
96
|
+
expect(toggle().disabled).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('unlocks once a real charge has succeeded', () => {
|
|
100
|
+
render(
|
|
101
|
+
<ProviderStatusBar
|
|
102
|
+
descriptor={DESCRIPTOR}
|
|
103
|
+
config={configWith('VERIFIED', false, PROVEN)}
|
|
104
|
+
busy={false}
|
|
105
|
+
onToggle={vi.fn()}
|
|
106
|
+
/>,
|
|
107
|
+
);
|
|
108
|
+
expect(toggle().disabled).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Turning OFF is never gated. A row enabled before this rule existed — or a
|
|
113
|
+
* store in RECONNECT_REQUIRED that demonstrably worked — must be pullable out
|
|
114
|
+
* of rotation at once, or the switch traps an owner with a broken provider
|
|
115
|
+
* live in the chain.
|
|
116
|
+
*/
|
|
117
|
+
it('lets an enabled-but-unproven provider be switched off', () => {
|
|
118
|
+
render(
|
|
119
|
+
<ProviderStatusBar
|
|
120
|
+
descriptor={DESCRIPTOR}
|
|
121
|
+
config={configWith('RECONNECT_REQUIRED', true)}
|
|
122
|
+
busy={false}
|
|
123
|
+
onToggle={vi.fn()}
|
|
124
|
+
/>,
|
|
125
|
+
);
|
|
126
|
+
expect(toggle().disabled).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('falls back to VERIFIED for a provider that cannot run the charge', () => {
|
|
130
|
+
render(
|
|
131
|
+
<ProviderStatusBar
|
|
132
|
+
descriptor={NO_CHARGE_PATH}
|
|
133
|
+
config={configWith('VERIFIED')}
|
|
134
|
+
busy={false}
|
|
135
|
+
onToggle={vi.fn()}
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
expect(toggle().disabled).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('is locked while another action is in flight', () => {
|
|
142
|
+
render(
|
|
143
|
+
<ProviderStatusBar
|
|
144
|
+
descriptor={DESCRIPTOR}
|
|
145
|
+
config={configWith('VERIFIED', false, PROVEN)}
|
|
146
|
+
busy
|
|
147
|
+
onToggle={vi.fn()}
|
|
148
|
+
/>,
|
|
149
|
+
);
|
|
150
|
+
expect(toggle().disabled).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
});
|