mockaton 11.2.5 → 11.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
1
1
  import { join } from 'node:path'
2
2
  import { randomUUID } from 'node:crypto'
3
3
 
4
- import { config } from './config.js'
5
4
  import { extFor } from './utils/mime.js'
6
5
  import { write, isFile } from './utils/fs.js'
6
+ import { readBody, BodyReaderError } from './utils/HttpIncomingMessage.js'
7
+
8
+ import { config } from './config.js'
7
9
  import { makeMockFilename } from './Filename.js'
8
- import { readBody, BodyReaderError } from './utils/http-request.js'
9
- import { sendUnprocessable, sendBadGateway } from './utils/http-response.js'
10
10
 
11
11
 
12
12
  export async function proxy(req, response, delay) {
@@ -22,9 +22,9 @@ export async function proxy(req, response, delay) {
22
22
  }
23
23
  catch (error) { // TESTME
24
24
  if (error instanceof BodyReaderError)
25
- sendUnprocessable(response, error.name)
25
+ response.unprocessable(error.name)
26
26
  else
27
- sendBadGateway(response, error)
27
+ response.badGateway(error)
28
28
  return
29
29
  }
30
30
 
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs'
4
4
  import { isFile } from './utils/fs.js'
5
5
  import { logger } from './utils/logger.js'
6
6
  import { mimeFor } from './utils/mime.js'
7
+
7
8
  import { brokerByRoute } from './staticCollection.js'
8
9
  import { config, calcDelay } from './config.js'
9
- import { sendMockNotFound, sendPartialContent } from './utils/http-response.js'
10
10
 
11
11
 
12
12
  // TODO HEAD
@@ -15,19 +15,19 @@ export async function dispatchStatic(req, response) {
15
15
 
16
16
  setTimeout(async () => {
17
17
  if (!broker || broker.status === 404) {
18
- sendMockNotFound(response)
18
+ response.mockNotFound()
19
19
  return
20
20
  }
21
21
 
22
22
  const file = join(config.staticDir, broker.route)
23
23
  if (!isFile(file)) {
24
- sendMockNotFound(response)
24
+ response.mockNotFound()
25
25
  return
26
26
  }
27
27
 
28
28
  logger.accessMock(req.url, 'static200')
29
29
  if (req.headers.range)
30
- await sendPartialContent(response, req.headers.range, file)
30
+ await response.partialContent(req.headers.range, file)
31
31
  else {
32
32
  response.setHeader('Content-Type', mimeFor(file))
33
33
  response.end(readFileSync(file))
@@ -2,19 +2,25 @@ import { join } from 'node:path'
2
2
  import { watch } from 'node:fs'
3
3
  import { EventEmitter } from 'node:events'
4
4
 
5
+ import {
6
+ HEADER_SYNC_VERSION,
7
+ LONG_POLL_SERVER_TIMEOUT
8
+ } from './ApiConstants.js'
9
+
5
10
  import { config } from './config.js'
6
11
  import { isFile, isDirectory } from './utils/fs.js'
12
+
7
13
  import * as staticCollection from './staticCollection.js'
8
14
  import * as mockBrokerCollection from './mockBrokersCollection.js'
9
15
 
10
16
 
11
17
  /**
12
- * ARR = Add, Remove, or Rename Mock Event
18
+ * ARR Event = Add, Remove, or Rename Mock
13
19
  *
14
20
  * The emitter is debounced so it handles e.g. bulk deletes,
15
21
  * and also renames, which are two events (delete + add).
16
22
  */
17
- export const uiSyncVersion = new class extends EventEmitter {
23
+ const uiSyncVersion = new class extends EventEmitter {
18
24
  delay = Number(process.env.MOCKATON_WATCHER_DEBOUNCE_MS ?? 80)
19
25
  version = 0
20
26
 
@@ -39,6 +45,7 @@ export const uiSyncVersion = new class extends EventEmitter {
39
45
  }
40
46
  }
41
47
 
48
+
42
49
  export function watchMocksDir() {
43
50
  const dir = config.mocksDir
44
51
  watch(dir, { recursive: true, persistent: false }, (_, file) => {
@@ -61,6 +68,7 @@ export function watchMocksDir() {
61
68
  })
62
69
  }
63
70
 
71
+
64
72
  export function watchStaticDir() {
65
73
  const dir = config.staticDir
66
74
  if (!dir)
@@ -86,4 +94,23 @@ export function watchStaticDir() {
86
94
  })
87
95
  }
88
96
 
89
- // TODO ThinkAbout watching for config changes
97
+
98
+ /** Realtime notify ARR Events */
99
+ export function longPollClientSyncVersion(req, response) {
100
+ const clientVersion = req.headers[HEADER_SYNC_VERSION]
101
+ if (clientVersion !== undefined && uiSyncVersion.version !== Number(clientVersion)) {
102
+ // e.g., tab was hidden while new mocks were added or removed
103
+ response.json(uiSyncVersion.version)
104
+ return
105
+ }
106
+ function onARR() {
107
+ uiSyncVersion.unsubscribe(onARR)
108
+ response.json(uiSyncVersion.version)
109
+ }
110
+ response.setTimeout(LONG_POLL_SERVER_TIMEOUT, onARR)
111
+ req.on('error', () => {
112
+ uiSyncVersion.unsubscribe(onARR)
113
+ response.destroy()
114
+ })
115
+ uiSyncVersion.subscribe(onARR)
116
+ }
@@ -1,17 +1,49 @@
1
- import { watch } from 'node:fs'
1
+ import { join } from 'node:path'
2
2
  import { EventEmitter } from 'node:events'
3
+ import { watch, readdirSync } from 'node:fs'
4
+ import { LONG_POLL_SERVER_TIMEOUT } from './ApiConstants.js'
3
5
 
4
6
 
5
- export const devClientWatcher = new class extends EventEmitter {
7
+ const DEV = process.env.NODE_ENV === 'development'
8
+
9
+ export const CLIENT_DIR = join(import.meta.dirname, '../client')
10
+ export const DASHBOARD_ASSETS = readdirSync(CLIENT_DIR)
11
+
12
+
13
+ const devClientWatcher = new class extends EventEmitter {
6
14
  emit(file) { super.emit('RELOAD', file) }
7
15
  subscribe(listener) { this.once('RELOAD', listener) }
8
16
  unsubscribe(listener) { this.removeListener('RELOAD', listener) }
9
17
  }
10
18
 
11
- // DashboardHtml.js is not watched.
12
- // It would need dynamic import + cache busting
19
+
20
+ // Although `client/indexHtml.js` is watched, it returns a stale version.
21
+ // i.e., it would need to be a dynamic import + cache busting.
13
22
  export function watchDevSPA() {
14
- watch('src/client', (_, file) => {
23
+ watch(CLIENT_DIR, (_, file) => {
15
24
  devClientWatcher.emit(file)
16
25
  })
17
26
  }
27
+
28
+
29
+ /** Realtime notify Dev UI changes */
30
+ export function longPollDevClientHotReload(req, response) {
31
+ if (!DEV) {
32
+ response.notFound()
33
+ return
34
+ }
35
+
36
+ function onDevChange(file) {
37
+ devClientWatcher.unsubscribe(onDevChange)
38
+ response.json(file)
39
+ }
40
+ response.setTimeout(LONG_POLL_SERVER_TIMEOUT, () => {
41
+ devClientWatcher.unsubscribe(onDevChange)
42
+ response.json('')
43
+ })
44
+ req.on('error', () => {
45
+ devClientWatcher.unsubscribe(onDevChange)
46
+ response.destroy()
47
+ })
48
+ devClientWatcher.subscribe(onDevChange)
49
+ }
package/src/server/cli.js CHANGED
@@ -5,6 +5,7 @@ import { parseArgs } from 'node:util'
5
5
 
6
6
  import { isFile } from './utils/fs.js'
7
7
  import { Mockaton } from '../../index.js'
8
+
8
9
  import pkgJSON from '../../package.json' with { type: 'json' }
9
10
 
10
11
 
@@ -3,11 +3,12 @@ import { resolve } from 'node:path'
3
3
  import { logger } from './utils/logger.js'
4
4
  import { isDirectory } from './utils/fs.js'
5
5
  import { openInBrowser } from './utils/openInBrowser.js'
6
- import { jsToJsonPlugin } from './MockDispatcher.js'
7
6
  import { optional, is, validate } from './utils/validate.js'
8
- import { SUPPORTED_METHODS } from './utils/http-request.js'
7
+ import { SUPPORTED_METHODS } from './utils/HttpIncomingMessage.js'
9
8
  import { validateCorsAllowedMethods, validateCorsAllowedOrigins } from './utils/http-cors.js'
10
9
 
10
+ import { jsToJsonPlugin } from './MockDispatcher.js'
11
+
11
12
 
12
13
  /** @type {{
13
14
  * [K in keyof Config]-?: [
@@ -1,9 +1,10 @@
1
1
  import { basename } from 'node:path'
2
2
 
3
3
  import { logger } from './utils/logger.js'
4
+ import { listFilesRecursively } from './utils/fs.js'
5
+
4
6
  import { cookie } from './cookie.js'
5
7
  import { MockBroker } from './MockBroker.js'
6
- import { listFilesRecursively } from './utils/fs.js'
7
8
  import { config, isFileAllowed } from './config.js'
8
9
  import { parseFilename, validateFilename } from './Filename.js'
9
10
 
@@ -1,4 +1,4 @@
1
- import { METHODS } from 'node:http'
1
+ import http, { METHODS } from 'node:http'
2
2
 
3
3
 
4
4
  export const SUPPORTED_METHODS = METHODS
@@ -12,6 +12,12 @@ export class BodyReaderError extends Error {
12
12
  }
13
13
  }
14
14
 
15
+ export class IncomingMessage extends http.IncomingMessage {
16
+ json() {
17
+ return readBody(this, JSON.parse)
18
+ }
19
+ }
20
+
15
21
  export const parseJSON = req => readBody(req, JSON.parse)
16
22
 
17
23
  export function readBody(req, parser = a => a) {
@@ -0,0 +1,117 @@
1
+ import http from 'node:http'
2
+ import fs, { readFileSync } from 'node:fs'
3
+
4
+ import { logger } from './logger.js'
5
+ import { mimeFor } from './mime.js'
6
+ import { HEADER_502 } from '../ApiConstants.js'
7
+
8
+
9
+ export class ServerResponse extends http.ServerResponse {
10
+ setHeaderList(headers) {
11
+ for (let i = 0; i < headers.length; i += 2)
12
+ this.setHeader(headers[i], headers[i + 1])
13
+ }
14
+
15
+ ok() {
16
+ logger.access(this)
17
+ this.end()
18
+ }
19
+
20
+ html(html, csp) {
21
+ logger.access(this)
22
+ this.setHeader('Content-Type', mimeFor('html'))
23
+ this.setHeader('Content-Security-Policy', csp)
24
+ this.end(html)
25
+ }
26
+
27
+ json(payload) {
28
+ logger.access(this)
29
+ this.setHeader('Content-Type', 'application/json')
30
+ this.end(JSON.stringify(payload))
31
+ }
32
+
33
+ file(file) {
34
+ logger.access(this)
35
+ this.setHeader('Content-Type', mimeFor(file))
36
+ this.end(readFileSync(file, 'utf8'))
37
+ }
38
+
39
+ noContent() {
40
+ this.statusCode = 204
41
+ logger.access(this)
42
+ this.end()
43
+ }
44
+
45
+
46
+ badRequest() {
47
+ this.statusCode = 400
48
+ logger.access(this)
49
+ this.end()
50
+ }
51
+
52
+ notFound() {
53
+ this.statusCode = 404
54
+ logger.access(this)
55
+ this.end()
56
+ }
57
+
58
+ mockNotFound() {
59
+ this.statusCode = 404
60
+ logger.accessMock(this.req.url, '404')
61
+ this.end()
62
+ }
63
+
64
+ uriTooLong() {
65
+ this.statusCode = 414
66
+ logger.access(this)
67
+ this.end()
68
+ }
69
+
70
+ unprocessable(error) {
71
+ logger.access(this, error)
72
+ this.statusCode = 422
73
+ this.end(error)
74
+ }
75
+
76
+
77
+ internalServerError(error) {
78
+ logger.error(500, this.req.url, error?.message || error, error?.stack || '')
79
+ this.statusCode = 500
80
+ this.end()
81
+ }
82
+
83
+ badGateway(error) {
84
+ logger.warn('Fallback Proxy Error:', error.cause.message)
85
+ this.statusCode = 502
86
+ this.setHeader(HEADER_502, 1)
87
+ this.end()
88
+ }
89
+
90
+
91
+ async partialContent(range, file) {
92
+ const { size } = await fs.promises.lstat(file)
93
+ let [start, end] = range.replace(/bytes=/, '').split('-').map(n => parseInt(n, 10))
94
+ if (isNaN(end)) end = size - 1
95
+ if (isNaN(start)) start = size - end
96
+
97
+ if (start < 0 || start > end || start >= size || end >= size) {
98
+ this.statusCode = 416 // Range Not Satisfiable
99
+ this.setHeader('Content-Range', `bytes */${size}`)
100
+ this.end()
101
+ }
102
+ else {
103
+ this.statusCode = 206 // Partial Content
104
+ this.setHeader('Accept-Ranges', 'bytes')
105
+ this.setHeader('Content-Range', `bytes ${start}-${end}/${size}`)
106
+ this.setHeader('Content-Type', mimeFor(file))
107
+ const reader = fs.createReadStream(file, { start, end })
108
+ const response = this
109
+ reader.on('open', function () {
110
+ this.pipe(response)
111
+ })
112
+ reader.on('error', error => {
113
+ this.internalServerError(error)
114
+ })
115
+ }
116
+ }
117
+ }
@@ -1,5 +1,6 @@
1
1
  import { join, dirname, sep, posix } from 'node:path'
2
2
  import { lstatSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs'
3
+
3
4
  import { logger } from './logger.js'
4
5
 
5
6
 
@@ -1,4 +1,4 @@
1
- import { methodIsSupported } from './http-request.js'
1
+ import { methodIsSupported } from './HttpIncomingMessage.js'
2
2
 
3
3
  /* https://www.w3.org/TR/2020/SPSD-cors-20200602/#resource-processing-model */
4
4
 
@@ -0,0 +1,225 @@
1
+ import { equal } from 'node:assert/strict'
2
+ import { promisify } from 'node:util'
3
+ import { createServer } from 'node:http'
4
+ import { describe, test, after } from 'node:test'
5
+ import { isPreflight, setCorsHeaders, CorsHeader as CH } from './http-cors.js'
6
+
7
+
8
+ function headerIs(response, header, value) {
9
+ equal(response.headers.get(header), value)
10
+ }
11
+
12
+ const FooDotCom = 'http://foo.com'
13
+ const AllowedDotCom = 'http://allowed.com'
14
+ const NotAllowedDotCom = 'http://not-allowed.com'
15
+
16
+ await describe('CORS', async () => {
17
+ let corsConfig = {}
18
+
19
+ const server = createServer((req, response) => {
20
+ setCorsHeaders(req, response, corsConfig)
21
+ if (isPreflight(req)) {
22
+ response.statusCode = 204
23
+ response.end()
24
+ }
25
+ else
26
+ response.end('NON_PREFLIGHT')
27
+ })
28
+ await promisify(server.listen).bind(server, 0, '127.0.0.1')()
29
+ after(() => {
30
+ server.close()
31
+ })
32
+ function preflight(headers, method = 'OPTIONS') {
33
+ const { address, port } = server.address()
34
+ return fetch(`http://${address}:${port}/`, { method, headers })
35
+ }
36
+ function request(headers, method) {
37
+ const { address, port } = server.address()
38
+ return fetch(`http://${address}:${port}/`, { method, headers })
39
+ }
40
+
41
+ await describe('Identifies Preflight Requests', async () => {
42
+ const requiredRequestHeaders = {
43
+ [CH.Origin]: 'http://localhost:9999',
44
+ [CH.AcRequestMethod]: 'POST'
45
+ }
46
+
47
+ await test('Ignores non-OPTIONS requests', async () => {
48
+ const res = await request(requiredRequestHeaders, 'POST')
49
+ equal(await res.text(), 'NON_PREFLIGHT')
50
+ })
51
+
52
+ await test(`Ignores non-parseable req ${CH.Origin} header`, async () => {
53
+ const headers = {
54
+ ...requiredRequestHeaders,
55
+ [CH.Origin]: 'non-url'
56
+ }
57
+ const res = await preflight(headers)
58
+ equal(await res.text(), 'NON_PREFLIGHT')
59
+ })
60
+
61
+ await test(`Ignores missing method in ${CH.AcRequestMethod} header`, async () => {
62
+ const headers = { ...requiredRequestHeaders }
63
+ delete headers[CH.AcRequestMethod]
64
+ const res = await preflight(headers)
65
+ equal(await res.text(), 'NON_PREFLIGHT')
66
+ })
67
+
68
+ await test(`Ignores non-standard method in ${CH.AcRequestMethod} header`, async () => {
69
+ const headers = {
70
+ ...requiredRequestHeaders,
71
+ [CH.AcRequestMethod]: 'NON_STANDARD'
72
+ }
73
+ const res = await preflight(headers)
74
+ equal(await res.text(), 'NON_PREFLIGHT')
75
+ })
76
+
77
+ await test('204 valid preflights', async () => {
78
+ const res = await preflight(requiredRequestHeaders)
79
+ equal(res.status, 204)
80
+ })
81
+ })
82
+
83
+ await describe('Preflight Response Headers', async () => {
84
+ await test('no origins allowed', async () => {
85
+ corsConfig = {
86
+ corsOrigins: [],
87
+ corsMethods: ['GET']
88
+ }
89
+ const p = await preflight({
90
+ [CH.Origin]: FooDotCom,
91
+ [CH.AcRequestMethod]: 'GET'
92
+ })
93
+ headerIs(p, CH.AcAllowOrigin, null)
94
+ headerIs(p, CH.AcAllowMethods, null)
95
+ headerIs(p, CH.AcAllowCredentials, null)
96
+ headerIs(p, CH.AcAllowHeaders, null)
97
+ headerIs(p, CH.AcMaxAge, null)
98
+ })
99
+
100
+ await test('not in allowed origins', async () => {
101
+ corsConfig = {
102
+ corsOrigins: [AllowedDotCom],
103
+ corsMethods: ['GET']
104
+ }
105
+ const p = await preflight({
106
+ [CH.Origin]: NotAllowedDotCom,
107
+ [CH.AcRequestMethod]: 'GET'
108
+ })
109
+ headerIs(p, CH.AcAllowOrigin, null)
110
+ headerIs(p, CH.AcAllowMethods, null)
111
+ headerIs(p, CH.AcAllowCredentials, null)
112
+ headerIs(p, CH.AcAllowHeaders, null)
113
+ })
114
+
115
+ await test('origin and method match', async () => {
116
+ corsConfig = {
117
+ corsOrigins: [AllowedDotCom],
118
+ corsMethods: ['GET']
119
+ }
120
+ const p = await preflight({
121
+ [CH.Origin]: AllowedDotCom,
122
+ [CH.AcRequestMethod]: 'GET'
123
+ })
124
+ headerIs(p, CH.AcAllowOrigin, AllowedDotCom)
125
+ headerIs(p, CH.AcAllowMethods, 'GET')
126
+ headerIs(p, CH.AcAllowCredentials, null)
127
+ headerIs(p, CH.AcAllowHeaders, null)
128
+ })
129
+
130
+ await test('origin matches from multiple', async () => {
131
+ corsConfig = {
132
+ corsOrigins: [AllowedDotCom, FooDotCom],
133
+ corsMethods: ['GET']
134
+ }
135
+ const p = await preflight({
136
+ [CH.Origin]: AllowedDotCom,
137
+ [CH.AcRequestMethod]: 'GET'
138
+ })
139
+ headerIs(p, CH.AcAllowOrigin, AllowedDotCom)
140
+ headerIs(p, CH.AcAllowMethods, 'GET')
141
+ headerIs(p, CH.AcAllowCredentials, null)
142
+ headerIs(p, CH.AcAllowHeaders, null)
143
+ })
144
+
145
+ await test('wildcard origin', async () => {
146
+ corsConfig = {
147
+ corsOrigins: ['*'],
148
+ corsMethods: ['GET']
149
+ }
150
+ const p = await preflight({
151
+ [CH.Origin]: FooDotCom,
152
+ [CH.AcRequestMethod]: 'GET'
153
+ })
154
+ headerIs(p, CH.AcAllowOrigin, FooDotCom)
155
+ headerIs(p, CH.AcAllowMethods, 'GET')
156
+ headerIs(p, CH.AcAllowCredentials, null)
157
+ headerIs(p, CH.AcAllowHeaders, null)
158
+ })
159
+
160
+ await test(`wildcard and credentials`, async () => {
161
+ corsConfig = {
162
+ corsOrigins: ['*'],
163
+ corsMethods: ['GET'],
164
+ corsCredentials: true
165
+ }
166
+ const p = await preflight({
167
+ [CH.Origin]: FooDotCom,
168
+ [CH.AcRequestMethod]: 'GET'
169
+ })
170
+ headerIs(p, CH.AcAllowOrigin, FooDotCom)
171
+ headerIs(p, CH.AcAllowMethods, 'GET')
172
+ headerIs(p, CH.AcAllowCredentials, 'true')
173
+ headerIs(p, CH.AcAllowHeaders, null)
174
+ })
175
+
176
+ await test(`wildcard, credentials, and headers`, async () => {
177
+ corsConfig = {
178
+ corsOrigins: ['*'],
179
+ corsMethods: ['GET'],
180
+ corsCredentials: true,
181
+ corsHeaders: ['content-type', 'my-header']
182
+ }
183
+ const p = await preflight({
184
+ [CH.Origin]: FooDotCom,
185
+ [CH.AcRequestMethod]: 'GET'
186
+ })
187
+ headerIs(p, CH.AcAllowOrigin, FooDotCom)
188
+ headerIs(p, CH.AcAllowMethods, 'GET')
189
+ headerIs(p, CH.AcAllowCredentials, 'true')
190
+ headerIs(p, CH.AcAllowHeaders, 'content-type,my-header')
191
+ })
192
+ })
193
+
194
+ await describe('Non-Preflight (Actual Response) Headers', async () => {
195
+ await test('no origins allowed', async () => {
196
+ corsConfig = {
197
+ corsOrigins: [],
198
+ corsMethods: ['GET']
199
+ }
200
+ const p = await request({
201
+ [CH.Origin]: NotAllowedDotCom
202
+ })
203
+ equal(p.status, 200)
204
+ headerIs(p, CH.AcAllowOrigin, null)
205
+ headerIs(p, CH.AcAllowCredentials, null)
206
+ headerIs(p, CH.AcExposeHeaders, null)
207
+ })
208
+
209
+ await test('origin allowed', async () => {
210
+ corsConfig = {
211
+ corsOrigins: [AllowedDotCom],
212
+ corsMethods: ['GET'],
213
+ corsCredentials: true,
214
+ corsExposedHeaders: ['x-h1', 'x-h2']
215
+ }
216
+ const p = await request({
217
+ [CH.Origin]: AllowedDotCom
218
+ })
219
+ equal(p.status, 200)
220
+ headerIs(p, CH.AcAllowOrigin, AllowedDotCom)
221
+ headerIs(p, CH.AcAllowCredentials, 'true')
222
+ headerIs(p, CH.AcExposeHeaders, 'x-h1,x-h2')
223
+ })
224
+ })
225
+ })
@@ -1,4 +1,4 @@
1
- import { decode, reControlAndDelChars } from './http-request.js'
1
+ import { decode, reControlAndDelChars } from './HttpIncomingMessage.js'
2
2
 
3
3
 
4
4
  export const logger = new class {
@@ -0,0 +1,24 @@
1
+ import { test } from 'node:test'
2
+ import { equal } from 'node:assert/strict'
3
+ import { parseMime, extFor, mimeFor } from './mime.js'
4
+
5
+
6
+ test('parseMime', () => [
7
+ 'text/html',
8
+ 'TEXT/html',
9
+ 'text/html; charset=utf-8'
10
+ ].map(input =>
11
+ equal(parseMime(input), 'text/html')))
12
+
13
+ test('extFor', () => [
14
+ 'text/html',
15
+ 'Text/html',
16
+ 'text/Html; charset=UTF-16'
17
+ ].map(input =>
18
+ equal(extFor(input), 'html')))
19
+
20
+ test('mimeFor', () => [
21
+ 'file.html',
22
+ 'file.HTmL'
23
+ ].map(input =>
24
+ equal(mimeFor(input), 'text/html')))
@@ -0,0 +1,47 @@
1
+ import { describe, test } from 'node:test'
2
+ import { doesNotThrow, throws } from 'node:assert/strict'
3
+ import { validate, is, optional } from './validate.js'
4
+
5
+
6
+ describe('validate', () => {
7
+ describe('optional', () => {
8
+ test('accepts undefined', () =>
9
+ doesNotThrow(() =>
10
+ validate({}, { field: optional(Number.isInteger) })))
11
+
12
+ test('accepts falsy value regardless of type', () =>
13
+ doesNotThrow(() =>
14
+ validate({ field: 0 }, { field: optional(Array.isArray) })))
15
+
16
+ test('accepts when tester func returns truthy', () =>
17
+ doesNotThrow(() =>
18
+ validate({ field: [] }, { field: optional(Array.isArray) })))
19
+
20
+ test('rejects when tester func returns falsy', () =>
21
+ throws(() =>
22
+ validate({ field: 1 }, { field: optional(Array.isArray) }),
23
+ /field=1 is invalid/))
24
+ })
25
+
26
+ describe('is', () => {
27
+ test('rejects mismatched type', () =>
28
+ throws(() =>
29
+ validate({ field: 1 }, { field: is(String) }),
30
+ /field=1 is invalid/))
31
+
32
+ test('accepts matched type', () =>
33
+ doesNotThrow(() =>
34
+ validate({ field: '' }, { field: is(String) })))
35
+ })
36
+
37
+ describe('custom tester func', () => {
38
+ test('rejects mismatched type', () =>
39
+ throws(() =>
40
+ validate({ field: 0.1 }, { field: Number.isInteger }),
41
+ /field=0.1 is invalid/))
42
+
43
+ test('accepts matched type', () =>
44
+ doesNotThrow(() =>
45
+ validate({ field: 1 }, { field: Number.isInteger })))
46
+ })
47
+ })