Skip to content

Commit 7400f14

Browse files
authored
test: add ExtraProfileForm unit tests for both happy and sad paths (#7692)
1 parent f9161e9 commit 7400f14

File tree

3 files changed

+704
-0
lines changed

3 files changed

+704
-0
lines changed
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
import {
2+
CustomProfileFieldType,
3+
SupportedDateFormat,
4+
type CustomProfileField,
5+
} from '@logto/schemas';
6+
import { render, fireEvent, waitFor, act } from '@testing-library/react';
7+
8+
import ExtraProfileForm from '.';
9+
import { buildField, queryInput, querySelectorAll, waitForStateUpdate } from './test-helpers';
10+
11+
jest.mock('react-i18next', () => ({
12+
useTranslation: () => ({
13+
t: (key: string, options?: { types?: string[] }) => options?.types?.[0] ?? key,
14+
i18n: { dir: () => 'ltr' },
15+
}),
16+
}));
17+
18+
describe('ExtraProfileForm', () => {
19+
test('fills and submits all supported profile field types', async () => {
20+
const onSubmit = jest.fn(async (_values: unknown) => {
21+
// No operation
22+
});
23+
24+
const customProfileFields: CustomProfileField[] = [
25+
// Text
26+
buildField({
27+
name: 'nickname',
28+
label: 'Nickname',
29+
config: { minLength: 2, maxLength: 30 },
30+
}),
31+
// Number
32+
buildField({
33+
name: 'age',
34+
type: CustomProfileFieldType.Number,
35+
label: 'Age',
36+
config: { minValue: 1, maxValue: 120 },
37+
}),
38+
// Checkbox
39+
buildField({
40+
name: 'subscribeEmail',
41+
type: CustomProfileFieldType.Checkbox,
42+
label: 'Subscribe Email',
43+
required: false,
44+
}),
45+
// Url
46+
buildField({
47+
name: 'website',
48+
type: CustomProfileFieldType.Url,
49+
label: 'Website',
50+
required: false,
51+
}),
52+
// Regex (employee code pattern ABC-12)
53+
buildField({
54+
name: 'employeeCode',
55+
type: CustomProfileFieldType.Regex,
56+
label: 'Employee Code',
57+
config: { format: '^[A-Z]{3}-\\d{2}$' },
58+
}),
59+
// Select
60+
buildField({
61+
name: 'favoriteColor',
62+
type: CustomProfileFieldType.Select,
63+
label: 'Favorite Color',
64+
config: { options: [{ value: 'red' }, { value: 'green' }, { value: 'blue' }] },
65+
}),
66+
// Date
67+
buildField({
68+
name: 'birthdate',
69+
type: CustomProfileFieldType.Date,
70+
label: 'Birthdate',
71+
config: { format: SupportedDateFormat.ISO },
72+
}),
73+
// Fullname (3 parts)
74+
buildField({
75+
name: 'fullname',
76+
type: CustomProfileFieldType.Fullname,
77+
label: 'Full name',
78+
config: {
79+
parts: [
80+
{
81+
name: 'givenName',
82+
type: CustomProfileFieldType.Text,
83+
enabled: true,
84+
required: true,
85+
},
86+
{
87+
name: 'middleName',
88+
type: CustomProfileFieldType.Text,
89+
enabled: true,
90+
required: false,
91+
},
92+
{
93+
name: 'familyName',
94+
type: CustomProfileFieldType.Text,
95+
enabled: true,
96+
required: true,
97+
},
98+
],
99+
},
100+
}),
101+
// Address (omit formatted to compute automatically)
102+
buildField({
103+
name: 'address',
104+
type: CustomProfileFieldType.Address,
105+
label: 'Address',
106+
config: {
107+
parts: [
108+
{
109+
name: 'streetAddress',
110+
type: CustomProfileFieldType.Text,
111+
enabled: true,
112+
required: true,
113+
},
114+
{
115+
name: 'locality',
116+
type: CustomProfileFieldType.Text,
117+
enabled: true,
118+
required: true,
119+
},
120+
{
121+
name: 'region',
122+
type: CustomProfileFieldType.Text,
123+
enabled: true,
124+
required: true,
125+
},
126+
{
127+
name: 'postalCode',
128+
type: CustomProfileFieldType.Text,
129+
enabled: true,
130+
required: true,
131+
},
132+
{
133+
name: 'country',
134+
type: CustomProfileFieldType.Text,
135+
enabled: true,
136+
required: true,
137+
},
138+
],
139+
},
140+
}),
141+
];
142+
143+
const { container, getByText } = render(
144+
<ExtraProfileForm customProfileFields={customProfileFields} onSubmit={onSubmit} />
145+
);
146+
147+
// Fill fullname parts
148+
const givenNameInput = queryInput(container, 'input[name="givenName"]');
149+
const middleNameInput = queryInput(container, 'input[name="middleName"]');
150+
const familyNameInput = queryInput(container, 'input[name="familyName"]');
151+
act(() => {
152+
fireEvent.change(givenNameInput, { target: { value: 'Alice' } });
153+
fireEvent.change(middleNameInput, { target: { value: 'B' } });
154+
fireEvent.change(familyNameInput, { target: { value: 'Carroll' } });
155+
});
156+
// Text (nickname)
157+
const nicknameInput = queryInput(container, 'input[name="nickname"]');
158+
act(() => {
159+
fireEvent.change(nicknameInput, { target: { value: 'Ali' } });
160+
});
161+
// Number (age)
162+
const ageInput = queryInput(container, 'input[name="age"]');
163+
act(() => {
164+
fireEvent.change(ageInput, { target: { value: '25' } });
165+
});
166+
// Checkbox (custom component rendered without native checkbox name attr; query by role and label span)
167+
const subscribeCheckbox = querySelectorAll(container, '[role="checkbox"]').find((element) =>
168+
element.textContent?.includes('Subscribe Email')
169+
);
170+
if (!subscribeCheckbox) {
171+
throw new Error('Subscribe Email checkbox not found');
172+
}
173+
act(() => {
174+
fireEvent.click(subscribeCheckbox);
175+
});
176+
// Url
177+
const websiteInput = queryInput(container, 'input[name="website"]');
178+
act(() => {
179+
fireEvent.change(websiteInput, { target: { value: 'https://example.com' } });
180+
});
181+
182+
// Regex
183+
const employeeCodeInput = queryInput(container, 'input[name="employeeCode"]');
184+
act(() => {
185+
fireEvent.change(employeeCodeInput, { target: { value: 'ABC-12' } });
186+
});
187+
188+
// Select (custom dropdown, open then pick option)
189+
const favoriteColorInput = queryInput(container, 'input[name="favoriteColor"]');
190+
act(() => {
191+
fireEvent.click(favoriteColorInput);
192+
});
193+
const option = await waitFor(() =>
194+
querySelectorAll(document, '[role="menuitem"], li').find(
195+
(element) => element.textContent === 'green'
196+
)
197+
);
198+
if (!option) {
199+
throw new Error('Green option not found');
200+
}
201+
act(() => {
202+
fireEvent.click(option);
203+
});
204+
// Allow any state updates from select to flush
205+
await waitForStateUpdate();
206+
207+
// Address parts
208+
const streetAddress = queryInput(container, 'input[name="address.streetAddress"]');
209+
const locality = queryInput(container, 'input[name="address.locality"]');
210+
const region = queryInput(container, 'input[name="address.region"]');
211+
const postalCode = queryInput(container, 'input[name="address.postalCode"]');
212+
const country = queryInput(container, 'input[name="address.country"]');
213+
act(() => {
214+
fireEvent.change(streetAddress, { target: { value: '123 Main St' } });
215+
fireEvent.change(locality, { target: { value: 'Springfield' } });
216+
fireEvent.change(region, { target: { value: 'IL' } });
217+
fireEvent.change(postalCode, { target: { value: '62701' } });
218+
fireEvent.change(country, { target: { value: 'US' } });
219+
});
220+
221+
// Date (birthdate) - click label then fill parts (YYYY-MM-DD)
222+
const birthdateLabel = Array.from(container.querySelectorAll('*')).find(
223+
(element) => element.textContent === 'Birthdate'
224+
);
225+
if (!birthdateLabel) {
226+
throw new Error('Birthdate label not found');
227+
}
228+
act(() => {
229+
fireEvent.click(birthdateLabel);
230+
});
231+
await waitForStateUpdate();
232+
const dateInputs = Array.from(container.querySelectorAll('input')).filter((element) => {
233+
const placeholder = element.getAttribute('placeholder') ?? '';
234+
return ['YYYY', 'MM', 'DD'].includes(placeholder);
235+
});
236+
const getDateInput = (ph: string): HTMLInputElement => {
237+
const found = dateInputs.find((i) => i.getAttribute('placeholder') === ph);
238+
if (!(found instanceof HTMLInputElement)) {
239+
throw new TypeError(`Date input ${ph} not found`);
240+
}
241+
return found;
242+
};
243+
const yearInput = getDateInput('YYYY');
244+
const monthInput = getDateInput('MM');
245+
const dayInput = getDateInput('DD');
246+
act(() => {
247+
fireEvent.input(yearInput, { target: { value: '2023' } });
248+
fireEvent.input(monthInput, { target: { value: '08' } });
249+
fireEvent.input(dayInput, { target: { value: '20' } });
250+
});
251+
252+
// Submit
253+
const submitButton = getByText('action.continue');
254+
act(() => {
255+
fireEvent.click(submitButton);
256+
});
257+
258+
await waitFor(() => {
259+
expect(onSubmit).toHaveBeenCalledTimes(1);
260+
});
261+
type SubmittedForm = {
262+
givenName: string;
263+
middleName?: string;
264+
familyName: string;
265+
nickname: string;
266+
age: string;
267+
subscribeEmail: string;
268+
website?: string;
269+
employeeCode: string;
270+
favoriteColor: string;
271+
birthdate: string;
272+
address: {
273+
streetAddress: string;
274+
locality: string;
275+
region: string;
276+
postalCode: string;
277+
country: string;
278+
formatted: string;
279+
};
280+
};
281+
const submitted = onSubmit.mock.calls[0]?.[0] as SubmittedForm;
282+
expect(submitted).toBeTruthy();
283+
284+
expect(submitted.givenName).toBe('Alice');
285+
expect(submitted.middleName).toBe('B');
286+
expect(submitted.familyName).toBe('Carroll');
287+
expect(submitted.nickname).toBe('Ali');
288+
expect(submitted.age).toBe('25');
289+
expect(submitted.subscribeEmail).toBe('true');
290+
expect(submitted.website).toBe('https://example.com');
291+
expect(submitted.employeeCode).toBe('ABC-12');
292+
expect(submitted.favoriteColor).toBe('green');
293+
expect(submitted.birthdate).toBe('2023-08-20');
294+
expect(submitted.address).toBeDefined();
295+
expect(submitted.address.streetAddress).toBe('123 Main St');
296+
expect(submitted.address.locality).toBe('Springfield');
297+
expect(submitted.address.region).toBe('IL');
298+
expect(submitted.address.postalCode).toBe('62701');
299+
expect(submitted.address.country).toBe('US');
300+
expect(submitted.address.formatted).toBe('123 Main St, Springfield, IL, 62701, US');
301+
});
302+
});

0 commit comments

Comments
 (0)