Skip to content

Commit f260821

Browse files
committed
test(validation): add coverage for validation feature
- Add Reason status-mapping tests for 422 and passthrough cases - Add Source extraction tests per request source - Add Validator middleware and standalone helper tests
1 parent 1ee44c5 commit f260821

4 files changed

Lines changed: 331 additions & 0 deletions

File tree

tests/middleware/Validator.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { assertEquals, assertThrows } from '@std/assert'
2+
import * as Core from '@core/index.ts'
3+
import * as Middleware from '@middleware/index.ts'
4+
import * as Validation from '@validation/index.ts'
5+
import { Define } from '@neabyte/typebox'
6+
7+
function createTestContext(url = 'http://localhost/', requestInit?: RequestInit): Core.Context {
8+
const request = new Request(url, requestInit)
9+
return new Core.Context(request, new URL(url), {})
10+
}
11+
12+
function jsonRequest(body: unknown, url = 'http://localhost/'): Core.Context {
13+
return createTestContext(url, {
14+
method: 'POST',
15+
headers: new Headers({ 'Content-Type': 'application/json' }),
16+
body: JSON.stringify(body)
17+
})
18+
}
19+
20+
Deno.test('Validate contract throw maps to 422 (client input never causes 500)', async () => {
21+
const schema = {
22+
json: Define((input: { value: number }) => {
23+
if (input.value === 0) {
24+
throw new RangeError('value cannot be zero')
25+
}
26+
return input
27+
})
28+
}
29+
const middleware = Middleware.Mware.validator(schema)
30+
const ctx = jsonRequest({ value: 0 })
31+
const next = async (): Promise<Response> => new Response('ok')
32+
const res = await middleware(ctx, next)
33+
assertEquals(res !== undefined, true)
34+
if (res) {
35+
assertEquals(res.status, 422)
36+
}
37+
})
38+
39+
Deno.test('Validate empty schema throws at build time', () => {
40+
assertThrows(() => Middleware.Mware.validator({}), Deno.errors.InvalidData)
41+
})
42+
43+
Deno.test('Validate failing guard returns 422 with reasons', async () => {
44+
const schema = {
45+
json: Define(
46+
(input: { age: number }) => input,
47+
(input) => (input.age >= 18 ? true : 'age must be at least 18')
48+
)
49+
}
50+
const middleware = Middleware.Mware.validator(schema)
51+
const ctx = jsonRequest({ age: 12 })
52+
const next = async (): Promise<Response> => new Response('ok')
53+
const res = await middleware(ctx, next)
54+
assertEquals(res !== undefined, true)
55+
if (res) {
56+
assertEquals(res.status, 422)
57+
}
58+
})
59+
60+
Deno.test('Validate multi-source schema validates each part', async () => {
61+
const schema = {
62+
json: Define((input: { name: string }) => input),
63+
query: Define((input: Record<string, string>) => input)
64+
}
65+
const middleware = Middleware.Mware.validator(schema)
66+
const ctx = jsonRequest({ name: 'neo' }, 'http://localhost/?page=1')
67+
let name = ''
68+
let page = ''
69+
const next = async (): Promise<Response> => {
70+
const { json, query } = Validation.Validator.read<typeof schema>(ctx)
71+
name = json.name
72+
page = query['page'] ?? ''
73+
return new Response('ok')
74+
}
75+
await middleware(ctx, next)
76+
assertEquals(name, 'neo')
77+
assertEquals(page, '1')
78+
})
79+
80+
Deno.test('Validate null JSON body against object guard maps to 422 not 500', async () => {
81+
const schema = {
82+
json: Define(
83+
(body: { name: string }) => ({ name: body.name.trim() }),
84+
(body) => (typeof body.name === 'string' && body.name.length > 0 ? true : 'name required')
85+
)
86+
}
87+
const middleware = Middleware.Mware.validator(schema)
88+
const ctx = jsonRequest(null)
89+
const next = async (): Promise<Response> => new Response('ok')
90+
const res = await middleware(ctx, next)
91+
assertEquals(res !== undefined, true)
92+
if (res) {
93+
assertEquals(res.status, 422)
94+
}
95+
})
96+
97+
Deno.test('Validate passing guard stores typed data on context', async () => {
98+
const schema = {
99+
json: Define(
100+
(input: { name: string; age: number }) => input,
101+
(input) => (input.age >= 18 ? true : 'age must be at least 18')
102+
)
103+
}
104+
const middleware = Middleware.Mware.validator(schema)
105+
const ctx = jsonRequest({ name: 'neo', age: 30 })
106+
let name = ''
107+
const next = async (): Promise<Response> => {
108+
const { json } = Validation.Validator.read<typeof schema>(ctx)
109+
name = json.name
110+
return new Response('ok')
111+
}
112+
const res = await middleware(ctx, next)
113+
assertEquals(name, 'neo')
114+
assertEquals(res !== undefined, true)
115+
if (res) {
116+
assertEquals(await res.text(), 'ok')
117+
}
118+
})
119+
120+
Deno.test('Validate stacked validators merge validated sources', async () => {
121+
const querySchema = { query: Define((input: Record<string, string>) => input) }
122+
const jsonSchema = { json: Define((input: { name: string }) => input) }
123+
const ctx = jsonRequest({ name: 'neo' }, 'http://localhost/?id=7')
124+
const passThrough = async (): Promise<Response> => new Response('ok')
125+
await Middleware.Mware.validator(querySchema)(ctx, passThrough)
126+
let queryId = ''
127+
let jsonName = ''
128+
await Middleware.Mware.validator(jsonSchema)(ctx, async () => {
129+
const merged = Validation.Validator.read<typeof querySchema & typeof jsonSchema>(ctx)
130+
queryId = merged.query['id'] ?? ''
131+
jsonName = merged.json.name
132+
return new Response('ok')
133+
})
134+
assertEquals(queryId, '7')
135+
assertEquals(jsonName, 'neo')
136+
})
137+
138+
Deno.test('Validator rejects a params source in middleware at registration', () => {
139+
const paramsSchema = { params: Define((input: Record<string, string>) => input) }
140+
assertThrows(
141+
() => Middleware.Mware.validator(paramsSchema),
142+
Deno.errors.InvalidData,
143+
'Validator.check(contract, ctx.params())'
144+
)
145+
})
146+
147+
Deno.test('Validator throws when no validation ran', () => {
148+
const ctx = createTestContext()
149+
assertThrows(() => Validation.Validator.read(ctx), Error)
150+
})

