mockaton 0.0.1

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.
Files changed (58) hide show
  1. package/Api.js +108 -0
  2. package/ApiConstants.js +22 -0
  3. package/Config.js +41 -0
  4. package/Dashboard.css +206 -0
  5. package/Dashboard.html +12 -0
  6. package/Dashboard.js +355 -0
  7. package/LICENSE +21 -0
  8. package/MockBroker.js +107 -0
  9. package/MockDispatcher.js +72 -0
  10. package/Mockaton.js +39 -0
  11. package/README-dashboard-dropdown.png +0 -0
  12. package/README-dashboard.png +0 -0
  13. package/README-mocks-with-comments.png +0 -0
  14. package/README.md +211 -0
  15. package/Route.js +90 -0
  16. package/StaticDispatcher.js +29 -0
  17. package/Tests.js +367 -0
  18. package/_usage_example.js +14 -0
  19. package/cookie.js +29 -0
  20. package/index.d.ts +17 -0
  21. package/index.js +2 -0
  22. package/mockBrokersCollection.js +84 -0
  23. package/package.json +12 -0
  24. package/sample-mocks/api/user/.GET.200.json +1 -0
  25. package/sample-mocks/api/user/.GET.501.txt +7 -0
  26. package/sample-mocks/api/user/edit-name.PATCH.200.json +1 -0
  27. package/sample-mocks/api/user/edit-name.PATCH.200.md +12 -0
  28. package/sample-mocks/api/user/edit-name.PATCH.501.txt +0 -0
  29. package/sample-mocks/api/user/friends.GET.200.json +1 -0
  30. package/sample-mocks/api/user/friends.GET.204.json +4 -0
  31. package/sample-mocks/api/user/friends.GET.501.txt +0 -0
  32. package/sample-mocks/api/user/logout.POST.200.json +1 -0
  33. package/sample-mocks/api/user/logout.POST.501.txt +0 -0
  34. package/sample-mocks/api/user/videos(assorted).GET.200.json +13 -0
  35. package/sample-mocks/api/user/videos(entirely unverified).GET.200.json +13 -0
  36. package/sample-mocks/api/user/videos(entirely verified)(another comment).GET.200.json +13 -0
  37. package/sample-mocks/api/user/videos.GET.501.txt +0 -0
  38. package/sample-mocks/api/video/[id].GET.200.json +4 -0
  39. package/sample-mocks/api/video/[id].GET.501.txt +0 -0
  40. package/sample-mocks/api/video/list(concat newly uploaded).GET.200.mjs +8 -0
  41. package/sample-mocks/api/video/list.GET.200.json +11 -0
  42. package/sample-mocks/api/video/list.GET.501.txt +0 -0
  43. package/sample-mocks/api/video/stat/[stat-id]/[video-id].GET.200.json +1 -0
  44. package/sample-mocks/api/video/stat/[stat-id]/[video-id].GET.501.txt +0 -0
  45. package/sample-mocks/api/video/stat/[stat-id]/all-videos?limit=[limit].GET.200.json +4 -0
  46. package/sample-mocks/api/video/stat/[stat-id]/all-videos?limit=[limit].GET.501.txt +0 -0
  47. package/sample-mocks/api/video/upload(insert newly uploaded).POST.201.mjs +10 -0
  48. package/sample-mocks/api/video/upload.POST.201.json +3 -0
  49. package/sample-mocks/api/video/upload.POST.501.txt +0 -0
  50. package/sample-static/another-entry/index.html +12 -0
  51. package/sample-static/assets/app.js +1 -0
  52. package/sample-static/assets/video.mp4 +0 -0
  53. package/sample-static/index.html +13 -0
  54. package/utils/http-request.js +36 -0
  55. package/utils/http-response.js +60 -0
  56. package/utils/jwt.js +21 -0
  57. package/utils/mime.js +47 -0
  58. package/utils/validate.js +17 -0
