Skip to content

Commit ec28582

Browse files
committed
test: add convex tests
1 parent a8ace60 commit ec28582

File tree

5 files changed

+396
-2
lines changed

5 files changed

+396
-2
lines changed

convex/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,3 @@
1616
"convex": "^1.27.0"
1717
}
1818
}
19-
20-
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { render, screen } from '@testing-library/react';
2+
import user from '@testing-library/user-event';
3+
import React from 'react';
4+
import { useForm } from 'react-hook-form';
5+
import { convexResolver } from '../convex';
6+
import { schema } from './__fixtures__/data';
7+
8+
const USERNAME_REQUIRED_MESSAGE = 'username field is required';
9+
const PASSWORD_REQUIRED_MESSAGE = 'New Password is required';
10+
11+
function TestComponent() {
12+
const {
13+
register,
14+
handleSubmit,
15+
formState: { errors },
16+
} = useForm({
17+
resolver: convexResolver(schema),
18+
shouldUseNativeValidation: true,
19+
});
20+
21+
return (
22+
<form onSubmit={handleSubmit(() => {})}>
23+
<input {...register('username')} />
24+
{errors.username && <span role="alert">{errors.username.message}</span>}
25+
26+
<input {...register('password')} />
27+
{errors.password && <span role="alert">{errors.password.message}</span>}
28+
29+
<button type="submit">submit</button>
30+
</form>
31+
);
32+
}
33+
34+
test('Form validation with Convex resolver using native validation', async () => {
35+
render(<TestComponent />);
36+
37+
expect(screen.queryAllByRole('alert')).toHaveLength(0);
38+
39+
await user.click(screen.getByText(/submit/i));
40+
41+
expect(screen.getByText(USERNAME_REQUIRED_MESSAGE)).toBeInTheDocument();
42+
expect(screen.getByText(PASSWORD_REQUIRED_MESSAGE)).toBeInTheDocument();
43+
});

