laravel-workers 0.4.0 → 0.4.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.
@@ -1,281 +1,281 @@
1
- // edge/kernel/tests/router.test.mjs
2
- import assert from 'node:assert/strict';
3
- import { normalizeTable, routeKey, match, dispatch } from '../router.mjs';
4
- import { html, json, redirect, notFound, text } from '../response.mjs';
5
-
6
- const routes = [
7
- { uri: 'up', name: null, methods: ['GET', 'HEAD'], domain: null },
8
- { uri: '/', name: null, methods: ['GET', 'HEAD'], domain: null },
9
- { uri: 'dbcheck', name: null, methods: ['GET', 'HEAD'], domain: null },
10
- { uri: 'storage/{path}', name: 'storage.local', methods: ['GET', 'HEAD'], domain: null },
11
- { uri: 'storage/{path}', name: 'storage.local.upload', methods: ['PUT'], domain: null },
12
- ];
13
-
14
- const table = normalizeTable(routes);
15
-
16
- // basic: 5 routes normalized with leading slashes
17
- assert.equal(table.length, 5);
18
- assert.equal(table[0].uri, '/up');
19
- assert.equal(table[3].uri, '/storage/{path}');
20
-
21
- // routeKey
22
- assert.equal(routeKey(table[0]), 'GET /up');
23
-
24
- // HEAD matched by GET route
25
- let m = match(table, { method: 'HEAD', path: '/up' });
26
- assert.ok(m, 'HEAD /up should match');
27
- assert.equal(m.route.name, null);
28
-
29
- // trailing slash normalization
30
- m = match(table, { method: 'GET', path: '/up/' });
31
- assert.ok(m, 'trailing slash should normalize');
32
- assert.equal(m.route.uri, '/up');
33
-
34
- // query string stripped
35
- m = match(table, { method: 'GET', path: '/dbcheck?foo=bar' });
36
- assert.ok(m);
37
- assert.equal(m.route.uri, '/dbcheck');
38
-
39
- // param capture + decodeURIComponent
40
- m = match(table, { method: 'GET', path: '/storage/hello%20world.txt' });
41
- assert.ok(m);
42
- assert.equal(m.params.path, 'hello world.txt');
43
-
44
- // method distinction: PUT vs GET on same uri
45
- m = match(table, { method: 'PUT', path: '/storage/x' });
46
- assert.ok(m);
47
- assert.equal(m.route.name, 'storage.local.upload');
48
- m = match(table, { method: 'GET', path: '/storage/x' });
49
- assert.ok(m);
50
- assert.equal(m.route.name, 'storage.local');
51
-
52
- // unknown path -> null
53
- assert.equal(match(table, { method: 'GET', path: '/nope' }), null);
54
-
55
- // unknown method -> null (DELETE not registered)
56
- assert.equal(match(table, { method: 'DELETE', path: '/up' }), null);
57
-
58
- // specificity / param: storage/{path} captures one segment only
59
- m = match(table, { method: 'GET', path: '/storage/a/b' });
60
- // {path} is one segment, so /storage/a/b should NOT match (b extra)
61
- assert.equal(m, null, 'multi-segment should not match single {path}');
62
-
63
- // dispatch: missing handler -> null
64
- const handlers = {};
65
- assert.equal(await dispatch({ method: 'GET', path: '/up' }, table, handlers), null);
66
-
67
- // dispatch: with handler returns response
68
- handlers['GET /up'] = (ctx) => html('hi ' + (ctx.params.x || ''), 200);
69
- const r = await dispatch({ method: 'GET', path: '/up' }, table, handlers);
70
- assert.deepEqual(r, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: 'hi ' });
71
-
72
- // dispatch: unknown path -> null
73
- assert.equal(await dispatch({ method: 'GET', path: '/nope' }, table, handlers), null);
74
-
75
- // duplicate method+uri -> later wins
76
- const dupTable = normalizeTable([
77
- { uri: '/x', name: 'first', methods: ['GET'], domain: null },
78
- { uri: '/x', name: 'second', methods: ['GET'], domain: null },
79
- ]);
80
- assert.equal(dupTable.length, 1);
81
- assert.equal(dupTable[0].name, 'second');
82
-
83
- // root path
84
- m = match(table, { method: 'GET', path: '/' });
85
- assert.ok(m);
86
- assert.equal(m.route.uri, '/');
87
-
88
- // response helpers
89
- assert.deepEqual(html('a'), { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: 'a' });
90
- assert.deepEqual(json({ b: 1 }), { status: 200, headers: { 'content-type': 'application/json' }, body: '{"b":1}' });
91
- assert.deepEqual(redirect('/x'), { status: 302, headers: { location: '/x' }, body: '' });
92
- assert.deepEqual(notFound(), { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: 'Not Found' });
93
- assert.deepEqual(text('t', 201), { status: 201, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: 't' });
94
-
95
- // header names lowercased by dispatch
96
- handlers['GET /dbcheck'] = () => ({ status: 200, headers: { 'X-Foo': 'bar' }, body: 'ok' });
97
- const r2 = await dispatch({ method: 'GET', path: '/dbcheck' }, table, handlers);
98
- assert.equal(r2.headers['x-foo'], 'bar');
99
-
100
- // wildcard {path*} captures multiple segments
101
- const wtable = normalizeTable([{ uri: '/files/{path*}', name: 'files', methods: ['GET'], domain: null }]);
102
- m = match(wtable, { method: 'GET', path: '/files/a/b/c.txt' });
103
- assert.ok(m, 'wildcard should match multi-segment');
104
- assert.equal(m.params.path, 'a/b/c.txt');
105
-
106
- // wildcard must be the last segment
107
- assert.throws(
108
- () => match(normalizeTable([{ uri: '/{a*}/x', name: null, methods: ['GET'], domain: null }]), { method: 'GET', path: '/a/x' }),
109
- /must be the last segment/,
110
- );
111
-
112
- // domain enforcement + domain param capture
113
- const dtable = normalizeTable([
114
- { uri: '/', name: 'root', methods: ['GET'], domain: null },
115
- { uri: '/admin', name: 'admin', methods: ['GET'], domain: 'admin.example.com' },
116
- { uri: '/tenant', name: 'tenant', methods: ['GET'], domain: '{sub}.example.com' },
117
- ]);
118
- m = match(dtable, { method: 'GET', path: '/admin', host: 'admin.example.com' });
119
- assert.ok(m);
120
- assert.equal(m.route.name, 'admin');
121
- assert.equal(match(dtable, { method: 'GET', path: '/admin', host: 'www.example.com' }), null, 'wrong host must not match');
122
- assert.equal(match(dtable, { method: 'GET', path: '/admin' }), null, 'missing host must not match domain route');
123
- m = match(dtable, { method: 'GET', path: '/tenant', host: 'acme.example.com' });
124
- assert.ok(m);
125
- assert.equal(m.params.sub, 'acme');
126
- m = match(dtable, { method: 'GET', path: '/admin', host: 'admin.example.com:8787' });
127
- assert.ok(m, 'port must be stripped from host');
128
-
129
- // an optional parameter: the separator belongs to the optional group
130
- // (Symfony's RouteCompiler), so '/users/{id?}' also answers '/users'.
131
- const otable = normalizeTable([
132
- { uri: '/users/{id?}', name: 'users.show', methods: ['GET'], domain: null },
133
- { uri: '/files/{path}', name: 'files.show', methods: ['GET'], domain: null },
134
- ]);
135
- m = match(otable, { method: 'GET', path: '/users' });
136
- assert.ok(m, 'the optional segment may be absent');
137
- assert.equal(m.route.name, 'users.show');
138
- assert.equal(m.params.id, undefined, 'an absent optional parameter is not captured');
139
- m = match(otable, { method: 'GET', path: '/users/5' });
140
- assert.ok(m);
141
- assert.equal(m.params.id, '5');
142
- // the optional variable as the only token: the separator stays outside
143
- // (Symfony's RouteCompiler) and the parameter may be absent
144
- const onlytable = normalizeTable([{ uri: '/{id?}', name: 'root', methods: ['GET'], domain: null }]);
145
- m = match(onlytable, { method: 'GET', path: '/' });
146
- assert.ok(m, 'the root route matches');
147
- assert.equal(m.params.id, undefined);
148
- m = match(onlytable, { method: 'GET', path: '/7' });
149
- assert.ok(m);
150
- assert.equal(m.params.id, '7');
151
- // an optional parameter that is not the last one is required (Symfony stops
152
- // its first-optional scan at the text token after it)
153
- const midtable = normalizeTable([{ uri: '/a/{b?}/c', name: 'mid', methods: ['GET'], domain: null }]);
154
- assert.equal(match(midtable, { method: 'GET', path: '/a/c' }), null, 'the middle parameter is required');
155
- assert.ok(match(midtable, { method: 'GET', path: '/a/x/c' }));
156
- // Laravel decodes the path BEFORE the route matches it (UriValidator), so an
157
- // encoded slash is a separator there: '/files/a%2Fb' is '/files/a/b' and does
158
- // not match a one-segment route.
159
- assert.equal(match(otable, { method: 'GET', path: '/files/a%2Fb' }), null, 'an encoded slash splits the path');
160
- // Symfony's requirement is `[^/]++` (one or more): an encoded trailing slash
161
- // ('/users/%2F' decodes to '/users//') leaves the group empty, and Symfony
162
- // refuses it - Laravel answers 404.
163
- assert.equal(match(otable, { method: 'GET', path: '/users/%2F' }), null, 'an empty optional parameter does not match');
164
-
165
- // method override (POST + header -> PUT)
166
- const mtable = normalizeTable([{ uri: '/things/1', name: 'update', methods: ['PUT'], domain: null }]);
167
- m = match(mtable, { method: 'POST', path: '/things/1', headers: { 'x-http-method-override': 'PUT' } });
168
- assert.ok(m, 'POST + override header should match PUT route');
169
- assert.equal(m.route.name, 'update');
170
- assert.equal(match(mtable, { method: 'DELETE', path: '/things/1', headers: { 'x-http-method-override': 'PUT' } }), null, 'override ignored for non-POST');
171
- m = match(mtable, { method: 'GET', path: '/things/1', methodOverride: 'PUT' });
172
- assert.ok(m, 'explicit methodOverride should work');
173
-
174
- // dispatch exposes effective method after override
175
- handlers['PUT /things/1'] = (ctx) => text(ctx.request.method);
176
- const r3 = await dispatch({ method: 'POST', path: '/things/1', headers: { 'x-http-method-override': 'PUT' } }, mtable, handlers);
177
- assert.ok(r3);
178
- assert.equal(r3.body, 'PUT');
179
-
180
- // ---- route-model binding ----
181
- class FakeBindModel {
182
- static query(db) {
183
- const chain = {
184
- _col: null,
185
- _val: null,
186
- where(col, val) { this._col = col; this._val = val; return this; },
187
- async first() {
188
- if (this._val === 'missing') return null;
189
- return { __col: this._col, __val: this._val };
190
- },
191
- };
192
- return chain;
193
- }
194
- }
195
-
196
- const btable = normalizeTable([
197
- { uri: '/posts/{post}', name: 'posts.show', methods: ['GET'], domain: null, bindings: { post: 'Post' } },
198
- ]);
199
- assert.equal(btable[0].bindings.post, 'Post');
200
-
201
- const bhandlers = {};
202
- let seenModel = null;
203
- let seenParams = null;
204
- bhandlers['GET /posts/{post}'] = (ctx) => {
205
- seenModel = ctx.models && ctx.models.post;
206
- seenParams = ctx.params;
207
- assert.equal(ctx.params.post, '42');
208
- return text('ok');
209
- };
210
- const bres = await dispatch({ method: 'GET', path: '/posts/42' }, btable, bhandlers, undefined, { models: { Post: FakeBindModel }, db: 'DB' });
211
- assert.equal(bres.status, 200);
212
- assert.ok(seenModel, 'handler should see resolved model');
213
- assert.equal(seenModel.__col, 'id');
214
- assert.equal(seenModel.__val, '42');
215
- assert.equal(seenParams.post, '42');
216
-
217
- // explicit column binding
218
- const ctable = normalizeTable([
219
- { uri: '/posts/{post}', name: 'posts.show', methods: ['GET'], domain: null, bindings: { post: { model: 'Post', column: 'slug' } } },
220
- ]);
221
- const chandlers = {};
222
- let cseen = null;
223
- chandlers['GET /posts/{post}'] = (ctx) => { cseen = ctx.models.post; return text('ok'); };
224
- const cres = await dispatch({ method: 'GET', path: '/posts/hello-slug' }, ctable, chandlers, undefined, { models: { Post: FakeBindModel }, db: 'DB' });
225
- assert.equal(cres.status, 200);
226
- assert.equal(cseen.__col, 'slug');
227
- assert.equal(cseen.__val, 'hello-slug');
228
-
229
- // missing record -> 404
230
- const mtable2 = normalizeTable([
231
- { uri: '/posts/{post}', name: 'posts.show', methods: ['GET'], domain: null, bindings: { post: 'Post' } },
232
- ]);
233
- const mhandlers = {};
234
- mhandlers['GET /posts/{post}'] = () => text('should not run');
235
- await assert.rejects(
236
- () => dispatch({ method: 'GET', path: '/posts/missing' }, mtable2, mhandlers, undefined, { models: { Post: FakeBindModel }, db: 'DB' }),
237
- (e) => e && e.status === 404,
238
- );
239
-
240
- // unknown model name -> 500
241
- const utable = normalizeTable([
242
- { uri: '/posts/{post}', name: 'posts.show', methods: ['GET'], domain: null, bindings: { post: 'Nope' } },
243
- ]);
244
- const uhandlers = {};
245
- uhandlers['GET /posts/{post}'] = () => text('should not run');
246
- await assert.rejects(
247
- () => dispatch({ method: 'GET', path: '/posts/42' }, utable, uhandlers, undefined, { models: { Post: FakeBindModel }, db: 'DB' }),
248
- (e) => e && e.status === 500,
249
- );
250
-
251
- // no options.models -> params unchanged, no throw, backward compatible
252
- const ntable = normalizeTable([
253
- { uri: '/posts/{post}', name: 'posts.show', methods: ['GET'], domain: null, bindings: { post: 'Post' } },
254
- ]);
255
- const nhandlers = {};
256
- let nseenModels = 'UNSET';
257
- let nseenParams = null;
258
- nhandlers['GET /posts/{post}'] = (ctx) => {
259
- nseenModels = ctx.models;
260
- nseenParams = ctx.params;
261
- return text('ok ' + ctx.params.post);
262
- };
263
- const nres = await dispatch({ method: 'GET', path: '/posts/42' }, ntable, nhandlers);
264
- assert.ok(nres, 'dispatch should not return null');
265
- assert.equal(nres.status, 200);
266
- assert.equal(nres.body, 'ok 42');
267
- assert.equal(nseenParams.post, '42');
268
- assert.equal(nseenModels, undefined);
269
-
270
- // route without bindings unaffected (even with models provided)
271
- const rtable = normalizeTable([
272
- { uri: '/health', name: 'health', methods: ['GET'], domain: null },
273
- ]);
274
- const rhandlers = {};
275
- rhandlers['GET /health'] = (ctx) => text('healthy ' + (ctx.params.id || ''));
276
- const rres = await dispatch({ method: 'GET', path: '/health' }, rtable, rhandlers, undefined, { models: { Post: FakeBindModel }, db: 'DB' });
277
- assert.ok(rres);
278
- assert.equal(rres.status, 200);
279
- assert.equal(rres.body, 'healthy ');
280
-
281
- console.log('ROUTER TESTS: PASS');
1
+ // edge/kernel/tests/router.test.mjs
2
+ import assert from 'node:assert/strict';
3
+ import { normalizeTable, routeKey, match, dispatch } from '../router.mjs';
4
+ import { html, json, redirect, notFound, text } from '../response.mjs';
5
+
6
+ const routes = [
7
+ { uri: 'up', name: null, methods: ['GET', 'HEAD'], domain: null },
8
+ { uri: '/', name: null, methods: ['GET', 'HEAD'], domain: null },
9
+ { uri: 'dbcheck', name: null, methods: ['GET', 'HEAD'], domain: null },
10
+ { uri: 'storage/{path}', name: 'storage.local', methods: ['GET', 'HEAD'], domain: null },
11
+ { uri: 'storage/{path}', name: 'storage.local.upload', methods: ['PUT'], domain: null },
12
+ ];
13
+
14
+ const table = normalizeTable(routes);
15
+
16
+ // basic: 5 routes normalized with leading slashes
17
+ assert.equal(table.length, 5);
18
+ assert.equal(table[0].uri, '/up');
19
+ assert.equal(table[3].uri, '/storage/{path}');
20
+
21
+ // routeKey
22
+ assert.equal(routeKey(table[0]), 'GET /up');
23
+
24
+ // HEAD matched by GET route
25
+ let m = match(table, { method: 'HEAD', path: '/up' });
26
+ assert.ok(m, 'HEAD /up should match');
27
+ assert.equal(m.route.name, null);
28
+
29
+ // trailing slash normalization
30
+ m = match(table, { method: 'GET', path: '/up/' });
31
+ assert.ok(m, 'trailing slash should normalize');
32
+ assert.equal(m.route.uri, '/up');
33
+
34
+ // query string stripped
35
+ m = match(table, { method: 'GET', path: '/dbcheck?foo=bar' });
36
+ assert.ok(m);
37
+ assert.equal(m.route.uri, '/dbcheck');
38
+
39
+ // param capture + decodeURIComponent
40
+ m = match(table, { method: 'GET', path: '/storage/hello%20world.txt' });
41
+ assert.ok(m);
42
+ assert.equal(m.params.path, 'hello world.txt');
43
+
44
+ // method distinction: PUT vs GET on same uri
45
+ m = match(table, { method: 'PUT', path: '/storage/x' });
46
+ assert.ok(m);
47
+ assert.equal(m.route.name, 'storage.local.upload');
48
+ m = match(table, { method: 'GET', path: '/storage/x' });
49
+ assert.ok(m);
50
+ assert.equal(m.route.name, 'storage.local');
51
+
52
+ // unknown path -> null
53
+ assert.equal(match(table, { method: 'GET', path: '/nope' }), null);
54
+
55
+ // unknown method -> null (DELETE not registered)
56
+ assert.equal(match(table, { method: 'DELETE', path: '/up' }), null);
57
+
58
+ // specificity / param: storage/{path} captures one segment only
59
+ m = match(table, { method: 'GET', path: '/storage/a/b' });
60
+ // {path} is one segment, so /storage/a/b should NOT match (b extra)
61
+ assert.equal(m, null, 'multi-segment should not match single {path}');
62
+
63
+ // dispatch: missing handler -> null
64
+ const handlers = {};
65
+ assert.equal(await dispatch({ method: 'GET', path: '/up' }, table, handlers), null);
66
+
67
+ // dispatch: with handler returns response
68
+ handlers['GET /up'] = (ctx) => html('hi ' + (ctx.params.x || ''), 200);
69
+ const r = await dispatch({ method: 'GET', path: '/up' }, table, handlers);
70
+ assert.deepEqual(r, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: 'hi ' });
71
+
72
+ // dispatch: unknown path -> null
73
+ assert.equal(await dispatch({ method: 'GET', path: '/nope' }, table, handlers), null);
74
+
75
+ // duplicate method+uri -> later wins
76
+ const dupTable = normalizeTable([
77
+ { uri: '/x', name: 'first', methods: ['GET'], domain: null },
78
+ { uri: '/x', name: 'second', methods: ['GET'], domain: null },
79
+ ]);
80
+ assert.equal(dupTable.length, 1);
81
+ assert.equal(dupTable[0].name, 'second');
82
+
83
+ // root path
84
+ m = match(table, { method: 'GET', path: '/' });
85
+ assert.ok(m);
86
+ assert.equal(m.route.uri, '/');
87
+
88
+ // response helpers
89
+ assert.deepEqual(html('a'), { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: 'a' });
90
+ assert.deepEqual(json({ b: 1 }), { status: 200, headers: { 'content-type': 'application/json' }, body: '{"b":1}' });
91
+ assert.deepEqual(redirect('/x'), { status: 302, headers: { location: '/x' }, body: '' });
92
+ assert.deepEqual(notFound(), { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: 'Not Found' });
93
+ assert.deepEqual(text('t', 201), { status: 201, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: 't' });
94
+
95
+ // header names lowercased by dispatch
96
+ handlers['GET /dbcheck'] = () => ({ status: 200, headers: { 'X-Foo': 'bar' }, body: 'ok' });
97
+ const r2 = await dispatch({ method: 'GET', path: '/dbcheck' }, table, handlers);
98
+ assert.equal(r2.headers['x-foo'], 'bar');
99
+
100
+ // wildcard {path*} captures multiple segments
101
+ const wtable = normalizeTable([{ uri: '/files/{path*}', name: 'files', methods: ['GET'], domain: null }]);
102
+ m = match(wtable, { method: 'GET', path: '/files/a/b/c.txt' });
103
+ assert.ok(m, 'wildcard should match multi-segment');
104
+ assert.equal(m.params.path, 'a/b/c.txt');
105
+
106
+ // wildcard must be the last segment
107
+ assert.throws(
108
+ () => match(normalizeTable([{ uri: '/{a*}/x', name: null, methods: ['GET'], domain: null }]), { method: 'GET', path: '/a/x' }),
109
+ /must be the last segment/,
110
+ );
111
+
112
+ // domain enforcement + domain param capture
113
+ const dtable = normalizeTable([
114
+ { uri: '/', name: 'root', methods: ['GET'], domain: null },
115
+ { uri: '/admin', name: 'admin', methods: ['GET'], domain: 'admin.example.com' },
116
+ { uri: '/tenant', name: 'tenant', methods: ['GET'], domain: '{sub}.example.com' },
117
+ ]);
118
+ m = match(dtable, { method: 'GET', path: '/admin', host: 'admin.example.com' });
119
+ assert.ok(m);
120
+ assert.equal(m.route.name, 'admin');
121
+ assert.equal(match(dtable, { method: 'GET', path: '/admin', host: 'www.example.com' }), null, 'wrong host must not match');
122
+ assert.equal(match(dtable, { method: 'GET', path: '/admin' }), null, 'missing host must not match domain route');
123
+ m = match(dtable, { method: 'GET', path: '/tenant', host: 'acme.example.com' });
124
+ assert.ok(m);
125
+ assert.equal(m.params.sub, 'acme');
126
+ m = match(dtable, { method: 'GET', path: '/admin', host: 'admin.example.com:8787' });
127
+ assert.ok(m, 'port must be stripped from host');
128
+
129
+ // an optional parameter: the separator belongs to the optional group
130
+ // (Symfony's RouteCompiler), so '/users/{id?}' also answers '/users'.
131
+ const otable = normalizeTable([
132
+ { uri: '/users/{id?}', name: 'users.show', methods: ['GET'], domain: null },
133
+ { uri: '/files/{path}', name: 'files.show', methods: ['GET'], domain: null },
134
+ ]);
135
+ m = match(otable, { method: 'GET', path: '/users' });
136
+ assert.ok(m, 'the optional segment may be absent');
137
+ assert.equal(m.route.name, 'users.show');
138
+ assert.equal(m.params.id, undefined, 'an absent optional parameter is not captured');
139
+ m = match(otable, { method: 'GET', path: '/users/5' });
140
+ assert.ok(m);
141
+ assert.equal(m.params.id, '5');
142
+ // the optional variable as the only token: the separator stays outside
143
+ // (Symfony's RouteCompiler) and the parameter may be absent
144
+ const onlytable = normalizeTable([{ uri: '/{id?}', name: 'root', methods: ['GET'], domain: null }]);
145
+ m = match(onlytable, { method: 'GET', path: '/' });
146
+ assert.ok(m, 'the root route matches');
147
+ assert.equal(m.params.id, undefined);
148
+ m = match(onlytable, { method: 'GET', path: '/7' });
149
+ assert.ok(m);
150
+ assert.equal(m.params.id, '7');
151
+ // an optional parameter that is not the last one is required (Symfony stops
152
+ // its first-optional scan at the text token after it)
153
+ const midtable = normalizeTable([{ uri: '/a/{b?}/c', name: 'mid', methods: ['GET'], domain: null }]);
154
+ assert.equal(match(midtable, { method: 'GET', path: '/a/c' }), null, 'the middle parameter is required');
155
+ assert.ok(match(midtable, { method: 'GET', path: '/a/x/c' }));
156
+ // Laravel decodes the path BEFORE the route matches it (UriValidator), so an
157
+ // encoded slash is a separator there: '/files/a%2Fb' is '/files/a/b' and does
158
+ // not match a one-segment route.
159
+ assert.equal(match(otable, { method: 'GET', path: '/files/a%2Fb' }), null, 'an encoded slash splits the path');
160
+ // Symfony's requirement is `[^/]++` (one or more): an encoded trailing slash
161
+ // ('/users/%2F' decodes to '/users//') leaves the group empty, and Symfony
162
+ // refuses it - Laravel answers 404.
163
+ assert.equal(match(otable, { method: 'GET', path: '/users/%2F' }), null, 'an empty optional parameter does not match');
164
+
165
+ // method override (POST + header -> PUT)
166
+ const mtable = normalizeTable([{ uri: '/things/1', name: 'update', methods: ['PUT'], domain: null }]);
167
+ m = match(mtable, { method: 'POST', path: '/things/1', headers: { 'x-http-method-override': 'PUT' } });
168
+ assert.ok(m, 'POST + override header should match PUT route');
169
+ assert.equal(m.route.name, 'update');
170
+ assert.equal(match(mtable, { method: 'DELETE', path: '/things/1', headers: { 'x-http-method-override': 'PUT' } }), null, 'override ignored for non-POST');
171
+ m = match(mtable, { method: 'GET', path: '/things/1', methodOverride: 'PUT' });
172
+ assert.ok(m, 'explicit methodOverride should work');
173
+
174
+ // dispatch exposes effective method after override
175
+ handlers['PUT /things/1'] = (ctx) => text(ctx.request.method);
176
+ const r3 = await dispatch({ method: 'POST', path: '/things/1', headers: { 'x-http-method-override': 'PUT' } }, mtable, handlers);
177
+ assert.ok(r3);
178
+ assert.equal(r3.body, 'PUT');
179
+
180
+ // ---- route-model binding ----
181
+ class FakeBindModel {
182
+ static query(db) {
183
+ const chain = {
184
+ _col: null,
185
+ _val: null,
186
+ where(col, val) { this._col = col; this._val = val; return this; },
187
+ async first() {
188
+ if (this._val === 'missing') return null;
189
+ return { __col: this._col, __val: this._val };
190
+ },
191
+ };
192
+ return chain;
193
+ }
194
+ }
195
+
196
+ const btable = normalizeTable([
197
+ { uri: '/posts/{post}', name: 'posts.show', methods: ['GET'], domain: null, bindings: { post: 'Post' } },
198
+ ]);
199
+ assert.equal(btable[0].bindings.post, 'Post');
200
+
201
+ const bhandlers = {};
202
+ let seenModel = null;
203
+ let seenParams = null;
204
+ bhandlers['GET /posts/{post}'] = (ctx) => {
205
+ seenModel = ctx.models && ctx.models.post;
206
+ seenParams = ctx.params;
207
+ assert.equal(ctx.params.post, '42');
208
+ return text('ok');
209
+ };
210
+ const bres = await dispatch({ method: 'GET', path: '/posts/42' }, btable, bhandlers, undefined, { models: { Post: FakeBindModel }, db: 'DB' });
211
+ assert.equal(bres.status, 200);
212
+ assert.ok(seenModel, 'handler should see resolved model');
213
+ assert.equal(seenModel.__col, 'id');
214
+ assert.equal(seenModel.__val, '42');
215
+ assert.equal(seenParams.post, '42');
216
+
217
+ // explicit column binding
218
+ const ctable = normalizeTable([
219
+ { uri: '/posts/{post}', name: 'posts.show', methods: ['GET'], domain: null, bindings: { post: { model: 'Post', column: 'slug' } } },
220
+ ]);
221
+ const chandlers = {};
222
+ let cseen = null;
223
+ chandlers['GET /posts/{post}'] = (ctx) => { cseen = ctx.models.post; return text('ok'); };
224
+ const cres = await dispatch({ method: 'GET', path: '/posts/hello-slug' }, ctable, chandlers, undefined, { models: { Post: FakeBindModel }, db: 'DB' });
225
+ assert.equal(cres.status, 200);
226
+ assert.equal(cseen.__col, 'slug');
227
+ assert.equal(cseen.__val, 'hello-slug');
228
+
229
+ // missing record -> 404
230
+ const mtable2 = normalizeTable([
231
+ { uri: '/posts/{post}', name: 'posts.show', methods: ['GET'], domain: null, bindings: { post: 'Post' } },
232
+ ]);
233
+ const mhandlers = {};
234
+ mhandlers['GET /posts/{post}'] = () => text('should not run');
235
+ await assert.rejects(
236
+ () => dispatch({ method: 'GET', path: '/posts/missing' }, mtable2, mhandlers, undefined, { models: { Post: FakeBindModel }, db: 'DB' }),
237
+ (e) => e && e.status === 404,
238
+ );
239
+
240
+ // unknown model name -> 500
241
+ const utable = normalizeTable([
242
+ { uri: '/posts/{post}', name: 'posts.show', methods: ['GET'], domain: null, bindings: { post: 'Nope' } },
243
+ ]);
244
+ const uhandlers = {};
245
+ uhandlers['GET /posts/{post}'] = () => text('should not run');
246
+ await assert.rejects(
247
+ () => dispatch({ method: 'GET', path: '/posts/42' }, utable, uhandlers, undefined, { models: { Post: FakeBindModel }, db: 'DB' }),
248
+ (e) => e && e.status === 500,
249
+ );
250
+
251
+ // no options.models -> params unchanged, no throw, backward compatible
252
+ const ntable = normalizeTable([
253
+ { uri: '/posts/{post}', name: 'posts.show', methods: ['GET'], domain: null, bindings: { post: 'Post' } },
254
+ ]);
255
+ const nhandlers = {};
256
+ let nseenModels = 'UNSET';
257
+ let nseenParams = null;
258
+ nhandlers['GET /posts/{post}'] = (ctx) => {
259
+ nseenModels = ctx.models;
260
+ nseenParams = ctx.params;
261
+ return text('ok ' + ctx.params.post);
262
+ };
263
+ const nres = await dispatch({ method: 'GET', path: '/posts/42' }, ntable, nhandlers);
264
+ assert.ok(nres, 'dispatch should not return null');
265
+ assert.equal(nres.status, 200);
266
+ assert.equal(nres.body, 'ok 42');
267
+ assert.equal(nseenParams.post, '42');
268
+ assert.equal(nseenModels, undefined);
269
+
270
+ // route without bindings unaffected (even with models provided)
271
+ const rtable = normalizeTable([
272
+ { uri: '/health', name: 'health', methods: ['GET'], domain: null },
273
+ ]);
274
+ const rhandlers = {};
275
+ rhandlers['GET /health'] = (ctx) => text('healthy ' + (ctx.params.id || ''));
276
+ const rres = await dispatch({ method: 'GET', path: '/health' }, rtable, rhandlers, undefined, { models: { Post: FakeBindModel }, db: 'DB' });
277
+ assert.ok(rres);
278
+ assert.equal(rres.status, 200);
279
+ assert.equal(rres.body, 'healthy ');
280
+
281
+ console.log('ROUTER TESTS: PASS');