tests/validation/Reason.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { assertEquals } from '@std/assert'
2+
import * as Core from '@core/index.ts'
3+
import * as Validation from '@validation/index.ts'
4+
5+
Deno.test('Reason.toStatusError empty cause array uses generic message', () => {
6+
const error = new Error('original')
7+
error.cause = []
8+
const statusError = Validation.Reason.toStatusError(error)
9+
assertEquals(statusError.statusCode, 422)
10+
assertEquals(statusError.message, 'Validation failed')
11+
assertEquals(statusError.cause, [])
12+
})
13+
14+
Deno.test('Reason.toStatusError filters non-string cause members', () => {
15+
const error = new Error('original')
16+
error.cause = ['valid', 1, null, 'also valid']
17+
const statusError = Validation.Reason.toStatusError(error)
18+
assertEquals(statusError.cause, ['valid', 'also valid'])
19+
assertEquals(statusError.message, 'valid; also valid')
20+
})
21+
22+
Deno.test('Reason.toStatusError joins string reasons into message', () => {
23+
const error = new Error('original')
24+
error.cause = ['name required', 'age too low']
25+
const statusError = Validation.Reason.toStatusError(error)
26+
assertEquals(statusError.statusCode, 422)
27+
assertEquals(statusError.message, 'name required; age too low')
28+
assertEquals(statusError.cause, ['name required', 'age too low'])
29+
})
30+
31+
Deno.test('Reason.toStatusError maps non-error value to generic 422', () => {
32+
const statusError = Validation.Reason.toStatusError('not an error')
33+
assertEquals(statusError.statusCode, 422)
34+
assertEquals(statusError.message, 'Unprocessable request input')
35+
})
36+
37+
Deno.test('Reason.toStatusError maps plain error to generic 422 without cause', () => {
38+
const error = new Error('Cannot read properties of null')
39+
const statusError = Validation.Reason.toStatusError(error)
40+
assertEquals(statusError.statusCode, 422)
41+
assertEquals(statusError.message, 'Unprocessable request input')
42+
assertEquals('cause' in statusError && Array.isArray(statusError.cause), false)
43+
})
44+
45+
Deno.test('Reason.toStatusError passes through an error that already carries a status', () => {
46+
const original = Core.Handler.createStatusError(400, 'Malformed or unreadable request body')
47+
const statusError = Validation.Reason.toStatusError(original)
48+
assertEquals(statusError, original)
49+
assertEquals(statusError.statusCode, 400)
50+
})
51+
52+
Deno.test('Reason.toStatusError sets non-enumerable cause', () => {
53+
const error = new Error('original')
54+
error.cause = ['reason']
55+
const statusError = Validation.Reason.toStatusError(error)
56+
assertEquals(Object.prototype.propertyIsEnumerable.call(statusError, 'cause'), false)
57+
})
58+
59+
Deno.test('Reason.toStatusError uses error pipeline status carrier', () => {
60+
const error = new Error('original')
61+
error.cause = ['reason']
62+
const statusError = Validation.Reason.toStatusError(error)
63+
assertEquals(Core.Handler.isErrorWithStatus(statusError), true)
64+
})

