Files
fluxer/packages/validation/src/__tests__/Validator.test.tsx
2026-02-17 12:22:36 +00:00

698 lines
18 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {createValidator} from '@fluxer/validation/src/Validator';
import {Hono} from 'hono';
import {describe, expect, it} from 'vitest';
import {z} from 'zod';
function createTestApp() {
return new Hono();
}
describe('Validator middleware', () => {
describe('JSON body validation', () => {
it('should validate valid JSON body', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
age: z.number(),
});
app.post('/test', createValidator('json', schema), (c) => {
const data = c.req.valid('json');
return c.json(data);
});
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'John', age: 30}),
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({name: 'John', age: 30});
});
it('should throw InputValidationError for invalid JSON body', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
age: z.number(),
});
let thrownError: Error | null = null;
app.post('/test', createValidator('json', schema), (c) => {
return c.json({success: true});
});
app.onError((err) => {
thrownError = err;
if (err instanceof InputValidationError) {
return new Response(JSON.stringify({error: 'validation_error'}), {status: 400});
}
return new Response('Internal error', {status: 500});
});
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 123, age: 'not a number'}),
});
expect(response.status).toBe(400);
expect(thrownError).toBeInstanceOf(InputValidationError);
});
it('should handle empty JSON body as empty object', async () => {
const app = createTestApp();
const schema = z.object({}).optional();
app.post('/test', createValidator('json', schema), (c) => {
const data = c.req.valid('json');
return c.json({received: data});
});
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: '',
});
expect(response.status).toBe(200);
});
});
describe('Query parameter validation', () => {
it('should validate valid query parameters', async () => {
const app = createTestApp();
const schema = z.object({
page: z.string(),
limit: z.string(),
});
app.get('/test', createValidator('query', schema), (c) => {
const data = c.req.valid('query');
return c.json(data);
});
const response = await app.request('/test?page=1&limit=10');
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({page: '1', limit: '10'});
});
it('should throw InputValidationError for missing required query parameters', async () => {
const app = createTestApp();
const schema = z.object({
page: z.string(),
limit: z.string(),
});
let thrownError: Error | null = null;
app.get('/test', createValidator('query', schema), (c) => {
return c.json({success: true});
});
app.onError((err) => {
thrownError = err;
return new Response('Error', {status: 400});
});
const response = await app.request('/test?page=1');
expect(response.status).toBe(400);
expect(thrownError).toBeInstanceOf(InputValidationError);
});
it('should handle multiple values for same query parameter', async () => {
const app = createTestApp();
const schema = z.object({
tags: z.array(z.string()),
});
app.get('/test', createValidator('query', schema), (c) => {
const data = c.req.valid('query');
return c.json(data);
});
const response = await app.request('/test?tags=a&tags=b&tags=c');
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({tags: ['a', 'b', 'c']});
});
});
describe('Path parameter validation', () => {
it('should validate valid path parameters', async () => {
const app = createTestApp();
const schema = z.object({
id: z.string(),
});
app.get('/users/:id', createValidator('param', schema), (c) => {
const data = c.req.valid('param');
return c.json(data);
});
const response = await app.request('/users/123');
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({id: '123'});
});
it('should throw InputValidationError for invalid path parameters', async () => {
const app = createTestApp();
const schema = z.object({
id: z.string().uuid(),
});
let thrownError: Error | null = null;
app.get('/users/:id', createValidator('param', schema), (c) => {
return c.json({success: true});
});
app.onError((err) => {
thrownError = err;
return new Response('Error', {status: 400});
});
const response = await app.request('/users/not-a-uuid');
expect(response.status).toBe(400);
expect(thrownError).toBeInstanceOf(InputValidationError);
});
});
describe('Header validation', () => {
it('should validate valid headers', async () => {
const app = createTestApp();
const schema = z.object({
'x-api-key': z.string(),
});
app.get('/test', createValidator('header', schema), (c) => {
const data = c.req.valid('header');
return c.json({key: data['x-api-key']});
});
const response = await app.request('/test', {
headers: {'X-Api-Key': 'secret-key'},
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({key: 'secret-key'});
});
});
describe('Empty string to null conversion', () => {
it('should convert empty strings to null', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
bio: z.string().nullable(),
});
app.post('/test', createValidator('json', schema), (c) => {
const data = c.req.valid('json');
return c.json(data);
});
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'John', bio: ''}),
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({name: 'John', bio: null});
});
it('should convert nested empty strings to null', async () => {
const app = createTestApp();
const schema = z.object({
user: z.object({
name: z.string(),
description: z.string().nullable(),
}),
});
app.post('/test', createValidator('json', schema), (c) => {
const data = c.req.valid('json');
return c.json(data);
});
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({user: {name: 'John', description: ''}}),
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({user: {name: 'John', description: null}});
});
it('should convert empty arrays elements to null', async () => {
const app = createTestApp();
const schema = z.object({
items: z.array(z.string().nullable()),
});
app.post('/test', createValidator('json', schema), (c) => {
const data = c.req.valid('json');
return c.json(data);
});
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: ['a', '', 'b']}),
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({items: ['a', null, 'b']});
});
});
describe('Hook options', () => {
it('should call pre hook before validation', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
});
app.post(
'/test',
createValidator('json', schema, {
pre: (value) => {
const obj = value as Record<string, unknown>;
return {...obj, name: String(obj.name).toUpperCase()};
},
}),
(c) => {
const data = c.req.valid('json');
return c.json(data);
},
);
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'john'}),
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({name: 'JOHN'});
});
it('should call post hook with validation result', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
});
let postHookCalled = false;
let postHookSuccess = false;
app.post(
'/test',
createValidator('json', schema, {
post: (result) => {
postHookCalled = true;
postHookSuccess = result.success;
return undefined;
},
}),
(c) => {
const data = c.req.valid('json');
return c.json(data);
},
);
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'John'}),
});
expect(response.status).toBe(200);
expect(postHookCalled).toBe(true);
expect(postHookSuccess).toBe(true);
});
it('should allow post hook to return custom response', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
});
app.post(
'/test',
createValidator('json', schema, {
post: () => {
return new Response('Custom response', {status: 202});
},
}),
(c) => {
return c.json({reached: true});
},
);
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'John'}),
});
expect(response.status).toBe(202);
const body = await response.text();
expect(body).toBe('Custom response');
});
it('should support hook as function shorthand for post hook', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
});
let hookCalled = false;
app.post(
'/test',
createValidator('json', schema, (result) => {
hookCalled = true;
expect(result.success).toBe(true);
return undefined;
}),
(c) => {
const data = c.req.valid('json');
return c.json(data);
},
);
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'John'}),
});
expect(response.status).toBe(200);
expect(hookCalled).toBe(true);
});
});
describe('Error details', () => {
it('should include path in validation errors', async () => {
const app = createTestApp();
const schema = z.object({
user: z.object({
email: z.email(),
}),
});
let capturedError: InputValidationError | null = null;
app.post('/test', createValidator('json', schema), (c) => {
return c.json({success: true});
});
app.onError((err) => {
if (err instanceof InputValidationError) {
capturedError = err;
}
return new Response('Error', {status: 400});
});
await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({user: {email: 'invalid'}}),
});
expect(capturedError).toBeInstanceOf(InputValidationError);
const localizedErrors = capturedError!.getLocalizedErrors();
expect(localizedErrors).not.toBeNull();
expect(localizedErrors![0].path).toBe('user.email');
expect(localizedErrors![0].code).toBe(ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
});
it('should include error code for too_small errors', async () => {
const app = createTestApp();
const schema = z.object({
items: z.array(z.string()).min(3),
});
let capturedError: InputValidationError | null = null;
app.post('/test', createValidator('json', schema), (c) => {
return c.json({success: true});
});
app.onError((err) => {
if (err instanceof InputValidationError) {
capturedError = err;
}
return new Response('Error', {status: 400});
});
await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: ['a']}),
});
expect(capturedError).toBeInstanceOf(InputValidationError);
const localizedErrors = capturedError!.getLocalizedErrors();
expect(localizedErrors).not.toBeNull();
expect(localizedErrors![0].path).toBe('items');
expect(localizedErrors![0].code).toBe(ValidationErrorCodes.INVALID_FORMAT);
});
it('should include error code for too_big errors', async () => {
const app = createTestApp();
const schema = z.object({
count: z.number().max(10),
});
let capturedError: InputValidationError | null = null;
app.post('/test', createValidator('json', schema), (c) => {
return c.json({success: true});
});
app.onError((err) => {
if (err instanceof InputValidationError) {
capturedError = err;
}
return new Response('Error', {status: 400});
});
await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({count: 100}),
});
expect(capturedError).toBeInstanceOf(InputValidationError);
const localizedErrors = capturedError!.getLocalizedErrors();
expect(localizedErrors).not.toBeNull();
expect(localizedErrors![0].path).toBe('count');
expect(localizedErrors![0].code).toBe(ValidationErrorCodes.INVALID_FORMAT);
});
it('should use root as path for top-level errors', async () => {
const app = createTestApp();
const schema = z.string();
let capturedError: InputValidationError | null = null;
app.post('/test', createValidator('json', schema), (c) => {
return c.json({success: true});
});
app.onError((err) => {
if (err instanceof InputValidationError) {
capturedError = err;
}
return new Response('Error', {status: 400});
});
await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({not: 'a string'}),
});
expect(capturedError).toBeInstanceOf(InputValidationError);
const localizedErrors = capturedError!.getLocalizedErrors();
expect(localizedErrors).not.toBeNull();
expect(localizedErrors![0].path).toBe('root');
});
});
describe('Form data validation', () => {
it('should validate form data', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
email: z.email(),
});
app.post('/test', createValidator('form', schema), (c) => {
const data = c.req.valid('form');
return c.json(data);
});
const formData = new FormData();
formData.append('name', 'John');
formData.append('email', 'john@example.com');
const response = await app.request('/test', {
method: 'POST',
body: formData,
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({name: 'John', email: 'john@example.com'});
});
it('should handle array fields in form data', async () => {
const app = createTestApp();
const schema = z.object({
'tags[]': z.array(z.string()),
});
app.post('/test', createValidator('form', schema), (c) => {
const data = c.req.valid('form');
return c.json(data);
});
const formData = new FormData();
formData.append('tags[]', 'a');
formData.append('tags[]', 'b');
formData.append('tags[]', 'c');
const response = await app.request('/test', {
method: 'POST',
body: formData,
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({'tags[]': ['a', 'b', 'c']});
});
it('should handle duplicate non-array fields as arrays', async () => {
const app = createTestApp();
const schema = z.object({
name: z.union([z.string(), z.array(z.string())]),
});
app.post('/test', createValidator('form', schema), (c) => {
const data = c.req.valid('form');
return c.json(data);
});
const formData = new FormData();
formData.append('name', 'John');
formData.append('name', 'Jane');
const response = await app.request('/test', {
method: 'POST',
body: formData,
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({name: ['John', 'Jane']});
});
});
describe('Async validation', () => {
it('should handle async schema validation', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string().refine(async (val) => val.length > 2, {
message: ValidationErrorCodes.STRING_LENGTH_INVALID,
}),
});
app.post('/test', createValidator('json', schema), (c) => {
const data = c.req.valid('json');
return c.json(data);
});
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'John'}),
});
expect(response.status).toBe(200);
});
it('should handle async pre hook', async () => {
const app = createTestApp();
const schema = z.object({
name: z.string(),
});
app.post(
'/test',
createValidator('json', schema, {
pre: async (value) => {
await new Promise((resolve) => setTimeout(resolve, 10));
const obj = value as Record<string, unknown>;
return {...obj, name: String(obj.name).toUpperCase()};
},
}),
(c) => {
const data = c.req.valid('json');
return c.json(data);
},
);
const response = await app.request('/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: 'john'}),
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({name: 'JOHN'});
});
});
});