@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
package/eslint.config.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { config as baseConfig } from '@12-apps/eslint-config/base';
|
|
2
|
+
import testFlakiness from 'eslint-plugin-test-flakiness';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The everyday DX lint for this package.
|
|
6
|
+
*
|
|
7
|
+
* It registers `eslint-plugin-test-flakiness` with every rule left OFF. Those
|
|
8
|
+
* rules are enforced by the repo-root CI lane (`eslint.flakiness.config.mjs`),
|
|
9
|
+
* not here — but a test file that legitimately suppresses one carries an inline
|
|
10
|
+
* `// eslint-disable-next-line test-flakiness/...` directive, and ESLint reports
|
|
11
|
+
* "Definition for rule not found" for a directive naming a plugin it has never
|
|
12
|
+
* heard of. Registering the plugin lets those directives RESOLVE without this
|
|
13
|
+
* config enforcing anything.
|
|
14
|
+
*
|
|
15
|
+
* Same trick, same reason as the root `eslint.complexity.config.mjs`, which
|
|
16
|
+
* registers `@typescript-eslint` (rules off) so source-file directives resolve
|
|
17
|
+
* inside that isolated gate.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** @type {import("eslint").Linter.Config[]} */
|
|
21
|
+
export default [
|
|
22
|
+
...baseConfig,
|
|
23
|
+
{
|
|
24
|
+
files: ['**/__tests__/**', '**/*.test.{ts,tsx}'],
|
|
25
|
+
plugins: { 'test-flakiness': testFlakiness },
|
|
26
|
+
// Registered-but-off means the rule never fires here, which would make
|
|
27
|
+
// every legitimate directive read as "unused". Scoped to test files so the
|
|
28
|
+
// rest of the package still gets dead-directive hygiene.
|
|
29
|
+
linterOptions: { reportUnusedDisableDirectives: 'off' },
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
ignores: ['dist/**', 'node_modules/**', 'coverage/**'],
|
|
33
|
+
},
|
|
34
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@12-apps/payments-frontend",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"clean": "rm -rf node_modules coverage",
|
|
11
|
+
"test": "node ../../../scripts/vitest-with-teardown.mjs run",
|
|
12
|
+
"test:watch": "vitest watch",
|
|
13
|
+
"lint": "eslint src --max-warnings 0",
|
|
14
|
+
"check-types": "tsc --noEmit",
|
|
15
|
+
"typecheck": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@emotion/react": ">=11.0.0",
|
|
19
|
+
"@emotion/styled": ">=11.0.0",
|
|
20
|
+
"@mui/material": ">=6.0.0",
|
|
21
|
+
"react": ">=19.0.0",
|
|
22
|
+
"react-dom": ">=19.0.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@emotion/react": "^11.14.0",
|
|
26
|
+
"@emotion/styled": "^11.14.0",
|
|
27
|
+
"@mui/material": "^6.5.0",
|
|
28
|
+
"@12-apps/eslint-config": "^1.1.0",
|
|
29
|
+
"@12-apps/payments-backend": "^1.0.0",
|
|
30
|
+
"@12-apps/typescript-config": "^1.1.0",
|
|
31
|
+
"@testing-library/react": "^16.1.0",
|
|
32
|
+
"@types/react": "19.2.2",
|
|
33
|
+
"@types/react-dom": "19.2.2",
|
|
34
|
+
"eslint": "^9.39.1",
|
|
35
|
+
"eslint-plugin-test-flakiness": "^1.4.0",
|
|
36
|
+
"jsdom": "^25.0.1",
|
|
37
|
+
"react": "^19.2.0",
|
|
38
|
+
"react-dom": "^19.2.0",
|
|
39
|
+
"typescript": "^5.9.2",
|
|
40
|
+
"vitest": "^3.2.4"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=22.0.0"
|
|
44
|
+
},
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"registry": "https://registry.npmjs.org",
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/12-apps/shared-packages.git",
|
|
53
|
+
"directory": "packages/payments/frontend"
|
|
54
|
+
},
|
|
55
|
+
"files": [
|
|
56
|
+
"src",
|
|
57
|
+
"dist",
|
|
58
|
+
"prisma",
|
|
59
|
+
"*.js",
|
|
60
|
+
"*.mjs",
|
|
61
|
+
"*.md"
|
|
62
|
+
]
|
|
63
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
});
|