tests/validation/Source.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { assertEquals } from '@std/assert'
2+
import * as Core from '@core/index.ts'
3+
import * as Validation from '@validation/index.ts'
4+
5+
function createTestContext(url = 'http://localhost/', requestInit?: RequestInit): Core.Context {
6+
const request = new Request(url, requestInit)
7+
return new Core.Context(request, new URL(url), { id: '42' })
8+
}
9+
10+
Deno.test('Source.extract body returns parsed text body', async () => {
11+
const ctx = createTestContext('http://localhost/', { method: 'POST', body: 'hello' })
12+
const result = await Validation.Source.extract('body', ctx)
13+
assertEquals(result, 'hello')
14+
})
15+
16+
Deno.test('Source.extract cookies returns parsed cookie record', async () => {
17+
const ctx = createTestContext('http://localhost/', {
18+
headers: new Headers({ cookie: 'a=1; b=2' })
19+
})
20+
const result = await Validation.Source.extract('cookies', ctx)
21+
assertEquals(result, { a: '1', b: '2' })
22+
})
23+
24+
Deno.test('Source.extract headers returns header record', async () => {
25+
const ctx = createTestContext('http://localhost/', {
26+
headers: new Headers({ 'x-test': 'value' })
27+
})
28+
const result = (await Validation.Source.extract('headers', ctx)) as Record<string, string>
29+
assertEquals(result['x-test'], 'value')
30+
})
31+
32+
Deno.test('Source.extract json returns parsed json body', async () => {
33+
const ctx = createTestContext('http://localhost/', {
34+
method: 'POST',
35+
headers: new Headers({ 'Content-Type': 'application/json' }),
36+
body: JSON.stringify({ name: 'neo' })
37+
})
38+
const result = await Validation.Source.extract('json', ctx)
39+
assertEquals(result, { name: 'neo' })
40+
})
41+
42+
Deno.test('Source.extract params returns route params', async () => {
43+
const ctx = createTestContext()
44+
const result = await Validation.Source.extract('params', ctx)
45+
assertEquals(result, { id: '42' })
46+
})
47+
48+
Deno.test('Source.extract query returns query record', async () => {
49+
const ctx = createTestContext('http://localhost/?page=1&size=10')
50+
const result = await Validation.Source.extract('query', ctx)
51+
assertEquals(result, { page: '1', size: '10' })
52+
})

tests/validation/Validator.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { assertEquals, assertThrows } from '@std/assert'
2+
import * as Core from '@core/index.ts'
3+
import * as Validation from '@validation/index.ts'
4+
import { Define } from '@neabyte/typebox'
5+
6+
function createTestContext(): Core.Context {
7+
const request = new Request('http://localhost/')
8+
return new Core.Context(request, new URL('http://localhost/'), {})
9+
}
10+
11+
const schema = {
12+
json: Define((input: { name: string }) => input)
13+
}
14+
15+
Deno.test('Validator.check returns contract output on pass', () => {
16+
const contract = Define(
17+
(input: { id: string }) => ({ id: Number(input.id) }),
18+
(input) => (/^\d+$/.test(input.id) ? true : 'id must be numeric')
19+
)
20+
const result = Validation.Validator.check(contract, { id: '42' })
21+
assertEquals(result.id, 42)
22+
})
23+
24+
Deno.test('Validator.check throws 422 on guard failure', () => {
25+
const contract = Define(
26+
(input: { id: string }) => ({ id: Number(input.id) }),
27+
(input) => (/^\d+$/.test(input.id) ? true : 'id must be numeric')
28+
)
29+
try {
30+
Validation.Validator.check(contract, { id: 'abc' })
31+
throw new Error('expected throw')
32+
} catch (error) {
33+
assertEquals(Core.Handler.isErrorWithStatus(error), true)
34+
if (Core.Handler.isErrorWithStatus(error)) {
35+
assertEquals(error.statusCode, 422)
36+
}
37+
}
38+
})
39+
40+
Deno.test('Validator.read returns stored validated data', () => {
41+
const ctx = createTestContext()
42+
ctx[Core.InternalContext].setInternalState(Core.Handler.stateKeys.validated, {
43+
json: { name: 'neo' }
44+
})
45+
const result = Validation.Validator.read<typeof schema>(ctx)
46+
assertEquals(result.json.name, 'neo')
47+
})
48+
49+
Deno.test('Validator.read throws 500 when no validation ran', () => {
50+
const ctx = createTestContext()
51+
assertThrows(() => Validation.Validator.read(ctx), Error, 'No validated data found')
52+
})
53+
54+
Deno.test('Validator.read throws carries 500 status code', () => {
55+
const ctx = createTestContext()
56+
try {
57+
Validation.Validator.read(ctx)
58+
throw new Error('expected throw')
59+
} catch (error) {
60+
assertEquals(Core.Handler.isErrorWithStatus(error), true)
61+
if (Core.Handler.isErrorWithStatus(error)) {
62+
assertEquals(error.statusCode, 500)
63+
}
64+
}
65+
})

0 commit comments

Comments
 (0)