Files
elysia/test/validator/response.test.ts

566 lines
11 KiB
TypeScript

import { Elysia, t, sse } from '../../src'
import { streamResponse } from '../../src/adapter/utils'
import * as z from 'zod'
import { describe, expect, it } from 'bun:test'
import { post, req, upload } from '../utils'
describe('Response Validator', () => {
it('validate primitive', async () => {
const app = new Elysia().get('/', () => 'sucrose', {
response: t.String()
})
const res = await app.handle(req('/'))
expect(await res.text()).toBe('sucrose')
expect(res.status).toBe(200)
})
it('validate number', async () => {
const app = new Elysia().get('/', () => 1, {
response: t.Number()
})
const res = await app.handle(req('/'))
expect(await res.text()).toBe('1')
expect(res.status).toBe(200)
})
it('validate boolean', async () => {
const app = new Elysia().get('/', () => true, {
response: t.Boolean()
})
const res = await app.handle(req('/'))
expect(await res.text()).toBe('true')
expect(res.status).toBe(200)
})
it('validate literal', async () => {
const app = new Elysia().get('/', () => 'A' as const, {
response: t.Literal('A')
})
const res = await app.handle(req('/'))
expect(await res.text()).toBe('A')
expect(res.status).toBe(200)
})
it('validate single', async () => {
const app = new Elysia().get(
'/',
() => ({
name: 'sucrose'
}),
{
response: t.Object({
name: t.String()
})
}
)
const res = await app.handle(req('/'))
expect(await res.json()).toEqual({ name: 'sucrose' })
expect(res.status).toBe(200)
})
it('validate multiple', async () => {
const app = new Elysia().get(
'/',
() => ({
name: 'sucrose',
job: 'alchemist',
trait: 'dog'
}),
{
response: t.Object({
name: t.String(),
job: t.String(),
trait: t.String()
})
}
)
const res = await app.handle(req('/'))
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(
'/',
() => ({
name: 'sucrose',
job: 'alchemist',
trait: 'dog'
}),
{
response: t.Object({
name: t.String(),
job: t.String(),
trait: t.String()
})
}
)
const res = await app.handle(req('/'))
expect(res.status).toBe(200)
})
it('validate optional', async () => {
const app = new Elysia().get(
'/',
() => ({
name: 'sucrose',
job: 'alchemist'
}),
{
response: t.Object({
name: t.String(),
job: t.String(),
trait: t.Optional(t.String())
})
}
)
const res = await app.handle(req('/'))
expect(await res.json()).toEqual({
name: 'sucrose',
job: 'alchemist'
})
expect(res.status).toBe(200)
})
it('allow undefined', async () => {
const app = new Elysia().get('/', () => {}, {
body: t.Union([
t.Undefined(),
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.text()).toBe('')
})
it('normalize by default', async () => {
const app = new Elysia().get(
'/',
() => ({
name: 'sucrose',
job: 'alchemist'
}),
{
response: t.Object({
name: t.String()
})
}
)
const res = await app.handle(req('/')).then((x) => x.json())
expect(res).toEqual({
name: 'sucrose'
})
})
it('strictly validate if not normalize', async () => {
const app = new Elysia({ normalize: false }).get(
'/',
() => ({
name: 'sucrose',
job: 'alchemist'
}),
{
response: {
200: t.Object({
name: t.String()
})
}
}
)
const res = await app.handle(req('/'))
expect(res.status).toBe(422)
})
it('handle File', async () => {
const app = new Elysia().post('/', ({ body: { file } }) => file.size, {
body: t.Object({
file: t.File()
})
})
expect(
await app
.handle(
upload('/', {
file: 'aris-yuzu.jpg'
}).request
)
.then((x) => x.text())
).toBe(Bun.file('./test/images/aris-yuzu.jpg').size + '')
})
it('convert File to Files automatically', async () => {
const app = new Elysia().post(
'/',
({ body: { files } }) => Array.isArray(files),
{
body: t.Object({
files: t.Files()
})
}
)
expect(
await app
.handle(
upload('/', {
files: 'aris-yuzu.jpg'
}).request
)
.then((x) => x.text())
).toEqual('true')
expect(
await app
.handle(
upload('/', {
files: ['aris-yuzu.jpg', 'midori.png']
}).request
)
.then((x) => x.text())
).toEqual('true')
})
it('validate response per status', async () => {
const app = new Elysia().post(
'/',
({ set, body: { status, response } }) => {
set.status = status
return response
},
{
body: t.Object({
status: t.Number(),
response: t.Any()
}),
response: {
200: t.String(),
201: t.Number()
}
}
)
const r200valid = await app.handle(
post('/', {
status: 200,
response: 'String'
})
)
const r200invalid = await app.handle(
post('/', {
status: 200,
response: 1
})
)
const r201valid = await app.handle(
post('/', {
status: 201,
response: 1
})
)
const r201invalid = await app.handle(
post('/', {
status: 201,
response: 'String'
})
)
expect(r200valid.status).toBe(200)
expect(r200invalid.status).toBe(422)
expect(r201valid.status).toBe(201)
expect(r201invalid.status).toBe(422)
})
it('validate response per status with error()', async () => {
const app = new Elysia().get(
'/',
({ status }) => status(418, 'I am a teapot'),
{
response: {
200: t.String(),
418: t.String()
}
}
)
})
it('use inline error from handler', async () => {
const app = new Elysia().get(
'/',
({ status }) => status(418, 'I am a teapot'),
{
response: {
200: t.String(),
418: t.String()
}
}
)
})
it('return null with schema', async () => {
const app = new Elysia().get('/', () => null, {
response: t.Union([
t.Null(),
t.Object({
name: t.String()
})
])
})
})
it('return undefined with schema', async () => {
const app = new Elysia().get('/', () => undefined, {
response: t.Union([
t.Undefined(),
t.Object({
name: t.String()
})
])
})
})
it('return void with schema', async () => {
const app = new Elysia().get('/', () => undefined, {
response: t.Union([
t.Void(),
t.Object({
name: t.String()
})
])
})
})
it('return null with status based schema', async () => {
const app = new Elysia().get('/', () => undefined, {
response: {
200: t.Union([
t.Void(),
t.Object({
name: t.String()
})
]),
418: t.String()
}
})
})
it('return static undefined with status based schema', async () => {
const app = new Elysia().get('/', undefined, {
response: {
200: t.Union([
t.Void(),
t.Object({
name: t.String()
})
]),
418: t.String()
}
})
})
it('return error response with validator', async () => {
const app = new Elysia()
.get('/ok', () => 'ok', {
response: {
200: t.String(),
418: t.Literal('Kirifuji Nagisa'),
420: t.Literal('Snoop Dogg')
}
})
.get(
'/error',
({ status }) => status("I'm a teapot", 'Kirifuji Nagisa'),
{
response: {
200: t.String(),
418: t.Literal('Kirifuji Nagisa'),
420: t.Literal('Snoop Dogg')
}
}
)
.get(
'/validate-error',
// @ts-ignore
({ status }) => status("I'm a teapot", 'Nagisa'),
{
response: {
200: t.String(),
418: t.Literal('Kirifuji Nagisa'),
420: t.Literal('Snoop Dogg')
}
}
)
const response = await Promise.all([
app.handle(req('/ok')).then((x) => x.status),
app.handle(req('/error')).then((x) => x.status),
app.handle(req('/validate-error')).then((x) => x.status)
])
expect(response).toEqual([200, 418, 422])
})
it('validate nested references', async () => {
const job = t.Object(
{
name: t.String()
},
{ $id: 'job' }
)
const person = t.Object({
name: t.String(),
job: t.Ref(job)
})
const app = new Elysia().model({ job, person }).get(
'/',
() => ({
name: 'sucrose',
job: { name: 'alchemist' }
}),
{
response: person
}
)
const res = await app.handle(req('/'))
expect(res.status).toBe(200)
})
it('validate SSE response with generator', async () => {
const app = new Elysia().get(
'/',
function* () {
yield sse({ data: { name: 'Alice' } })
yield sse({ data: { name: 'Bob' } })
},
{
response: t.Object({
data: t.Object({
name: t.String()
})
})
}
)
const res = await app.handle(req('/'))
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('text/event-stream')
// Verify the stream contains the expected SSE data
const result = []
for await (const chunk of streamResponse(res)) {
result.push(chunk)
}
expect(result.join('')).toContain('data: {"name":"Alice"}')
expect(result.join('')).toContain('data: {"name":"Bob"}')
})
it('validate async SSE response with generator', async () => {
const app = new Elysia().get(
'/',
async function* () {
yield sse({ data: { name: 'Charlie' } })
await Bun.sleep(1)
yield sse({ data: { name: 'Diana' } })
},
{
response: t.Object({
data: t.Object({
name: t.String()
})
})
}
)
const res = await app.handle(req('/'))
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('text/event-stream')
})
it('validate streaming response with generator', async () => {
const app = new Elysia().get(
'/',
function* () {
yield { message: 'first' }
yield { message: 'second' }
},
{
response: t.Object({
message: t.String()
})
}
)
const res = await app.handle(req('/'))
expect(res.status).toBe(200)
const result = []
for await (const chunk of streamResponse(res)) {
result.push(chunk)
}
expect(result.join('')).toContain('"message":"first"')
expect(result.join('')).toContain('"message":"second"')
})
it('validate SSE with Zod schema (bug report scenario)', async () => {
// This test reproduces the exact bug report:
// https://github.com/elysiajs/elysia/issues/1490
const Schema = z.object({
data: z.object({
name: z.string()
})
})
const app = new Elysia().get(
'/',
function* () {
yield sse({ data: { name: 'Name' } })
},
{ response: Schema }
)
const res = await app.handle(req('/'))
// Should not throw validation error
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('text/event-stream')
// Verify the stream contains the expected SSE data
const result = []
for await (const chunk of streamResponse(res)) {
result.push(chunk)
}
expect(result.join('')).toContain('data: {"name":"Name"}')
})
})