convex/src/__tests__/Form.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { render, screen } from '@testing-library/react';
2+
import user from '@testing-library/user-event';
3+
import { v } from 'convex/values';
4+
import React from 'react';
5+
import { useForm } from 'react-hook-form';
6+
import { convexResolver } from '../convex';
7+
8+
const USERNAME_REQUIRED_MESSAGE = 'username field is required';
9+
const PASSWORD_REQUIRED_MESSAGE = 'New Password is required';
10+
11+
const schema = v.object({
12+
username: v.string(),
13+
password: v.string(),
14+
});
15+
16+
type FormData = {
17+
username?: string;
18+
password?: string;
19+
};
20+
21+
interface Props {
22+
onSubmit: (data: FormData) => void;
23+
}
24+
25+
function TestComponent({ onSubmit }: Props) {
26+
const {
27+
register,
28+
handleSubmit,
29+
formState: { errors },
30+
} = useForm<FormData>({
31+
resolver: convexResolver(schema),
32+
});
33+
34+
return (
35+
<form onSubmit={handleSubmit(onSubmit)}>
36+
<input {...register('username')} />
37+
{errors.username && <span role="alert">{errors.username.message}</span>}
38+
39+
<input {...register('password')} />
40+
{errors.password && <span role="alert">{errors.password.message}</span>}
41+
42+
<button type="submit">submit</button>
43+
</form>
44+
);
45+
}
46+
47+
test("form's validation with Convex resolver and TypeScript's integration", async () => {
48+
const handleSubmit = vi.fn();
49+
render(<TestComponent onSubmit={handleSubmit} />);
50+
51+
expect(screen.queryAllByRole('alert')).toHaveLength(0);
52+
53+
await user.click(screen.getByText(/submit/i));
54+
55+
expect(screen.getByText(USERNAME_REQUIRED_MESSAGE)).toBeInTheDocument();
56+
expect(screen.getByText(PASSWORD_REQUIRED_MESSAGE)).toBeInTheDocument();
57+
expect(handleSubmit).not.toHaveBeenCalled();
58+
});
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { Field, InternalFieldName } from 'react-hook-form';
2+
3+
type ConvexIssue = {
4+
path?: (string | number)[];
5+
message: string;
6+
code?: string;
7+
};
8+
9+
type ConvexValidationResult<T> =
10+
| { success: true; value: T }
11+
| { success: false; issues: ConvexIssue[] };
12+
13+
type ConvexSchema<Input, Output> = {
14+
validate: (
15+
value: Input,
16+
) => ConvexValidationResult<Output> | Promise<ConvexValidationResult<Output>>;
17+
};
18+
19+
export type SchemaInput = {
20+
username?: string;
21+
password?: string;
22+
repeatPassword?: string;
23+
accessToken?: string | number;
24+
birthYear?: number;
25+
email?: string;
26+
tags?: (string | number)[];
27+
enabled?: boolean;
28+
like?: {
29+
id?: number | string;
30+
name?: string;
31+
};
32+
};
33+
34+
export type SchemaOutput = Required<
35+
Omit<SchemaInput, 'repeatPassword' | 'like'> & {
36+
like: { id: number; name: string };
37+
}
38+
>;
39+
40+
export const schema: ConvexSchema<SchemaInput, SchemaOutput> = {
41+
validate(value) {
42+
const issues: ConvexIssue[] = [];
43+
44+
if (typeof value.username !== 'string' || value.username.length === 0) {
45+
issues.push({
46+
path: ['username'],
47+
message: 'username field is required',
48+
code: 'required',
49+
});
50+
}
51+
if (
52+
typeof value.username === 'string' &&
53+
value.username.length > 0 &&
54+
value.username.length < 2
55+
) {
56+
issues.push({
57+
path: ['username'],
58+
message: 'Too short',
59+
code: 'minLength',
60+
});
61+
}
62+
63+
if (typeof value.password !== 'string' || value.password.length === 0) {
64+
issues.push({
65+
path: ['password'],
66+
message: 'New Password is required',
67+
code: 'required',
68+
});
69+
}
70+
if (
71+
typeof value.password === 'string' &&
72+
value.password.length > 0 &&
73+
value.password.length < 8
74+
) {
75+
issues.push({
76+
path: ['password'],
77+
message: 'Must be at least 8 characters in length',
78+
code: 'minLength',
79+
});
80+
}
81+
82+
if (typeof value.email !== 'string' || value.email.length === 0) {
83+
issues.push({
84+
path: ['email'],
85+
message: 'Invalid email address',
86+
code: 'email',
87+
});
88+
} else if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value.email)) {
89+
issues.push({
90+
path: ['email'],
91+
message: 'Invalid email address',
92+
code: 'email',
93+
});
94+
}
95+
96+
if (typeof value.birthYear !== 'number') {
97+
issues.push({
98+
path: ['birthYear'],
99+
message: 'Please enter your birth year',
100+
code: 'type',
101+
});
102+
} else if (value.birthYear < 1900 || value.birthYear > 2013) {
103+
issues.push({
104+
path: ['birthYear'],
105+
message: 'Invalid birth year',
106+
code: 'range',
107+
});
108+
}
109+
110+
if (!Array.isArray(value.tags)) {
111+
issues.push({
112+
path: ['tags'],
113+
message: 'Tags should be strings',
114+
code: 'type',
115+
});
116+
} else {
117+
for (let i = 0; i < value.tags.length; i++) {
118+
if (typeof value.tags[i] !== 'string') {
119+
issues.push({
120+
path: ['tags', i],
121+
message: 'Tags should be strings',
122+
code: 'type',
123+
});
124+
}
125+
}
126+
}
127+
128+
if (typeof value.enabled !== 'boolean') {
129+
issues.push({
130+
path: ['enabled'],
131+
message: 'enabled must be a boolean',
132+
code: 'type',
133+
});
134+
}
135+
136+
const like = value.like || {};
137+
if (typeof like.id !== 'number') {
138+
issues.push({
139+
path: ['like', 'id'],
140+
message: 'Like id is required',
141+
code: 'type',
142+
});
143+
}
144+
if (typeof like.name !== 'string') {
145+
issues.push({
146+
path: ['like', 'name'],
147+
message: 'Like name is required',
148+
code: 'required',
149+
});
150+
} else if ((like.name as string).length < 4) {
151+
issues.push({
152+
path: ['like', 'name'],
153+
message: 'Too short',
154+
code: 'minLength',
155+
});
156+
}
157+
158+
if (issues.length > 0) {
159+
return { success: false, issues };
160+
}
161+
162+
return {
163+
success: true,
164+
value: {
165+
username: value.username!,
166+
password: value.password!,
167+
accessToken: value.accessToken!,
168+
birthYear: value.birthYear!,
169+
email: value.email!,
170+
tags: (value.tags as string[])!,
171+
enabled: value.enabled!,
172+
like: { id: like.id as number, name: like.name as string },
173+
},
174+
} as const;
175+
},
176+
};
177+
178+
export const validData: SchemaInput = {
179+
username: 'Doe',
180+
password: 'Password123_',
181+
repeatPassword: 'Password123_',
182+
birthYear: 2000,
183+
email: 'john@doe.com',
184+
tags: ['tag1', 'tag2'],
185+
enabled: true,
186+
accessToken: 'accessToken',
187+
like: {
188+
id: 1,
189+
name: 'name',
190+
},
191+
};
192+
193+
export const invalidData: SchemaInput = {
194+
password: '___',
195+
email: '',
196+
birthYear: undefined as any,
197+
like: { id: 'z' as any },
198+
tags: [1, 2, 3] as any,
199+
};
200+
201+
export const fields: Record<InternalFieldName, Field['_f']> = {
202+
username: {
203+
ref: { name: 'username' },
204+
name: 'username',
205+
},
206+
password: {
207+
ref: { name: 'password' },
208+
name: 'password',
209+
},
210+
email: {
211+
ref: { name: 'email' },
212+
name: 'email',
213+
},
214+
birthday: {
215+
ref: { name: 'birthday' },
216+
name: 'birthday',
217+
},
218+
};

0 commit comments

Comments
 (0)