package/README.md ADDED
@@ -0,0 +1,211 @@
1
+ # Mockaton (Mock Server)
2
+
3
+ Mockaton scans `Config.mocksDir` for files
4
+ following a specific file name convention, which is similar to the URLs.
5
+
6
+ For example, the following file will be served for `/api/user/1234`
7
+ ```
8
+ api/
9
+ api/user/
10
+ api/user/[user-id].GET.200.json
11
+ ```
12
+
13
+ ### Mock Variants
14
+ Each route can have different mocks and those variants could either be:
15
+ - a different response status code, (e.g. 200, 401),
16
+ - or a comment on the filename, which is anything within parentheses.
17
+
18
+ The variants can be manually selected via the dashboard
19
+ UI, or programmatically for instance for setting up tests.
20
+
21
+
22
+ ## Getting Started
23
+ The best way to learn mockaton is by checking out this repo and
24
+ exploring its [sample-mocks/](./sample-mocks) directory. Then run
25
+ [`./_usage_example.js`](./_usage_example.js) and you’ll see this dashboard:
26
+
27
+ ![](./README-dashboard.png)
28
+
29
+
30
+ ### Mock Variants of Status Code
31
+ The **sample-mocks/** directory has three mock alternatives for serving
32
+ `/api/user/friends`.
33
+ - with an HTTP status of _200 - OK_,
34
+ - another one with _204 - No Content_ of an empty list of friends, and
35
+ - a _501 - Internal Server Error_
36
+ - By the way, 501 mocks get autogenerated for routes that have no 501’s.
37
+
38
+ ![](./README-dashboard-dropdown.png)
39
+
40
+ ### Mock Variants with Comments
41
+ Comments are anything within parentheses, including them.
42
+ ![](./README-mocks-with-comments.png)
43
+
44
+ ---
45
+
46
+ ## Delay
47
+ The clock icon next to the mock selector dropdown is a checkbox for delaying a
48
+ particular response. They are handy for testing spinners when developing UIs. By the
49
+ way, the milliseconds for the delay is globally configurable via `Config.delay`.
50
+
51
+ ---
52
+
53
+ ## Basic Usage (see [_usage_example.js](./_usage_example.js))
54
+ ```
55
+ npm install @ericfortis/mockaton
56
+ ```
57
+ Create a `my-mockaton.js` file
58
+ ```js
59
+ import { resolve } from 'node:path'
60
+ import { Mockaton } from '@ericfortis/mockaton'
61
+
62
+ Mockaton({ // Config options
63
+ port: 2345,
64
+ mocksDir: resolve('my-mocks-dir')
65
+ })
66
+ ```
67
+
68
+ ```sh
69
+ node my-mockaton.js
70
+ ```
71
+
72
+ ---
73
+
74
+ ## File Name Convention
75
+
76
+
77
+
78
+ ### Extension
79
+ `.Method.HttpResponseStatusCode.FileExt`
80
+
81
+ The **file extension** can anything, but `.md` and `.mjs` are reserved
82
+ for documentation, and mock processors (more on that later).
83
+
84
+ By the way, the `Config.allowedExt` regex defaults to: `/\.(json|txt|md|mjs)$/`
85
+
86
+
87
+ ### Dynamic Parameters
88
+ Anything within square brackets. For example, `api/user/[id]/[age].GET.200.json`
89
+
90
+ ### Comments
91
+ Comments are anything within parentheses, including them, and they are
92
+ ignored for URL purposes. In other words, comments have no effect on the
93
+ URL mask. For example, these two are for `/api/foo`
94
+ ```
95
+ api/foo(my comment).GET.200.json(foo)
96
+ api/foo.GET.200.json
97
+ ```
98
+
99
+ ### Query String Params
100
+ ```
101
+ api/video?limit=[limit].GET.200.json
102
+ ```
103
+ The query string behaves like comments in the sense it’s
104
+ only used for documenting the URL API contract.
105
+
106
+ In other words, the query string is ignored when routing to it. BTW, in Windows,
107
+ filenames containing "?" are not permitted, but they are ignored anyway.
108
+
109
+ https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
110
+
111
+
112
+ ### Default (index-like) file
113
+ For the default route of a directory, omit the name (just use
114
+ the extension). For example, the following files will be routed
115
+ to `api/foo` because comments and the query string are ignored.
116
+ ```text
117
+ api/foo/.GET.200.json
118
+ api/foo/?bar=[bar].GET.200.json
119
+ api/foo/(my comment).GET.200.json
120
+ ```
121
+
122
+ ---
123
+
124
+ ## Config Options
125
+ ```ts
126
+ interface Config {
127
+ mocksDir: string
128
+ staticDir?: string
129
+ host?: string,
130
+ port?: number
131
+
132
+ cookies?(): object
133
+
134
+ skipOpen?: boolean
135
+ allowedExt?: RegExp
136
+ delayMilliseconds?: number
137
+ database?: object
138
+ }
139
+ ```
140
+ ---
141
+
142
+ ## Cookies
143
+ ```
144
+ Config.cookies = {
145
+ 'My Admin User': 'my-cookie=1;Path=/;SameSite=strict',
146
+ 'My Normal User': 'my-cookie=0;Path=/;SameSite=strict'
147
+ }
148
+ ```
149
+
150
+ ---
151
+ ## Mock Precedence
152
+ The first file in **alphabetical order** wins when a particular route has many files.
153
+
154
+ ### Why do we have many mocks per Route+Method?
155
+ Each route has mocks for many status codes, and also different
156
+ mocks (by having comments) for testing particular scenarios.
157
+ For example, different 422 validation error messages.
158
+
159
+ ---
160
+
161
+ ## Reset the Dashboard UI after insert or delete
162
+ When deleting the currently selected option, without refreshing the dashboard, the
163
+ served mock will be an alternative mock if it exists. That is, the dashboard won't show
164
+ a 404 after deleting the current mock if there’s another mock for that particular route.
165
+
166
+ Similarly, inserting a file that goes first in alphabetical order will
167
+ send a different mock from the one stated in the dashboard dropdown.
168
+
169
+ ---
170
+
171
+ ## Documenting Contracts (.md)
172
+ This is handy for documenting request payload parameters. The dashboard will
173
+ print the markdown document (as plain text) above the actual payload content.
174
+
175
+ Create a markdown file following the same filename convention.
176
+ The status code can be any number. For example,
177
+ ```text
178
+ api/foo/[user-id].POST.201.md
179
+ ```
180
+
181
+ ---
182
+
183
+ ## Non-Deterministic Mocks (.mjs handlers)
184
+ Using the same filename convention, files ending
185
+ with `.mjs` will process the mock before serving it.
186
+
187
+ For example, this handler will uppercase the mock body.
188
+ ```js
189
+ export default capitalizeAllText(mockAsText, requestBody) {
190
+ return mockAsText.toUpperCase();
191
+ }
192
+ ```
193
+
194
+ In demo mode, transforms tagged with the string `demo` within a filename
195
+ comment get activated. Mock sets tags e.g. `demo-a` have no effect. In
196
+ other words, only one transform per route is supported in demo mode.
197
+
198
+ ---
199
+
200
+ ## Bulk Selecting Mocks by Matching comments
201
+ Many mocks can be changed at once. We do that by searching the
202
+ comments on the filename. For example, `api/foo(demo-a).GET.200.json`
203
+
204
+ Non-matching mocks are ignored. For instance, if for a
205
+ particular API there is only `demo-a` and `demo-b`, changing to
206
+ `demo-c` will preserve the last one that was successfully set.
207
+
208
+ Similarly, if there’s no demo mock at all for
209
+ a route, the first dev mock (a-z) will be served.
210
+
211
+
package/Route.js ADDED
@@ -0,0 +1,90 @@
1
+ const httpMethods = [
2
+ 'CONNECT',
3
+ 'DELETE',
4
+ 'GET',
5
+ 'HEAD',
6
+ 'OPTIONS',
7
+ 'PATCH',
8
+ 'POST',
9
+ 'PUT',
10
+ 'TRACE'
11
+ ]
12
+
13
+ export class Route {
14
+ #urlRegex
15
+
16
+ constructor(file) {
17
+ const { urlMask, method } = Route.parseFilename(file)
18
+ this.method = method
19
+ this.#urlRegex = new RegExp(`^${disregardVariables(removeQueryStringAndFragment(urlMask))}/*$`)
20
+ }
21
+
22
+ urlMaskMatches(url) {
23
+ // Appending a '/' so URLs ending with variables don't match
24
+ // URLs that have a path after that variable. For example,
25
+ // without it, the following regex would match both of these URLs:
26
+ // api/foo/[route_id] => api/foo/.* (wrong match because it’s too greedy)
27
+ // api/foo/[route_id]/suffix => api/foo/.*/suffix
28
+ // By the same token, the regex handles many trailing
29
+ // slashes. For instance, for routing api/foo/[id]?qs…
30
+ return this.#urlRegex.test(removeQueryStringAndFragment(url) + '/')
31
+ }
32
+
33
+ // Anything within parentheses in the filename is a comment, including the parentheses.
34
+ static reComments = /\(.*?\)/g
35
+
36
+ static extractComments(filename) {
37
+ return Array.from(filename.matchAll(Route.reComments), ([comment]) => comment)
38
+ }
39
+
40
+ static hasInParentheses(filename, search) {
41
+ return Route.extractComments(filename)
42
+ .some(comment => comment.includes(search))
43
+ }
44
+
45
+ static parseFilename(file) {
46
+ const tokens = file.replace(Route.reComments, '').split('.')
47
+
48
+ let error = ''
49
+ if (tokens.length < 4)
50
+ error = 'Invalid Filename Convention'
51
+
52
+ const method = tokens.at(-3)
53
+ if (!httpMethods.includes(method))
54
+ error = `Unrecognized HTTP Method: "${method}"`
55
+
56
+ const status = Number(tokens.at(-2))
57
+ if (!responseStatusIsValid(status))
58
+ error = `Invalid HTTP Response Status: "${status}"`
59
+
60
+ return {
61
+ error,
62
+ urlMask: '/' + removeTrailingSlash(tokens.at(-4)),
63
+ method,
64
+ status
65
+ }
66
+ }
67
+ }
68
+
69
+
70
+ // Stars out (for regex) all the paths that are in angle brackets
71
+ function disregardVariables(str) {
72
+ return str.replace(/\[.*?]/g, '[^/]*')
73
+ }
74
+
75
+ function removeQueryStringAndFragment(urlMask) {
76
+ return urlMask.replace(/[?#].*/, '')
77
+ }
78
+
79
+ function removeTrailingSlash(url = '') {
80
+ return decodeURIComponent(url
81
+ .replace(/\/$/, '')
82
+ .replace('/?', '?')
83
+ .replace('/#', '#'))
84
+ }
85
+
86
+ function responseStatusIsValid(status) {
87
+ return Number.isInteger(status)
88
+ && status >= 100
89
+ && status <= 599
90
+ }
@@ -0,0 +1,29 @@
1
+ import { join } from 'node:path'
2
+ import { existsSync, lstatSync } from 'node:fs'
3
+
4
+ import { Config } from './Config.js'
5
+ import { sendFile, sendPartialContent } from './utils/http-response.js'
6
+
7
+
8
+ export function isStatic(req) {
9
+ return Config.staticDir &&
10
+ existsSync(resolvePath(req))
11
+ }
12
+
13
+ export async function dispatchStatic(req, response) {
14
+ const file = resolvePath(req)
15
+ if (req.headers.range)
16
+ await sendPartialContent(response, req.headers.range, file)
17
+ else
18
+ sendFile(response, file)
19
+ }
20
+
21
+ function resolvePath(req) {
22
+ const candidate = join(Config.staticDir, req.url)
23
+ if (existsSync(candidate))
24
+ return lstatSync(candidate).isDirectory()
25
+ ? candidate + '/index.html'
26
+ : candidate
27
+ }
28
+
29
+
package/Tests.js ADDED
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { tmpdir } from 'node:os'
4
+ import { dirname } from 'node:path'
5
+ import { describe, it } from 'node:test'
6
+ import { equal, deepEqual, match } from 'node:assert/strict'
7
+ import { writeFileSync, mkdtempSync, mkdirSync } from 'node:fs'
8
+
9
+ import { Route } from './Route.js'
10
+ import { mimeFor } from './utils/mime.js'
11
+ import { DP, DF } from './ApiConstants.js'
12
+ import { Mockaton } from './Mockaton.js'
13
+
14
+
15
+ const tmpDir = mkdtempSync(tmpdir()) + '/'
16
+ const staticTmpDir = mkdtempSync(tmpdir()) + '/'
17
+ const fixtures = [
18
+ [
19
+ '/api',
20
+ 'api/.GET.200.json',
21
+ 'index-like route is just the extension convention'
22
+ ],
23
+
24
+ // Exact route paths
25
+ [
26
+ '/api/the-route',
27
+ 'api/the-route(comment-1).GET.200.json',
28
+ 'my route body content'
29
+ ], [
30
+ '/api/the-mime',
31
+ 'api/the-mime.GET.200.txt',
32
+ 'determines the content type'
33
+ ], [
34
+ '/api/the-method-and-status',
35
+ 'api/the-method-and-status.POST.201.json',
36
+ 'obeys the HTTP method and response status'
37
+ ], [
38
+ '/api/the-comment',
39
+ 'api/the-comment(this is the actual comment).GET.200(another comment).txt',
40
+ ''
41
+ ], [
42
+ '/api/alternative',
43
+ 'api/alternative(comment-1).GET.200.json',
44
+ 'With_Comment_1'
45
+ ],
46
+
47
+
48
+ // Dynamic Params
49
+ [
50
+ '/api/user/1234',
51
+ 'api/user/[id]/.GET.200.json',
52
+ 'variable at end'
53
+ ], [
54
+ '/api/user/1234/suffix',
55
+ 'api/user/[id]/suffix.GET.200.json',
56
+ 'sandwich a variable that another route has at the end'
57
+ ], [
58
+ '/api/user/exact-route',
59
+ 'api/user/exact-route.GET.200.json',
60
+ 'ensure dynamic params do not take precedence over exact routes'
61
+ ],
62
+
63
+ // Query String
64
+ [
65
+ '/api/my-query-string?foo=[foo]&bar=[bar]',
66
+ 'api/my-query-string?foo=[foo]&bar=[bar].GET.200.json',
67
+ 'two query string params'
68
+ ], [
69
+ '/api/company-a',
70
+ 'api/company-a/[id]?limit=[limit].GET.200.json',
71
+ 'without pretty-param nor query-params'
72
+ ], [
73
+ '/api/company-b/',
74
+ 'api/company-b/[id]?limit=[limit].GET.200.json',
75
+ 'without pretty-param nor query-params with trailing slash'
76
+ ], [
77
+ '/api/company-c/1234',
78
+ 'api/company-c/[id]?limit=[limit].GET.200.json',
79
+ 'with pretty-param and without query-params'
80
+ ], [
81
+ '/api/company-d/1234/?',
82
+ 'api/company-d/[id]?limit=[limit].GET.200.json',
83
+ 'with pretty-param and without query-params, but with trailing slash and "?"'
84
+ ], [
85
+ '/api/company-e/1234/?limit=4',
86
+ 'api/company-e/[id]?limit=[limit].GET.200.json',
87
+ 'with pretty-param and query-params'
88
+ ]
89
+ ]
90
+ for (const [, file, body] of fixtures)
91
+ write(file, file.endsWith('.json') ? JSON.stringify(body) : body)
92
+
93
+ write('api/.GET.501.txt', 'keeps non-autogenerated 501')
94
+ write('api/alternative(comment-2).GET.200.json', JSON.stringify({ comment: 2 }))
95
+ write('api/my-route(comment-2).GET.200.json', JSON.stringify({ comment: 2 }))
96
+
97
+ // These files ensure the server doesn’t crash. We don’t test their console.error
98
+ write('api/bad-filename.200.json', 'missing method')
99
+ write('api/bad-filename.GET.200', 'missing extension')
100
+ write('api/bad-filename.GET.json', 'missing response status')
101
+
102
+ writeStatic('index.html', '<h1>Static</h1>')
103
+ writeStatic('assets/app.js', 'const app = 1')
104
+ writeStatic('another-entry/index.html', '<h1>Another</h1>')
105
+
106
+
107
+ const server = Mockaton({
108
+ mocksDir: tmpDir,
109
+ staticDir: staticTmpDir,
110
+ skipOpen: true,
111
+ cookies: {
112
+ userA: 'CookieA',
113
+ userB: 'CookieB'
114
+ }
115
+ })
116
+ server.on('listening', runTests)
117
+
118
+ async function runTests() {
119
+ await testItRendersDashboard()
120
+
121
+ for (const [url, file, body] of fixtures)
122
+ await testMockDispatching(url, file, body)
123
+
124
+ await testItUpdatesDelayAndFile(
125
+ '/api/alternative',
126
+ 'api/alternative(comment-1).GET.200.json')
127
+
128
+ await testAutogenerates501(
129
+ '/api/company-e/123?limit=9',
130
+ 'api/company-e/[id]?limit=[limit].GET.501.txt')
131
+
132
+ await testPreservesExiting501(
133
+ '/api',
134
+ 'api/.GET.501.txt',
135
+ 'keeps non-autogenerated 501')
136
+
137
+ await reset()
138
+ await testItUpdatesTheCurrentSelectedMock(
139
+ '/api/alternative',
140
+ 'api/alternative(comment-2).GET.200.json',
141
+ 200,
142
+ JSON.stringify({ comment: 2 }))
143
+
144
+ await reset()
145
+ await testExtractsAllComments([
146
+ '(comment-1)',
147
+ '(comment-2)',
148
+ '(this is the actual comment)',
149
+ '(another comment)'
150
+ ])
151
+ await testItBulkSelectsByComment('(comment-2)',
152
+ [
153
+ ['/api/alternative', 'api/alternative(comment-2).GET.200.json', { comment: 2 }],
154
+ ['/api/my-route', 'api/my-route(comment-2).GET.200.json', { comment: 2 }]
155
+ ]
156
+ )
157
+
158
+ await reset()
159
+ for (const [url, file, body] of fixtures)
160
+ await testMockDispatching(url, file, body)
161
+
162
+ await testItUpdatesUserRole()
163
+ await testTransforms()
164
+
165
+ await testStaticFileServing()
166
+
167
+ server.close()
168
+ }
169
+
170
+ async function reset() {
171
+ await request(DP.reset, { method: 'PATCH' })
172
+ }
173
+
174
+ async function testItRendersDashboard() {
175
+ const res = await request(DP.dashboard)
176
+ const body = await res.text()
177
+ await describe('Dashboard', () =>
178
+ it('Renders HTML', () => match(body, new RegExp('<!DOCTYPE html>'))))
179
+ }
180
+
181
+ async function testMockDispatching(url, file, expectedBody, reqBody = void 0) {
182
+ const { urlMask, method, status } = Route.parseFilename(file)
183
+ const mime = mimeFor(file)
184
+ const now = new Date()
185
+ const res = await request(url, { method, body: reqBody })
186
+ const body = mime === 'application/json'
187
+ ? await res.json()
188
+ : await res.text()
189
+ await describe('URL Mask: ' + urlMask, () => {
190
+ it('file: ' + file, () => deepEqual(body, expectedBody))
191
+ it('mime: ' + mime, () => equal(res.headers.get('content-type'), mime))
192
+ it('status: ' + status, () => equal(res.status, status))
193
+ it('cookie: ' + mime, () => equal(res.headers.get('set-cookie'), 'CookieA'))
194
+ it('delay is under 1 sec', () => equal((new Date()).getTime() - now.getTime() < 1000, true))
195
+ })
196
+ }
197
+
198
+ async function testItUpdatesTheCurrentSelectedMock(url, file, expectedStatus, expectedBody) {
199
+ await request(DP.edit, {
200
+ method: 'PATCH',
201
+ body: JSON.stringify({ [DF.file]: file })
202
+ })
203
+ const res = await request(url)
204
+ const body = await res.text()
205
+ await describe('url: ' + url, () => {
206
+ it('body is: ' + expectedBody, () => equal(body, expectedBody))
207
+ it('status is: ' + expectedStatus, () => equal(res.status, expectedStatus))
208
+ })
209
+ }
210
+
211
+ async function testItUpdatesDelayAndFile(url, file) {
212
+ await request(DP.edit, {
213
+ method: 'PATCH',
214
+ body: JSON.stringify({
215
+ [DF.file]: file,
216
+ [DF.delayed]: true
217
+ })
218
+ })
219
+ const now = new Date()
220
+ await request(url)
221
+ await describe('url: ' + url, () =>
222
+ it('delay is over 1 sec', () => equal((new Date()).getTime() - now.getTime() > 1000, true)))
223
+ }
224
+
225
+
226
+ async function testAutogenerates501(url, file) {
227
+ await request(DP.edit, {
228
+ method: 'PATCH',
229
+ body: JSON.stringify({ [DF.file]: file })
230
+ })
231
+ const res = await request(url)
232
+ const body = await res.text()
233
+ await describe('autogenerated 501', () => {
234
+ it('body is empty', () => equal(body, ''))
235
+ it('status is: 501', () => equal(res.status, 501))
236
+ })
237
+ }
238
+
239
+ async function testPreservesExiting501(url, file, expectedBody) {
240
+ await request(DP.edit, {
241
+ method: 'PATCH',
242
+ body: JSON.stringify({ [DF.file]: file })
243
+ })
244
+ const res = await request(url)
245
+ const body = await res.text()
246
+ await describe('preserves existing 501', () => {
247
+ it('body is empty', () => equal(body, expectedBody))
248
+ it('status is: 501', () => equal(res.status, 501))
249
+ })
250
+ }
251
+
252
+ async function testExtractsAllComments(expected) {
253
+ const res = await request(DP.comments)
254
+ const body = await res.json()
255
+ await it('Extracts all comments without duplicates', () =>
256
+ deepEqual(body, expected))
257
+ }
258
+
259
+ async function testItBulkSelectsByComment(comment, tests) {
260
+ await request(DP.bulkSelect, {
261
+ method: 'PATCH',
262
+ body: JSON.stringify({
263
+ [DF.comment]: comment
264
+ })
265
+ })
266
+ for (const [url, file, body] of tests)
267
+ await testMockDispatching(url, file, body)
268
+ }
269
+
270
+
271
+ async function testItUpdatesUserRole() {
272
+ await describe('Cookie', () => {
273
+ it('Defaults to the first key:value', async () => {
274
+ const res = await request(DP.cookies)
275
+ deepEqual(await res.json(), [
276
+ ['userA', true],
277
+ ['userB', false]
278
+ ])
279
+ })
280
+
281
+ it('Update the selected cookie', async () => {
282
+ await request(DP.cookies, {
283
+ method: 'PATCH',
284
+ body: JSON.stringify({ [DF.currentCookieKey]: 'userB' })
285
+ })
286
+ const res = await request(DP.cookies)
287
+ deepEqual(await res.json(), [
288
+ ['userA', false],
289
+ ['userB', true]
290
+ ])
291
+ })
292
+ })
293
+ }
294
+
295
+ async function testTransforms() {
296
+ await describe('Applies transform', async () => {
297
+ write('api/transform.POST.200.json', JSON.stringify(['initial']))
298
+ write('api/transform.POST.200.mjs', `
299
+ export default function (mock, reqBody, config) {
300
+ const body = JSON.parse(mock);
301
+ body.push(reqBody[0]);
302
+ body.push(config.mocksDir);
303
+ return JSON.stringify(body);
304
+ }`)
305
+ await reset() // for registering the files
306
+ await request(DP.transform, {
307
+ method: 'PATCH',
308
+ body: JSON.stringify({
309
+ [DF.method]: 'POST',
310
+ [DF.urlMask]: '/api/transform',
311
+ [DF.file]: 'api/transform.POST.200.mjs'
312
+ })
313
+ })
314
+ await testMockDispatching('/api/transform',
315
+ 'api/transform.POST.200.json',
316
+ ['initial', 'another', tmpDir],
317
+ JSON.stringify(['another']))
318
+ })
319
+ }
320
+
321
+
322
+ async function testStaticFileServing() {
323
+ await describe('Static File Serving', () => {
324
+ it('Defaults to index.html', async () => {
325
+ const res = await request('/')
326
+ const body = await res.text()
327
+ equal(body, '<h1>Static</h1>')
328
+ equal(res.status, 200)
329
+ })
330
+
331
+ it('Defaults to in subdirs index.html', async () => {
332
+ const res = await request('/another-entry')
333
+ const body = await res.text()
334
+ equal(body, '<h1>Another</h1>')
335
+ equal(res.status, 200)
336
+ })
337
+
338
+ it('Serves exacts paths', async () => {
339
+ const res = await request('/assets/app.js')
340
+ const body = await res.text()
341
+ equal(body, 'const app = 1')
342
+ equal(res.status, 200)
343
+ })
344
+ })
345
+ }
346
+
347
+
348
+ // Utils
349
+
350
+ function write(filename, data) {
351
+ _write(tmpDir + filename, data)
352
+ }
353
+
354
+ function writeStatic(filename, data) {
355
+ _write(staticTmpDir + filename, data)
356
+ }
357
+
358
+ function _write(absPath, data) {
359
+ mkdirSync(dirname(absPath), { recursive: true })
360
+ writeFileSync(absPath, data, 'utf8')
361
+ }
362
+
363
+ function request(path, options = {}) {
364
+ const { address, port } = server.address()
365
+ return fetch(`http://${address}:${port}${path}`, options)
366
+ }
367
+
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { resolve } from 'node:path'
4
+ import { Mockaton } from './index.js' // from 'mockaton'
5
+
6
+ Mockaton({
7
+ port: 2345,
8
+ mocksDir: resolve('sample-mocks'),
9
+ staticDir: resolve('sample-static'),
10
+ cookies: {
11
+ 'Admin User': 'my-cookie=1;Path=/;SameSite=strict',
12
+ 'Normal User': 'my-cookie=0;Path=/;SameSite=strict'
13
+ }
14
+ })