Files

1130 lines
24 KiB
TypeScript

import { Context, Elysia, t, ValidationError } from '../../src'
import { describe, expect, it } from 'bun:test'
import { req } from '../utils'
describe('Query Validator', () => {
it('validate single', async () => {
const app = new Elysia().get('/', ({ query: { name } }) => name, {
query: t.Object({
name: t.String()
})
})
const res = await app.handle(req('/?name=sucrose'))
expect(await res.text()).toBe('sucrose')
expect(res.status).toBe(200)
})
it('validate with hyphen in key', async () => {
const app = new Elysia().get(
'/',
({ query }) => query['character-name'],
{
query: t.Object({
'character-name': t.String()
})
}
)
const res = await app.handle(req('/?character-name=sucrose'))
expect(await res.text()).toBe('sucrose')
expect(res.status).toBe(200)
})
it('validate with dot in key', async () => {
const app = new Elysia().get(
'/',
({ query }) => query['character.name'],
{
query: t.Object({
'character.name': t.String()
})
}
)
const res = await app.handle(req('/?character.name=sucrose'))
expect(await res.text()).toBe('sucrose')
expect(res.status).toBe(200)
})
it('validate multiple', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
name: t.String(),
job: t.String(),
trait: t.String()
})
})
const res = await app.handle(
req('/?name=sucrose&job=alchemist&trait=dog')
)
expect(await res.json()).toEqual({
name: 'sucrose',
job: 'alchemist',
trait: 'dog'
})
expect(res.status).toBe(200)
})
it('parse without reference', async () => {
const app = new Elysia().get('/', () => '', {
query: t.Object({
name: t.String(),
job: t.String(),
trait: t.String()
})
})
const res = await app.handle(
req('/?name=sucrose&job=alchemist&trait=dog')
)
expect(res.status).toBe(200)
})
it('validate optional', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
name: t.String(),
job: t.String(),
trait: t.Optional(t.String())
})
})
const res = await app.handle(req('/?name=sucrose&job=alchemist'))
expect(await res.json()).toEqual({
name: 'sucrose',
job: 'alchemist'
})
expect(res.status).toBe(200)
})
it('parse single numeric', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
name: t.String(),
job: t.String(),
trait: t.Optional(t.String()),
age: t.Numeric()
})
})
const res = await app.handle(req('/?name=sucrose&job=alchemist&age=16'))
expect(await res.json()).toEqual({
name: 'sucrose',
job: 'alchemist',
age: 16
})
expect(res.status).toBe(200)
})
it('parse multiple numeric', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
name: t.String(),
job: t.String(),
trait: t.Optional(t.String()),
age: t.Numeric(),
rank: t.Numeric()
})
})
const res = await app.handle(
req('/?name=sucrose&job=alchemist&age=16&rank=4')
)
expect(await res.json()).toEqual({
name: 'sucrose',
job: 'alchemist',
age: 16,
rank: 4
})
expect(res.status).toBe(200)
})
it('parse numeric enum', async () => {
enum Gender {
MALE = 1,
FEMALE = 2,
UNKNOWN = 3
}
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
name: t.String(),
gender: t.NumericEnum(Gender)
})
})
const res = await app.handle(
req(`/?name=sucrose&gender=${Gender.MALE}`)
)
expect(await res.json()).toEqual({
name: 'sucrose',
gender: Gender.MALE
})
expect(res.status).toBe(200)
})
it('parse single integer', async () => {
const app = new Elysia().get('/', ({ query: { limit } }) => limit, {
query: t.Object({
limit: t.Integer()
})
})
const res = await app.handle(req('/?limit=16'))
expect(res.status).toBe(200)
expect(await res.text()).toBe('16')
})
it('parse multiple integer', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
limit: t.Integer(),
offset: t.Integer()
})
})
const res = await app.handle(req('/?limit=16&offset=0'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ limit: 16, offset: 0 })
})
it('validate partial', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Partial(
t.Object({
name: t.String(),
job: t.String(),
trait: t.Optional(t.String())
})
)
})
const res = await app.handle(req('/'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
})
it('parse numeric with partial', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Partial(
t.Object({
name: t.String(),
job: t.String(),
trait: t.Optional(t.String()),
age: t.Numeric(),
rank: t.Numeric()
})
)
})
const res = await app.handle(req('/'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
})
it('parse boolean string', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
param1: t.BooleanString()
})
})
const res = await app.handle(req('/?param1=true'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ param1: true })
})
it('parse optional boolean string', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
param1: t.Optional(t.BooleanString({ default: true }))
})
})
const res = await app.handle(req('/'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ param1: true })
})
it('parse optional boolean string with second parameter', async () => {
const schema = t.Object({
registered: t.Optional(t.Boolean()),
other: t.String()
})
const app = new Elysia().get('/', ({ query }) => query, {
query: schema
})
const res = await app.handle(req('/?other=sucrose'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ other: 'sucrose' })
})
it('parse optional boolean string with default value', async () => {
const schema = t.Object({
registered: t.Optional(t.Boolean({ default: true })),
other: t.String()
})
const app = new Elysia().get('/', ({ query }) => query, {
query: schema
})
const res = await app.handle(req('/?other=sucrose'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ other: 'sucrose', registered: true })
})
it('validate optional object', async () => {
const app = new Elysia().get(
'/',
({ query }) => query?.name ?? 'sucrose',
{
query: t.Object(
{
name: t.Optional(t.String())
},
{
additionalProperties: true
}
)
}
)
const [valid, invalid] = await Promise.all([
app.handle(req('/?name=sucrose')),
app.handle(req('/'))
])
expect(await valid.text()).toBe('sucrose')
expect(valid.status).toBe(200)
expect(await invalid.text()).toBe('sucrose')
expect(invalid.status).toBe(200)
})
it('create default string query', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
name: t.String(),
faction: t.String({ default: 'tea_party' })
})
})
const value = await app
.handle(req('/?name=nagisa'))
.then((x) => x.json())
expect(value).toEqual({
name: 'nagisa',
faction: 'tea_party'
})
})
it('create default number query', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
name: t.String(),
rank: t.Number({ default: 1 })
})
})
const value = await app
.handle(req('/?name=nagisa'))
.then((x) => x.json())
expect(value).toEqual({
name: 'nagisa',
rank: 1
})
})
it('handle query edge case', async () => {
const checker = {
check(ctx: Context, name: string, state?: string) {
return typeof state !== 'undefined'
}
}
const app = new Elysia()
.derive((ctx) => {
const { name } = ctx.params
return {
check() {
const { state } = ctx.query
if (!checker.check(ctx, name, state ?? ctx.query.state))
throw new Error('State mismatch')
}
}
})
.get('/:name', ({ check }) => {
check()
return 'yay'
})
const response = await app
.handle(req('/a?state=123'))
.then((x) => x.text())
expect(response).toBe('yay')
})
it('parse query array', async () => {
const params = new URLSearchParams()
params.append('keys', '1')
params.append('keys', '2')
const response = await new Elysia()
.get('/', ({ query }) => query, {
query: t.Object({
keys: t.Array(t.String())
})
})
.handle(new Request(`http://localhost/?${params.toString()}`))
.then((res) => res.json())
expect(response).toEqual({ keys: ['1', '2'] })
})
it('parse query object', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
role: t.Optional(
t.Array(
t.Object({
name: t.String()
})
)
)
})
})
const response = await app
.handle(
req(
`/?role=${JSON.stringify([
{ name: 'hello' },
{ name: 'world' }
])}`
)
)
.then((x) => x.json())
expect(response).toEqual({
role: [{ name: 'hello' }, { name: 'world' }]
})
})
it('parse optional query object', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Optional(
t.Object({
role: t.Optional(
t.Array(
t.Object({
name: t.String()
})
)
)
})
)
})
const response = await app
.handle(
req(
`/?role=${JSON.stringify([
{ name: 'hello' },
{ name: 'world' }
])}`
)
)
.then((x) => x.json())
expect(response).toEqual({
role: [{ name: 'hello' }, { name: 'world' }]
})
})
it('parse array with nested object', async () => {
const params = new URLSearchParams()
params.append('keys', JSON.stringify({ a: 'hello' }))
params.append('keys', JSON.stringify({ a: 'hi' }))
const response = await new Elysia()
.get('/', ({ query }) => query, {
query: t.Object({
keys: t.Array(
t.Object({
a: t.String()
})
)
})
})
.handle(new Request(`http://localhost/?${params.toString()}`))
.then((res) => res.json())
expect(response).toEqual({
keys: [{ a: 'hello' }, { a: 'hi' }]
})
})
it('parse optional array with nested object', async () => {
const params = new URLSearchParams()
params.append('keys', JSON.stringify({ a: 'hello' }))
params.append('keys', JSON.stringify({ a: 'hi' }))
const response = await new Elysia()
.get('/', ({ query }) => query, {
query: t.Optional(
t.Object({
keys: t.Array(
t.Object({
a: t.String()
})
)
})
)
})
.handle(new Request(`http://localhost/?${params.toString()}`))
.then((res) => res.json())
expect(response).toEqual({
keys: [{ a: 'hello' }, { a: 'hi' }]
})
})
it('parse query object array without schema', async () => {
const params = new URLSearchParams()
params.append('keys', JSON.stringify({ a: 'hello' }))
params.append('keys', JSON.stringify({ a: 'hi' }))
const response = await new Elysia()
.get('/', ({ query }) => query, {
query: t.Optional(
t.Object({
keys: t.Array(
t.Object({
a: t.String()
})
)
})
)
})
.handle(new Request(`http://localhost/?${params.toString()}`))
.then((res) => res.json())
expect(response).toEqual({
keys: [{ a: 'hello' }, { a: 'hi' }]
})
})
// People don't expect this
// @see: https://x.com/saltyAom/status/1813236251321069918
// it('parse query array without schema', async () => {
// let value: string[] | undefined
// const response = await new Elysia()
// .get('/', ({ query: { keys } }) => value = keys)
// .handle(new Request(`http://localhost/?id=1&id=2`))
// .then((res) => res.json())
// expect(value).toEqual(['1', '2'])
// })
it("don't parse query object without schema", async () => {
const app = new Elysia().get('/', ({ query: { role } }) => role)
const response = await app
.handle(req(`/?role=${JSON.stringify({ name: 'hello' })}`))
.then((x) => x.text())
expect(response).toBe(JSON.stringify({ name: 'hello' }))
})
it('parse union primitive and object', async () => {
const app = new Elysia().get('/', ({ query: { ids } }) => ids, {
query: t.Object({
ids: t.Union([
t.Array(
t.Union([t.Object({ a: t.String() }), t.Numeric()])
),
t.Numeric()
])
})
})
const response = await app
.handle(req(`/?ids=1&ids=${JSON.stringify({ a: 'b' })}`))
.then((res) => res.json())
expect(response).toEqual([1, { a: 'b' }])
})
it('coerce number object to numeric', async () => {
const app = new Elysia().get('/', ({ query: { id } }) => typeof id, {
query: t.Object({
id: t.Number()
})
})
const value = await app.handle(req('/?id=1')).then((x) => x.text())
expect(value).toBe('number')
})
it('coerce string object to boolean', async () => {
const app = new Elysia().get(
'/',
({ query: { isAdmin } }) => typeof isAdmin,
{
query: t.Object({
isAdmin: t.Boolean()
})
}
)
const value = await app
.handle(req('/?isAdmin=true'))
.then((x) => x.text())
expect(value).toBe('boolean')
})
it("don't parse object automatically unless explicitly specified", async () => {
let value: string | undefined
const app = new Elysia().get(
'/',
({ query: { pagination } }) => (value = pagination as string)
)
await app.handle(
req(
`/?pagination=${JSON.stringify({ pageIndex: 1, pageLimit: 10 })}`
)
)
expect(value).toEqual(JSON.stringify({ pageIndex: 1, pageLimit: 10 }))
})
it('handle object array in single query', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
pagination: t.Array(
t.Object({
pageIndex: t.Number(),
pageLimit: t.Number()
})
)
})
})
const response = await app
.handle(
req(
`/?pagination=${JSON.stringify([{ pageIndex: 1, pageLimit: 10 }])}`
)
)
.then((x) => x.json())
expect(response).toEqual({
pagination: [{ pageIndex: 1, pageLimit: 10 }]
})
})
it('handle merge object to array in multiple query', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
pagination: t.Array(
t.Object({
pageIndex: t.Number(),
pageLimit: t.Number()
})
)
})
})
const response = await app
.handle(
req(
`/?pagination=${JSON.stringify({ pageIndex: 1, pageLimit: 10 })}&pagination=${JSON.stringify({ pageIndex: 2, pageLimit: 9 })}`
)
)
.then((x) => x.json())
expect(response).toEqual({
pagination: [
{ pageIndex: 1, pageLimit: 10 },
{ pageIndex: 2, pageLimit: 9 }
]
})
})
it('don\t coerce number in nested object', async () => {
const app = new Elysia().get('/', ({ query: { user } }) => user, {
query: t.Object({
user: t.Object({
id: t.Number(),
name: t.String()
})
})
})
const response = await app.handle(
req(
`?user=${JSON.stringify({
id: '2',
name: 'test'
})}`
)
)
expect(response.status).toBe(422)
})
it('handle optional at root', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Optional(
t.Object({
id: t.Numeric()
})
)
})
const res = await Promise.all([
app.handle(req('/')).then((x) => x.json()),
app.handle(req('/?id=1')).then((x) => x.json())
])
expect(res).toEqual([{}, { id: 1 }])
})
it('parse query array in multiple location correctly', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
leading: t.String(),
arr: t.Array(t.String()),
trailing: t.String()
})
})
const response = await app
.handle(req('/?leading=foo&arr=bar&arr=baz&trailing=qux&arr=xd'))
.then((x) => x.json())
expect(response).toEqual({
leading: 'foo',
arr: ['bar', 'baz', 'xd'],
trailing: 'qux'
})
})
it('parse + in query', async () => {
const api = new Elysia().get('', ({ query }) => query, {
query: t.Object({
keyword: t.String()
})
})
const url = new URL('http://localhost:3000/')
url.searchParams.append('keyword', 'hello world')
// console.log(url.href) //http://localhost:3000/?keyword=hello+world
const result = await api
.handle(new Request(url.href))
.then((response) => response.json())
expect(result).toEqual({
keyword: 'hello world'
})
})
// https://github.com/elysiajs/elysia/issues/929
it('slice non-ASCII querystring offset correctly', async () => {
const app = new Elysia().get('/', () => 'ok', {
query: t.Object({
key1: t.Union([t.Array(t.String()), t.String()])
})
})
expect(
await app
.handle(req('/?key1=ab&key1=cd&z=が'))
.then((x) => x.status)
).toEqual(200)
expect(
await app.handle(req('/?key1=ab&z=が')).then((x) => x.status)
).toEqual(200)
expect(
await app.handle(req('/?key1=ab&key1=cd&z=x')).then((x) => x.status)
).toEqual(200)
expect(
await app
.handle(req('/?z=が&key1=ab&key1=cd'))
.then((x) => x.status)
).toEqual(200)
expect(
await app.handle(req('/?key1=で&key1=が&z=x')).then((x) => x.status)
).toEqual(200)
})
// https://github.com/elysiajs/elysia/issues/912
it('handle JavaScript date numeric offset', () => {
const api = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
date: t.Date()
})
})
api.handle(req(`/?date=${Date.now()}`)).then((x) => x.json())
})
it('handle nuqs format when specified as Array', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
a: t.Array(t.String())
})
})
const response = await app.handle(req('/?a=a,b')).then((x) => x.json())
expect(response).toEqual({
a: ['a', 'b']
})
})
it('handle nuqs format when specified as number', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
a: t.Array(t.Numeric())
})
})
const response = await app.handle(req('/?a=1,2')).then((x) => x.json())
expect(response).toEqual({
a: [1, 2]
})
})
it('handle nuqs format when specified as boolean', async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
a: t.Array(t.BooleanString())
})
})
const response = await app
.handle(req('/?a=true,false'))
.then((x) => x.json())
expect(response).toEqual({
a: [true, false]
})
})
// https://github.com/elysiajs/elysia/issues/1015
it('handle ref transform', async () => {
const app = new Elysia()
.model({
myModel: t.Object({ num: t.Number() })
})
.get(
'/',
({ query: { num } }) => ({
num,
type: typeof num
}),
{
query: 'myModel'
}
)
const response = await app
.handle(new Request('http://localhost?num=1&a=2'))
.then((x) => x.json())
expect(response).toEqual({ num: 1, type: 'number' })
})
// https://github.com/elysiajs/elysia/issues/1068
it('handle ref transform with ref inside reference model', async () => {
const app = new Elysia()
.model({
num2: t.Numeric(),
myModel: t.Object({ num: t.Numeric(), num2: t.Ref('num2') })
})
.get(
'/',
({ query: { num, num2 } }) => ({
num,
numType: typeof num,
num2,
num2Type: typeof num2
}),
{
query: 'myModel'
}
)
const response = await app
.handle(new Request('http://localhost?num=1&num2=2'))
.then((x) => x.json())
expect(response).toEqual({
num: 1,
numType: 'number',
num2: 2,
num2Type: 'number'
})
})
it('handle "&" inside a query value', async () => {
const app = new Elysia().get(
'*',
({ query, request }) => ({
query,
url: {
test: new URL(request.url).searchParams.get('test')
}
}),
{
query: t.Object({
test: t.String()
})
}
)
const url = "https://localhost/?test=Test1%20%26%20Test2'"
const value = await app.handle(new Request(url)).then((x) => x.json())
expect(value).toEqual({
query: {
test: "Test1 & Test2'"
},
url: {
test: new URL(url).searchParams.get('test')
}
})
})
it('handle array string correctly', async () => {
const app = new Elysia({ precompile: true }).get(
'/',
({ query }) => query,
{
query: t.Object({
status: t.Optional(t.Array(t.String()))
})
}
)
const response = await Promise.all([
app.handle(req('/?')).then((x) => x.json()),
app.handle(req('/?status=a')).then((x) => x.json()),
app.handle(req('/?status=a&status=b')).then((x) => x.json())
])
expect(response).toEqual([
{},
{ status: ['a'] },
{ status: ['a', 'b'] }
])
})
it('handle Transform query', async () => {
const app = new Elysia().get(
'/test',
({ query: { id } }) => ({
id,
type: typeof id
}),
{
query: t.Object({
id: t
.Transform(t.UnionEnum(['test', 'foo']))
.Decode((id) => ({ value: id }))
.Encode((id) => id.value)
})
}
)
const response = await app
.handle(req('/test?id=test'))
.then((x) => x.json())
expect(response).toEqual({
id: {
value: 'test'
},
type: 'object'
})
})
it('handle Date query', async () => {
const app = new Elysia().get(
'/',
({ query: { date } }) => date.toISOString(),
{
query: t.Object({
date: t.Date()
})
}
)
const response = await app
.handle(req(`/?date=2023-04-05T12:30:00+01:00`))
.then((x) => x.text())
expect(response).toEqual('2023-04-05T11:30:00.000Z')
})
it('handle coerce TransformDecodeError', async () => {
let err: Error | undefined
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
year: t.Numeric({ minimum: 1900, maximum: 2160 })
}),
error({ code, error }) {
switch (code) {
case 'VALIDATION':
err = error
}
}
})
await app.handle(req('?year=3000'))
expect(err instanceof ValidationError).toBe(true)
})
it('handle reference query array', async () => {
const app = new Elysia()
.model({
ids: t.Object({
ids: t.Array(t.Union([t.String(), t.ArrayString()]))
})
})
.get('/', ({ query }) => query, {
query: 'ids'
})
const response = await app
.handle(req('?ids=1,2,3'))
.then((x) => x.json())
expect(response).toEqual({
ids: ['1', '2', '3']
})
})
it('validate url encoded query', () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
test: t.Optional(t.Number()),
$test: t.Optional(t.Number())
})
})
const value = app
.handle(new Request('http://localhost?test=1&%24test=2'))
.then((x) => x.json())
expect(value).resolves.toEqual({
test: 1,
$test: 2
})
})
it("don't populate object query on failed validation", async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
filter: t.Object({
latlng: t.Object({
within: t.Object({
ne: t.Number(),
sw: t.Number()
})
}),
zoom: t.Object({
equalTo: t.Number({
minimum: 0,
maximum: 20,
multipleOf: 1
})
})
})
})
})
const filter = JSON.stringify({
latlng: {
within: {
ne: 1,
sw: 1
}
},
zoom: {
equalTo: 2
}
})
const valid = await app.handle(
new Request(`http://localhost:3000/?filter=${filter}`)
)
const invalid1 = await app.handle(new Request(`http://localhost:3000`))
const invalid2 = await app.handle(
new Request(
`http://localhost:3000?filter=${JSON.stringify({ zoom: { equalTo: 21 } })}`
)
)
expect(valid.status).toBe(200)
expect(invalid1.status).toBe(422)
expect(invalid2.status).toBe(422)
})
it("don't populate array query on failed validation", async () => {
const app = new Elysia().get('/', ({ query }) => query, {
query: t.Object({
party: t.Array(
t.Object({
name: t.String()
})
)
})
})
const filter = JSON.stringify([
{
name: 'lilith'
},
{
name: 'fouco'
}
])
const valid = await app.handle(
new Request(`http://localhost:3000/?party=${filter}`)
)
const invalid1 = await app.handle(new Request(`http://localhost:3000`))
const invalid2 = await app.handle(
new Request(`http://localhost:3000?filter=[]`)
)
expect(valid.status).toBe(200)
expect(invalid1.status).toBe(422)
expect(invalid2.status).toBe(422)
})
})