mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-10 08:27:27 +00:00
* fix(routes): reject middleware and route metadata false positives * fix(routes): invalidate cached phantom routes on upgrade * test(routes): cover cache upgrades across merged schema versions Verify that schemas 137 through 140 invalidate both parse stores after merging main into PR #3561 and advancing the schema to 141. --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
1124 lines
43 KiB
TypeScript
1124 lines
43 KiB
TypeScript
/**
|
|
* Hand-rolled dispatch-guard route extraction.
|
|
*
|
|
* The gap this closes is a whole TOOL answering empty: `route_map` reported
|
|
* "No routes found in this project" for a repo with seventeen route modules,
|
|
* because every route extractor before this one needs a framework to declare
|
|
* the route. A raw `node:http` server declares it by comparing the path.
|
|
*
|
|
* The bar here is precision, not recall — `route_map` presents its output as
|
|
* fact, so a route that does not exist is worse than a route that is missing.
|
|
* Roughly half of these cases are therefore assertions that something is NOT
|
|
* extracted.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import Parser from 'tree-sitter';
|
|
import JavaScript from 'tree-sitter-javascript';
|
|
import TypeScript from 'tree-sitter-typescript';
|
|
import {
|
|
extractDispatchGuardRoutes,
|
|
reconcileDispatchGuardRoutes,
|
|
regexToRoutePath,
|
|
DISPATCH_GUARD_SOURCE,
|
|
} from '../../src/core/ingestion/route-extractors/dispatch-guard.js';
|
|
|
|
const parser = new Parser();
|
|
parser.setLanguage(JavaScript);
|
|
|
|
const extract = (source: string, filePath = 'src/server/routes.js') =>
|
|
extractDispatchGuardRoutes(parser.parse(source), filePath).map((r) => ({
|
|
routePath: r.routePath,
|
|
httpMethod: r.httpMethod,
|
|
handlerName: r.handlerName,
|
|
source: r.source,
|
|
}));
|
|
|
|
const paths = (source: string): string[] => extract(source).map((r) => r.routePath);
|
|
|
|
describe('dispatch-guard route extraction', () => {
|
|
describe('the dominant idiom', () => {
|
|
it('extracts a verb-qualified path comparison', () => {
|
|
const routes = extract(`
|
|
export async function handle(req, res, reqCtx) {
|
|
const { pathname } = reqCtx
|
|
if (req.method === 'GET' && pathname === '/api/live/portfolio') {
|
|
return sendJson(res, await loadPortfolio())
|
|
}
|
|
}
|
|
`);
|
|
expect(routes).toEqual([
|
|
{
|
|
routePath: '/api/live/portfolio',
|
|
httpMethod: 'GET',
|
|
handlerName: 'handle',
|
|
source: DISPATCH_GUARD_SOURCE,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('reads the verb when the comparison order is reversed', () => {
|
|
expect(
|
|
extract(`
|
|
function handle(req) {
|
|
if ('POST' === req.method && '/api/orders' === pathname) { return 1 }
|
|
}
|
|
`),
|
|
).toMatchObject([{ routePath: '/api/orders', httpMethod: 'POST' }]);
|
|
});
|
|
|
|
it('distributes an outer verb across an inner OR of paths', () => {
|
|
const routes = extract(`
|
|
function handle(req) {
|
|
if (req.method === 'GET' && (pathname === '/api/a' || pathname === '/api/b')) { return 1 }
|
|
}
|
|
`);
|
|
expect(routes).toMatchObject([
|
|
{ routePath: '/api/a', httpMethod: 'GET' },
|
|
{ routePath: '/api/b', httpMethod: 'GET' },
|
|
]);
|
|
});
|
|
|
|
it('inherits a verb from an ENCLOSING if, not just a sibling', () => {
|
|
expect(
|
|
extract(`
|
|
function handle(req) {
|
|
if (req.method === 'DELETE') {
|
|
if (pathname === '/api/session') { return 1 }
|
|
}
|
|
}
|
|
`),
|
|
).toMatchObject([{ routePath: '/api/session', httpMethod: 'DELETE' }]);
|
|
});
|
|
|
|
// The inverted case, and the reason the ancestor walk tracks which branch it
|
|
// came from: in the `else`, the method is precisely NOT POST, so inheriting
|
|
// POST would label the route with the one verb it cannot have.
|
|
it('refuses to inherit a verb from an if whose ELSE branch holds the comparison', () => {
|
|
const routes = extract(`
|
|
function handle(req) {
|
|
if (req.method === 'POST') {
|
|
save()
|
|
} else if (pathname === '/api/report') {
|
|
return 1
|
|
}
|
|
}
|
|
`);
|
|
expect(routes).toMatchObject([{ routePath: '/api/report', httpMethod: '' }]);
|
|
});
|
|
|
|
it('extracts a verb-less path guard', () => {
|
|
expect(
|
|
extract(`
|
|
function match(method, pathname) {
|
|
return pathname === '/api/health'
|
|
}
|
|
`),
|
|
).toMatchObject([{ routePath: '/api/health', httpMethod: '', handlerName: 'match' }]);
|
|
});
|
|
});
|
|
|
|
describe('what must NOT become a route', () => {
|
|
it('ignores comparisons against stored route metadata', () => {
|
|
expect(paths("function f(route) { return route.routePath === '/comparison-ghost' }")).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
|
|
it('ignores a comparison against something that is not a request path', () => {
|
|
// `mode` is not a path expression, so `/full` is just a string.
|
|
expect(paths(`function f() { if (mode === '/full') { return 1 } }`)).toEqual([]);
|
|
});
|
|
|
|
it('ignores a path-shaped literal compared to a filesystem path variable', () => {
|
|
// `path` is excluded on purpose — in Node it is overwhelmingly node:path
|
|
// or a file location, never the request path.
|
|
expect(paths(`function f() { if (path === '/tmp/cache') { return 1 } }`)).toEqual([]);
|
|
});
|
|
|
|
it('ignores startsWith namespace tests', () => {
|
|
// A prefix test asks "do I own this?" — minting `/api/` would claim a
|
|
// route nothing serves.
|
|
expect(
|
|
paths(`function f() { if (pathname.startsWith('/api/')) { return route(pathname) } }`),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('ignores a bare "/" normalisation with no verb', () => {
|
|
// The static-file idiom, verbatim from the reporting repo.
|
|
expect(
|
|
paths(`function serve() { const file = pathname === '/' ? '/index.html' : pathname }`),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('DOES extract a bare "/" when a verb makes the intent unambiguous', () => {
|
|
expect(
|
|
extract(
|
|
`function handle(req) { if (req.method === 'GET' && pathname === '/') { return 1 } }`,
|
|
),
|
|
).toMatchObject([{ routePath: '/', httpMethod: 'GET' }]);
|
|
});
|
|
|
|
it('ignores a non-equality comparison', () => {
|
|
expect(paths(`function f() { if (pathname !== '/api/health') { return 1 } }`)).toEqual([]);
|
|
});
|
|
|
|
it('ignores an absolute URL', () => {
|
|
expect(
|
|
paths(`function f() { if (pathname === 'https://x.test/api/a') { return 1 } }`),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('ignores a template string whose substitution is not a known constant', () => {
|
|
expect(paths('function f() { if (pathname === `/api/${id}`) { return 1 } }')).toEqual([]);
|
|
});
|
|
|
|
it('ignores a verb literal that is not an HTTP verb', () => {
|
|
const routes = extract(`
|
|
function handle(req) {
|
|
if (req.method === 'SUBSCRIBE' && pathname === '/api/feed') { return 1 }
|
|
}
|
|
`);
|
|
expect(routes).toMatchObject([{ routePath: '/api/feed', httpMethod: '' }]);
|
|
});
|
|
});
|
|
|
|
// BOOLEAN POLARITY. The module refuses to inherit a verb from an `if` whose
|
|
// `else` branch holds the comparison, because that branch runs precisely when
|
|
// the condition did NOT hold. `!` is the same fact written as an operator, and
|
|
// it was not handled — a stated invariant with half an implementation, which
|
|
// is worse than an absent one because the doc comment reads as covered.
|
|
//
|
|
// Every case below was reproduced against the unguarded extractor before the
|
|
// fix: `!(path)` INVENTED a route, and `!(verb) && path` emitted the one verb
|
|
// the branch guarantees the request does not have.
|
|
describe('negated conditions', () => {
|
|
it('claims nothing when the path comparison is negated', () => {
|
|
expect(paths(`function h(req) { if (!(pathname === '/api/admin')) { return 1 } }`)).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
|
|
it('claims nothing when the whole guard is negated', () => {
|
|
expect(
|
|
paths(
|
|
`function h(req) { if (!(req.method === 'POST' && pathname === '/api/w')) { return 1 } }`,
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('keeps the path but drops a NEGATED verb rather than inverting it', () => {
|
|
// The path is still evidence — this branch is reached for `/api/x`. The
|
|
// verb is not: `!(method === 'GET')` says every method EXCEPT GET, which
|
|
// no single value can express, so the honest answer is verb-less.
|
|
expect(
|
|
extract(
|
|
`function h(req) { if (!(req.method === 'GET') && pathname === '/api/x') { return 1 } }`,
|
|
),
|
|
).toMatchObject([{ routePath: '/api/x', httpMethod: '' }]);
|
|
});
|
|
|
|
it('treats double negation as positive', () => {
|
|
// PARITY, not presence. A rule keyed on "is there a `!` above me" would
|
|
// refuse this, which is a real route.
|
|
expect(paths(`function h(req) { if (!!(pathname === '/api/z')) { return 1 } }`)).toEqual([
|
|
'/api/z',
|
|
]);
|
|
});
|
|
|
|
it('does not let an outer negation leak into the branch BODY', () => {
|
|
// Polarity is a property of the expression, not of the statements a branch
|
|
// contains: the inner comparison is positive where it is written.
|
|
expect(
|
|
paths(`
|
|
function h(req) {
|
|
if (!(req.method === 'GET')) {
|
|
if (pathname === '/api/inner') { return 1 }
|
|
}
|
|
}
|
|
`),
|
|
).toEqual(['/api/inner']);
|
|
});
|
|
|
|
it('negates a regex path test too', () => {
|
|
expect(
|
|
paths(`function h(req) { if (!/^\\/api\\/runs\\/[^/]+$/.test(pathname)) { return 1 } }`),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('reads a doubly-negated VERB, not just a doubly-negated path', () => {
|
|
// The parity rule was stated for `!` but the verb walk refused on the mere
|
|
// PRESENCE of one, so this lost a verb the source states outright. The
|
|
// path-position case above passed throughout and hid it.
|
|
expect(
|
|
extract(
|
|
`function h(req) { if (!!(req.method === 'GET') && pathname === '/api/dn') { return 1 } }`,
|
|
),
|
|
).toMatchObject([{ routePath: '/api/dn', httpMethod: 'GET' }]);
|
|
});
|
|
});
|
|
|
|
// A ternary SELECTS between its arms, so a verb inside one is not reached
|
|
// merely because the whole condition is truthy. Reproduced against the
|
|
// extractor before fixing: the first case emitted `GET /api/i`, the one method
|
|
// that branch guarantees the request does NOT have — the same inversion `!`
|
|
// produced before it was handled, one level up.
|
|
//
|
|
// The last three cases were ALREADY correct and are here to pin them: refusing
|
|
// every ternary would fix the bug and silently drop three real verbs.
|
|
describe('ternary polarity', () => {
|
|
const guard = (cond: string, path = '/api/i') =>
|
|
extract(`function h(req) { if (${cond} && pathname === '${path}') { return 1 } }`);
|
|
|
|
it('drops the verb when a ternary INVERTS it', () => {
|
|
// `c ? false : true` is `!c`: the branch runs for every method except GET.
|
|
expect(guard(`(req.method === 'GET' ? false : true)`)).toMatchObject([
|
|
{ routePath: '/api/i', httpMethod: '' },
|
|
]);
|
|
});
|
|
|
|
it('keeps the verb when a ternary is the identity', () => {
|
|
// `c ? true : false` is `c`. Verb-less would be safe but wrong to settle for.
|
|
expect(guard(`(req.method === 'GET' ? true : false)`)).toMatchObject([
|
|
{ routePath: '/api/i', httpMethod: 'GET' },
|
|
]);
|
|
});
|
|
|
|
it('keeps a verb in the consequence when the alternative is `false`', () => {
|
|
// `c ? A : false` is `c && A` — reaching the body requires BOTH.
|
|
expect(guard(`(isAdmin ? req.method === 'GET' : false)`)).toMatchObject([
|
|
{ routePath: '/api/i', httpMethod: 'GET' },
|
|
]);
|
|
});
|
|
|
|
it('keeps a verb in the alternative when the consequence is `false`', () => {
|
|
// `c ? false : B` is `!c && B`.
|
|
expect(guard(`(isAdmin ? false : req.method === 'GET')`)).toMatchObject([
|
|
{ routePath: '/api/i', httpMethod: 'GET' },
|
|
]);
|
|
});
|
|
|
|
it('claims no verb when both arms are live comparisons', () => {
|
|
// Which verb this serves depends on `isAdmin`, so naming either is a guess.
|
|
expect(guard(`(isAdmin ? req.method === 'GET' : req.method === 'POST')`)).toMatchObject([
|
|
{ routePath: '/api/i', httpMethod: '' },
|
|
]);
|
|
});
|
|
|
|
it('claims no verb when a `true` arm makes the ternary a disjunction', () => {
|
|
// `c ? true : B` is `c || B`: the body is also reached for any method when
|
|
// `isAdmin` holds, so `GET` would present a broader route as a narrow one.
|
|
expect(guard(`(req.method === 'GET' ? true : isAdmin)`)).toMatchObject([
|
|
{ routePath: '/api/i', httpMethod: '' },
|
|
]);
|
|
});
|
|
|
|
it('claims no verb when the whole ternary is negated', () => {
|
|
// `!(c ? A : false)` is `!(c && A)`, i.e. `!c || !A` — a disjunction, which
|
|
// guarantees nothing. Without the parity guard the conjunction rule would
|
|
// read `GET` straight out of the consequence and invert it exactly as the
|
|
// un-negated form did.
|
|
expect(guard(`!(isAdmin ? req.method === 'GET' : false)`, '/api/n')).toMatchObject([
|
|
{ routePath: '/api/n', httpMethod: '' },
|
|
]);
|
|
});
|
|
|
|
// `c ? A : false` is `c && A`, and the methods a CONJUNCTION guarantees are
|
|
// the ones both sides admit — their intersection. Taking the first side that
|
|
// named a verb reported one operand's set unintersected, which is how a verb
|
|
// the guard excludes got minted as a route of its own.
|
|
it('INTERSECTS the two sides of the conjunction instead of taking the first', () => {
|
|
// {GET,POST} ∩ {POST,PUT} is POST alone. GET reaches the ternary but not
|
|
// its consequence, so `GET /api/t1` was a route no request can take.
|
|
expect(
|
|
guard(
|
|
`((req.method === 'GET' || req.method === 'POST') ? (req.method === 'POST' || req.method === 'PUT') : false)`,
|
|
'/api/t1',
|
|
),
|
|
).toMatchObject([{ routePath: '/api/t1', httpMethod: 'POST' }]);
|
|
});
|
|
|
|
it('claims no verb when the two sides cannot both hold', () => {
|
|
// `GET && POST` is unsatisfiable — no method satisfies this guard, so
|
|
// naming either side invents a route. Verb-less is the honest answer, and
|
|
// the path itself is still proven.
|
|
expect(
|
|
guard(`(req.method === 'GET' ? req.method === 'POST' : false)`, '/api/t2'),
|
|
).toMatchObject([{ routePath: '/api/t2', httpMethod: '' }]);
|
|
});
|
|
|
|
it('intersects the other conjunction too, at flipped parity', () => {
|
|
// `c ? false : B` is `!c && B`. With `c` = `!(method === 'GET')` the guard
|
|
// reads `GET && POST` — the same contradiction, reached through the arm
|
|
// that searches the condition negated.
|
|
expect(
|
|
guard(`(!(req.method === 'GET') ? false : req.method === 'POST')`, '/api/t4'),
|
|
).toMatchObject([{ routePath: '/api/t4', httpMethod: '' }]);
|
|
});
|
|
|
|
it('still reads a conjunction where only ONE side names a verb', () => {
|
|
// The fallthrough the intersection replaces stays right when a side is
|
|
// simply silent about the method: `isReady && POST` serves POST. An empty
|
|
// side means "names no method", not "admits none".
|
|
expect(guard(`(isReady ? req.method === 'POST' : false)`, '/api/t3')).toMatchObject([
|
|
{ routePath: '/api/t3', httpMethod: 'POST' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
// One guard, several methods. Verbatim from the reporting repo:
|
|
// if ((req.method === 'GET' || req.method === 'POST') && bundlesMatch) { … }
|
|
// Taking the FIRST verb reported this as GET-only, so `route_map` presented a
|
|
// route open to two methods as restricted to one, and `impact` on the POST
|
|
// path found nothing.
|
|
describe('multi-method guards', () => {
|
|
const guard = (cond: string) =>
|
|
extract(`function h(req) { if (${cond} && pathname === '/api/i') { return 1 } }`).map(
|
|
(r) => r.httpMethod,
|
|
);
|
|
|
|
it('emits one route per method in a verb disjunction', () => {
|
|
expect(guard(`(req.method === 'GET' || req.method === 'POST')`)).toEqual(['GET', 'POST']);
|
|
});
|
|
|
|
it('handles more than two', () => {
|
|
expect(
|
|
guard(`(req.method === 'GET' || req.method === 'POST' || req.method === 'PUT')`),
|
|
).toEqual(['GET', 'POST', 'PUT']);
|
|
});
|
|
|
|
it('claims NO verb when a disjunct is not a verb test', () => {
|
|
// `GET || isAdmin` is reached for ANY method when `isAdmin` holds. Naming
|
|
// GET would describe a route open to everything as single-method — the
|
|
// direction this module treats as more expensive than saying nothing.
|
|
expect(guard(`(req.method === 'GET' || isAdmin)`)).toEqual(['']);
|
|
});
|
|
|
|
it('claims no verb when the disjunction is negated', () => {
|
|
// `!(GET || POST)` excludes both rather than offering either.
|
|
expect(guard(`!(req.method === 'GET' || req.method === 'POST')`)).toEqual(['']);
|
|
});
|
|
|
|
it('still distributes ONE verb across an OR of paths', () => {
|
|
// The pre-existing rule, pinned against the disjunction change: here the
|
|
// `||` joins PATHS, not verbs, and must not start multiplying methods.
|
|
expect(
|
|
extract(`
|
|
function h(req) {
|
|
if (req.method === 'GET' && (pathname === '/api/a' || pathname === '/api/b')) { return 1 }
|
|
}
|
|
`),
|
|
).toMatchObject([
|
|
{ routePath: '/api/a', httpMethod: 'GET' },
|
|
{ routePath: '/api/b', httpMethod: 'GET' },
|
|
]);
|
|
});
|
|
|
|
it('gives every switch arm the full method set', () => {
|
|
expect(
|
|
extract(`
|
|
function h(req) {
|
|
if (req.method === 'GET' || req.method === 'POST') {
|
|
switch (pathname) {
|
|
case '/api/a': return 1
|
|
case '/api/b': return 2
|
|
}
|
|
}
|
|
}
|
|
`),
|
|
).toMatchObject([
|
|
{ routePath: '/api/a', httpMethod: 'GET' },
|
|
{ routePath: '/api/a', httpMethod: 'POST' },
|
|
{ routePath: '/api/b', httpMethod: 'GET' },
|
|
{ routePath: '/api/b', httpMethod: 'POST' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
// Not in any report — the same dispatch written with different syntax. A
|
|
// graph that waits for a bug report per shape stays permanently one idiom
|
|
// behind the code it indexes.
|
|
describe('switch dispatch', () => {
|
|
it('extracts every string-literal case of a switch on the path', () => {
|
|
expect(
|
|
extract(`
|
|
function handle(req, pathname) {
|
|
switch (pathname) {
|
|
case '/api/health': return ok()
|
|
case '/api/version': return version()
|
|
default: return notFound()
|
|
}
|
|
}
|
|
`),
|
|
).toMatchObject([
|
|
{ routePath: '/api/health', httpMethod: '', handlerName: 'handle' },
|
|
{ routePath: '/api/version', httpMethod: '', handlerName: 'handle' },
|
|
]);
|
|
});
|
|
|
|
it('applies a verb governing the whole switch to every arm', () => {
|
|
expect(
|
|
extract(`
|
|
function handle(req, pathname) {
|
|
if (req.method === 'POST') {
|
|
switch (pathname) {
|
|
case '/api/a': return a()
|
|
case '/api/b': return b()
|
|
}
|
|
}
|
|
}
|
|
`),
|
|
).toMatchObject([
|
|
{ routePath: '/api/a', httpMethod: 'POST' },
|
|
{ routePath: '/api/b', httpMethod: 'POST' },
|
|
]);
|
|
});
|
|
|
|
it('ignores a switch on something that is not a request path', () => {
|
|
// The file must mention a path token, or PATH_TOKEN_HINT skips it before
|
|
// the discriminant rule is ever consulted and this asserts nothing. The
|
|
// real route below is the proof the walk ran.
|
|
expect(
|
|
paths(`
|
|
function f(kind, pathname) {
|
|
switch (kind) { case '/full': return 1 }
|
|
if (pathname === '/api/real') { return 2 }
|
|
}
|
|
`),
|
|
).toEqual(['/api/real']);
|
|
});
|
|
|
|
it('ignores non-path cases in a switch that is on the path', () => {
|
|
expect(
|
|
paths(`
|
|
function handle(pathname) {
|
|
switch (pathname) {
|
|
case '/api/a': return 1
|
|
case 'unknown': return 2
|
|
}
|
|
}
|
|
`),
|
|
).toEqual(['/api/a']);
|
|
});
|
|
});
|
|
|
|
// A composed path is not an exotic shape — one of the reporting repo's
|
|
// seventeen route modules writes every one of its ~20 routes this way, and
|
|
// without folding that file contributes NOTHING while looking exactly like a
|
|
// file that has no routes.
|
|
describe('paths composed from same-file constants', () => {
|
|
it('folds a template substitution naming a module-level constant', () => {
|
|
expect(
|
|
extract(
|
|
'const BASE = "/api/live/auto-trade"\n' +
|
|
'function handle(req) {\n' +
|
|
' if (req.method === "GET" && pathname === `${BASE}/rules`) { return 1 }\n' +
|
|
'}',
|
|
),
|
|
).toMatchObject([{ routePath: '/api/live/auto-trade/rules', httpMethod: 'GET' }]);
|
|
});
|
|
|
|
it('follows an alias hop, which is how the reporting repo writes it', () => {
|
|
// `const autoTradeBasePath = AUTO_TRADE_BASE_PATH` inside the handler,
|
|
// with the literal at module scope.
|
|
expect(
|
|
paths(
|
|
'const AUTO_TRADE_BASE_PATH = "/api/live/auto-trade"\n' +
|
|
'function handle(req) {\n' +
|
|
' const autoTradeBasePath = AUTO_TRADE_BASE_PATH\n' +
|
|
' if (pathname === `${autoTradeBasePath}/positions`) { return 1 }\n' +
|
|
'}',
|
|
),
|
|
).toEqual(['/api/live/auto-trade/positions']);
|
|
});
|
|
|
|
it('folds + concatenation', () => {
|
|
expect(
|
|
paths(
|
|
'const BASE = "/api/v2"\n' +
|
|
'function handle() { if (pathname === BASE + "/orders") { return 1 } }',
|
|
),
|
|
).toEqual(['/api/v2/orders']);
|
|
});
|
|
|
|
it('folds a bare constant with no suffix', () => {
|
|
expect(
|
|
paths(
|
|
'const HEALTH = "/api/health"\nfunction handle() { if (pathname === HEALTH) { return 1 } }',
|
|
),
|
|
).toEqual(['/api/health']);
|
|
});
|
|
|
|
// The refusals. A partially-folded path is a WRONG route, and a wrong route
|
|
// is worse than a missing one — the whole premise of this module.
|
|
it('refuses a name declared twice with different values', () => {
|
|
expect(
|
|
paths(
|
|
'const BASE = "/api/a"\n' +
|
|
'function other() { const BASE = "/api/b"; return BASE }\n' +
|
|
'function handle() { if (pathname === `${BASE}/x`) { return 1 } }',
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('refuses when only part of the template resolves', () => {
|
|
expect(
|
|
paths(
|
|
'const BASE = "/api"\n' +
|
|
'function handle(id) { if (pathname === `${BASE}/x/${id}`) { return 1 } }',
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('refuses a constant bound to a call result', () => {
|
|
expect(
|
|
paths(
|
|
'const BASE = buildBase()\nfunction handle() { if (pathname === `${BASE}/x`) { return 1 } }',
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('still rejects a folded value that is not path-shaped', () => {
|
|
expect(
|
|
paths(
|
|
'const MODE = "full"\nfunction handle() { if (pathname === `${MODE}/x`) { return 1 } }',
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('parameterised routes from anchored regexes', () => {
|
|
it('converts a single-segment wildcard to a named parameter', () => {
|
|
expect(
|
|
extract(`
|
|
function handle(req) {
|
|
if (req.method === 'GET' && /^\\/api\\/research-runs\\/[^/]+$/.test(pathname)) { return 1 }
|
|
}
|
|
`),
|
|
).toMatchObject([{ routePath: '/api/research-runs/{param1}', httpMethod: 'GET' }]);
|
|
});
|
|
|
|
it('numbers multiple parameters in order', () => {
|
|
expect(regexToRoutePath('^\\/api\\/runs\\/[^/]+\\/experiments\\/[^/]+$')).toBe(
|
|
'/api/runs/{param1}/experiments/{param2}',
|
|
);
|
|
});
|
|
|
|
it('accepts an escaped slash inside the wildcard class', () => {
|
|
expect(regexToRoutePath('^\\/api\\/x\\/[^\\/]+$')).toBe('/api/x/{param1}');
|
|
});
|
|
|
|
// Bail cases. A route path is a claim about what the server serves, so a
|
|
// pattern that cannot be translated exactly is dropped, not approximated.
|
|
it('refuses an unanchored pattern', () => {
|
|
expect(regexToRoutePath('\\/api\\/x')).toBeNull();
|
|
expect(regexToRoutePath('^\\/api\\/x')).toBeNull();
|
|
});
|
|
|
|
it('refuses an optional group', () => {
|
|
expect(regexToRoutePath('^\\/api\\/runs\\/[^/]+\\/artifacts(?:\\/.*)?$')).toBeNull();
|
|
});
|
|
|
|
it('refuses an alternation and a bare wildcard', () => {
|
|
expect(regexToRoutePath('^\\/api\\/(a|b)$')).toBeNull();
|
|
expect(regexToRoutePath('^\\/api\\/.*$')).toBeNull();
|
|
});
|
|
|
|
it('refuses a character-class escape', () => {
|
|
expect(regexToRoutePath('^\\/api\\/runs\\/\\d+$')).toBeNull();
|
|
});
|
|
|
|
it('ignores a regex tested against something that is not a request path', () => {
|
|
expect(paths(`function f() { if (/^\\/api\\/x$/.test(filename)) { return 1 } }`)).toEqual([]);
|
|
});
|
|
|
|
// The form every real dispatcher writes, and the one this converter refused.
|
|
// `(` fell through to the metacharacter bail, so the capturing pattern
|
|
// translated to nothing while its non-capturing twin translated fine — which
|
|
// is exactly why every test above passed. The reporting repo does not contain
|
|
// a single non-capturing path wildcard: a dispatcher captures the segment
|
|
// because it needs the id.
|
|
it('converts a CAPTURING single-segment wildcard', () => {
|
|
expect(regexToRoutePath('^\\/api\\/research-runs\\/([^/]+)$')).toBe(
|
|
'/api/research-runs/{param1}',
|
|
);
|
|
});
|
|
|
|
it('converts a capturing wildcard followed by more literal path', () => {
|
|
expect(regexToRoutePath('^\\/api\\/live\\/positions\\/([^/]+)\\/replay$')).toBe(
|
|
'/api/live/positions/{param1}/replay',
|
|
);
|
|
});
|
|
|
|
it('still refuses a capture group around anything that is not one segment', () => {
|
|
// `.+` spans slashes, so it is not a single segment and cannot be one
|
|
// `{param}`. Accepting `(` must not mean accepting every group.
|
|
expect(regexToRoutePath('^\\/api\\/x\\/(.+)$')).toBeNull();
|
|
expect(regexToRoutePath('^\\/api\\/x\\/(a|b)$')).toBeNull();
|
|
});
|
|
|
|
it('refuses an unbalanced capture group', () => {
|
|
// A stray `)` would otherwise be read as a literal path character.
|
|
expect(regexToRoutePath('^\\/api\\/x\\/([^/]+$')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// `RE.test(pathname)` and `pathname.match(RE)` are the same test with the
|
|
// operands swapped. Only `.test` was read, which is why 28 of the reporting
|
|
// repo's 75 routes still named the shared route table as their handler: their
|
|
// modules dispatch with `.match`.
|
|
//
|
|
// `.match` differs in one way that matters — its result is USED, so it is
|
|
// almost always BOUND, and the verb then lives in a later `if` rather than
|
|
// around the call.
|
|
describe('bound .match() dispatch', () => {
|
|
const RUNS = `/^\\/api\\/research-runs\\/([^/]+)$/`;
|
|
|
|
it('reads the verb from where the binding is TESTED, not where it is bound', () => {
|
|
expect(
|
|
extract(`
|
|
function handle(req) {
|
|
const runMatch = pathname.match(${RUNS})
|
|
if (req.method === 'GET' && runMatch) { return runMatch[1] }
|
|
}
|
|
`),
|
|
).toMatchObject([
|
|
{ routePath: '/api/research-runs/{param1}', httpMethod: 'GET', handlerName: 'handle' },
|
|
]);
|
|
});
|
|
|
|
it('emits a route per method for a multi-method bound match', () => {
|
|
// Verbatim shape from researchRunRoutes.js.
|
|
expect(
|
|
extract(`
|
|
function handle(req) {
|
|
const bundlesMatch = pathname.match(/^\\/api\\/runs\\/([^/]+)\\/bundles$/)
|
|
if ((req.method === 'GET' || req.method === 'POST') && bundlesMatch) { return 1 }
|
|
}
|
|
`).map((r) => r.httpMethod),
|
|
).toEqual(['GET', 'POST']);
|
|
});
|
|
|
|
it('emits once per TEST SITE, not once per capture read', () => {
|
|
// `m[1]` is a read of the captured segment. It says nothing about
|
|
// dispatch, and counting it would mint a duplicate route per use of the id.
|
|
expect(
|
|
extract(`
|
|
function handle(req) {
|
|
const m = pathname.match(/^\\/api\\/w\\/([^/]+)$/)
|
|
if (req.method === 'GET' && m) { return [m[1], m[2], m[1]] }
|
|
}
|
|
`),
|
|
).toMatchObject([{ routePath: '/api/w/{param1}', httpMethod: 'GET' }]);
|
|
});
|
|
|
|
it('emits a route per test site when one binding is tested for two methods', () => {
|
|
expect(
|
|
extract(`
|
|
function handle(req) {
|
|
const m = pathname.match(/^\\/api\\/t\\/([^/]+)$/)
|
|
if (req.method === 'GET' && m) { return 1 }
|
|
if (req.method === 'PUT' && m) { return 2 }
|
|
}
|
|
`).map((r) => r.httpMethod),
|
|
).toEqual(['GET', 'PUT']);
|
|
});
|
|
|
|
it('does not inherit a verb across a NEGATED guard clause', () => {
|
|
// `if (!m) return` is the early-out. The `if (method === 'GET')` after it
|
|
// governs the rest of the function, not this binding's test.
|
|
expect(
|
|
extract(`
|
|
function handle(req) {
|
|
const m = pathname.match(/^\\/api\\/z\\/([^/]+)$/)
|
|
if (!m) { return false }
|
|
if (req.method === 'GET') { return 1 }
|
|
}
|
|
`),
|
|
).toMatchObject([{ routePath: '/api/z/{param1}', httpMethod: '' }]);
|
|
});
|
|
|
|
it('keeps the path when a binding is never tested', () => {
|
|
// The code still computed an anchored match against the request path —
|
|
// the same evidence an unbound `.test` carries.
|
|
expect(
|
|
extract(`
|
|
function handle(req) {
|
|
const m = pathname.match(/^\\/api\\/y\\/([^/]+)$/)
|
|
return m[1]
|
|
}
|
|
`),
|
|
).toMatchObject([{ routePath: '/api/y/{param1}', httpMethod: '' }]);
|
|
});
|
|
|
|
it('reads an UNBOUND .match like a .test', () => {
|
|
expect(
|
|
extract(
|
|
`function handle(req) { if (req.method === 'GET' && pathname.match(/^\\/api\\/x$/)) { return 1 } }`,
|
|
),
|
|
).toMatchObject([{ routePath: '/api/x', httpMethod: 'GET' }]);
|
|
});
|
|
|
|
it('resolves a regex named by a same-file const, both ways round', () => {
|
|
// positionReplayRoutes.js declares the pattern once and uses it both ways.
|
|
const re = `const RE = /^\\/api\\/positions\\/([^/]+)\\/replay$/`;
|
|
expect(
|
|
extract(`
|
|
${re}
|
|
function handle(req) {
|
|
const routeMatch = pathname.match(RE)
|
|
if (req.method === 'DELETE' && routeMatch) { return 1 }
|
|
}
|
|
`),
|
|
).toMatchObject([{ routePath: '/api/positions/{param1}/replay', httpMethod: 'DELETE' }]);
|
|
expect(
|
|
extract(`
|
|
${re}
|
|
function handle(req) { if (req.method === 'GET' && RE.test(pathname)) { return 1 } }
|
|
`),
|
|
).toMatchObject([{ routePath: '/api/positions/{param1}/replay', httpMethod: 'GET' }]);
|
|
});
|
|
|
|
it('refuses .match on a receiver that is not a request path', () => {
|
|
// The genuine route alongside it is load-bearing, NOT decoration: without
|
|
// a path token somewhere in the file, PATH_TOKEN_HINT skips the walk
|
|
// entirely and this assertion is satisfied by a file that was never
|
|
// examined. It proves the file WAS processed and `userAgent` was refused
|
|
// on its merits.
|
|
expect(
|
|
paths(`
|
|
function handle(req) {
|
|
const m = userAgent.match(/^\\/api\\/nope$/)
|
|
if (req.method === 'GET' && m) { return 1 }
|
|
if (req.method === 'GET' && pathname === '/api/real') { return 2 }
|
|
}
|
|
`),
|
|
).toEqual(['/api/real']);
|
|
});
|
|
|
|
it('claims no verb when the test site itself sits under a negation', () => {
|
|
// `if (!(method === 'GET' && m))` runs precisely when the path did NOT
|
|
// match, so attributing GET is backwards. `!m` alone never reaches this
|
|
// check — a `unary_expression` parent is not a truthiness position to
|
|
// begin with — so the wrapped conjunction is the shape that exercises it.
|
|
expect(
|
|
extract(`
|
|
function handle(req) {
|
|
const m = pathname.match(/^\\/api\\/n\\/([^/]+)$/)
|
|
if (!(req.method === 'GET' && m)) { return false }
|
|
}
|
|
`),
|
|
).toMatchObject([{ routePath: '/api/n/{param1}', httpMethod: '' }]);
|
|
});
|
|
|
|
it('refuses a regex const bound twice to different patterns', () => {
|
|
// Same ambiguity refusal the string-constant map applies: a half-right
|
|
// regex is a wrong route.
|
|
expect(
|
|
paths(`
|
|
const RE = /^\\/api\\/a\\/([^/]+)$/
|
|
const RE = /^\\/api\\/b\\/([^/]+)$/
|
|
function handle(req) { if (req.method === 'GET' && RE.test(pathname)) { return 1 } }
|
|
`),
|
|
).toEqual([]);
|
|
});
|
|
|
|
// That refusal only ever compared regex LITERALS, so the two rebindings that
|
|
// actually occur walked straight past it and the literal's route was minted
|
|
// as though the name still held it.
|
|
it('refuses a regex const REASSIGNED to something dynamic', () => {
|
|
expect(
|
|
paths(`
|
|
let RE = /^\\/api\\/re\\/([^/]+)$/
|
|
RE = buildDynamic(req)
|
|
function handle(req) { if (req.method === 'GET' && RE.test(pathname)) { return 1 } }
|
|
`),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('refuses a regex const with a non-literal twin in another function', () => {
|
|
// The map is flat, so a same-named binding anywhere in the file is the
|
|
// ambiguity it claims to refuse — `new RegExp(userPrefix + '/x')` is not a
|
|
// `regex` node, which is the only reason it used to survive.
|
|
expect(
|
|
paths(`
|
|
const RE = /^\\/api\\/twin\\/([^/]+)$/
|
|
function other(userPrefix) { const RE = new RegExp(userPrefix + '/x'); return RE }
|
|
function handle(req) { if (req.method === 'GET' && RE.test(pathname)) { return 1 } }
|
|
`),
|
|
).toEqual([]);
|
|
});
|
|
|
|
// A match binding is keyed by the FUNCTION it is bound in, not by its bare
|
|
// name. `m`, `match` and `result` are the commonest locals in dispatcher
|
|
// code, so a file with two handlers routinely binds one of them twice to
|
|
// unrelated things — and the poison rule never fired, because it only ran
|
|
// when a second REGEX MATCH bound the name.
|
|
it('does not resolve a same-named local in ANOTHER function to this binding', () => {
|
|
// Measured before fixing: this emitted a second route,
|
|
// `DELETE /api/live/positions/{param1}/replay @9 handler=handleSettings`
|
|
// — wrong verb, wrong handler, wrong line, for a path that handler never
|
|
// serves. Being VERBED, it also outranked the real route in
|
|
// `reconcileDispatchGuardRoutes`, which drops a verb-less URL claimed with
|
|
// a verb anywhere in the repo.
|
|
expect(
|
|
extract(`
|
|
function handleReplay(req, res) {
|
|
const pathname = new URL(req.url, 'http://x').pathname
|
|
const m = pathname.match(/^\\/api\\/live\\/positions\\/([^/]+)\\/replay$/)
|
|
if (req.method === 'GET' && m) { return replay(m[1]) }
|
|
}
|
|
function handleSettings(req, res) {
|
|
const m = req.headers['x-mode']
|
|
if (req.method === 'DELETE' && m) { return wipeEverything() }
|
|
}
|
|
`),
|
|
).toEqual([
|
|
{
|
|
routePath: '/api/live/positions/{param1}/replay',
|
|
httpMethod: 'GET',
|
|
handlerName: 'handleReplay',
|
|
source: DISPATCH_GUARD_SOURCE,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('keeps an untested binding verb-less when another function reuses the name', () => {
|
|
// The second loss channel of the same defect: `tested` was keyed by bare
|
|
// name too, so the unrelated `if (m)` below marked `m` tested and
|
|
// SUPPRESSED this binding's own verb-less emit. The honest route was not
|
|
// merely joined by a fabricated one — it was replaced by it, reporting
|
|
// `handleSettings` as the handler for a path only `handleReplay` serves.
|
|
expect(
|
|
extract(`
|
|
function handleReplay(req, res) {
|
|
const m = pathname.match(/^\\/api\\/live\\/positions\\/([^/]+)\\/replay$/)
|
|
return m[1]
|
|
}
|
|
function handleSettings(req, res) {
|
|
const m = req.headers['x-mode']
|
|
if (m) { return wipeEverything() }
|
|
}
|
|
`),
|
|
).toEqual([
|
|
{
|
|
routePath: '/api/live/positions/{param1}/replay',
|
|
httpMethod: '',
|
|
handlerName: 'handleReplay',
|
|
source: DISPATCH_GUARD_SOURCE,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('refuses a name shadowed by a second declarator in the SAME function', () => {
|
|
// The key is the function, not the block, so a shadow inside one handler
|
|
// is ambiguity this walk cannot order — refused whole, the way
|
|
// `buildConstantMap` refuses a constant declared twice. It costs the real
|
|
// GET alongside the DELETE the shadow would have fabricated, which is the
|
|
// cheaper of the two failures.
|
|
expect(
|
|
extract(`
|
|
function handle(req) {
|
|
const m = pathname.match(/^\\/api\\/bs\\/([^/]+)$/)
|
|
if (req.method === 'GET' && m) { return 1 }
|
|
{ const m = req.headers['x']; if (req.method === 'DELETE' && m) { return wipe() } }
|
|
}
|
|
`),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('refuses a match binding REASSIGNED later in the same function', () => {
|
|
// `m` no longer holds the match by the time it is tested, so the
|
|
// declaration is not evidence of what the `if` asks about.
|
|
expect(
|
|
paths(`
|
|
function handle(req) {
|
|
let m = pathname.match(/^\\/api\\/ra\\/([^/]+)$/)
|
|
m = req.headers['x-mode']
|
|
if (req.method === 'GET' && m) { return 1 }
|
|
}
|
|
`),
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('handler attribution', () => {
|
|
it('names an object-literal method handler', () => {
|
|
// The route-module shape the reporting repo uses throughout.
|
|
expect(
|
|
extract(`
|
|
export function createRoutes(ctx) {
|
|
return {
|
|
async handle(req, res, reqCtx) {
|
|
const { pathname } = reqCtx
|
|
if (req.method === 'GET' && pathname === '/api/live/events') { return 1 }
|
|
},
|
|
}
|
|
}
|
|
`),
|
|
).toMatchObject([{ routePath: '/api/live/events', handlerName: 'handle' }]);
|
|
});
|
|
|
|
it('names an arrow function bound to a const', () => {
|
|
expect(
|
|
extract(`
|
|
const dispatch = (req) => {
|
|
if (req.method === 'GET' && pathname === '/api/ping') { return 1 }
|
|
}
|
|
`),
|
|
).toMatchObject([{ routePath: '/api/ping', handlerName: 'dispatch' }]);
|
|
});
|
|
|
|
it('reports no handler for a top-level comparison', () => {
|
|
expect(extract(`if (pathname === '/api/top') { go() }`)).toMatchObject([
|
|
{ routePath: '/api/top', handlerName: undefined },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('per-file dedup', () => {
|
|
it('collapses a repeated (url, verb) pair', () => {
|
|
const routes = extract(`
|
|
function handle(req) {
|
|
if (req.method === 'GET' && pathname === '/api/a') { return 1 }
|
|
if (req.method === 'GET' && pathname === '/api/a') { return 2 }
|
|
}
|
|
`);
|
|
expect(routes).toHaveLength(1);
|
|
});
|
|
|
|
it('keeps distinct verbs on the same URL as separate routes', () => {
|
|
expect(
|
|
extract(`
|
|
function handle(req) {
|
|
if (req.method === 'GET' && pathname === '/api/a') { return 1 }
|
|
if (req.method === 'DELETE' && pathname === '/api/a') { return 2 }
|
|
}
|
|
`),
|
|
).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
// Whole-repo reconciliation. Deliberately NOT per-file: the reporting repo
|
|
// keeps its path table (`isKnownApiPath`) in one module and its handlers in
|
|
// sixteen others, so a per-file rule sees each half separately and the map
|
|
// ends up listing every route twice — once verb-less with the table as its
|
|
// "handler", once properly. Measured there: 94 routes, 34 of them shadows.
|
|
describe('cross-file reconciliation', () => {
|
|
const route = (routePath: string, httpMethod: string, source = DISPATCH_GUARD_SOURCE) => ({
|
|
routePath,
|
|
httpMethod,
|
|
source,
|
|
});
|
|
|
|
it('drops a verb-less guard route when another file claims the URL with a verb', () => {
|
|
expect(
|
|
reconcileDispatchGuardRoutes([
|
|
route('/api/live/health', ''), // the table
|
|
route('/api/live/health', 'GET'), // the handler
|
|
]),
|
|
).toEqual([route('/api/live/health', 'GET')]);
|
|
});
|
|
|
|
it('keeps a verb-less guard route no verb claims', () => {
|
|
const only = [route('/api/plans/examples/{param1}', ''), route('/api/other', 'GET')];
|
|
expect(reconcileDispatchGuardRoutes(only)).toEqual(only);
|
|
});
|
|
|
|
it('keeps every verb on a multi-verb URL', () => {
|
|
const multi = [route('/api/x', 'GET'), route('/api/x', 'POST'), route('/api/x', '')];
|
|
expect(reconcileDispatchGuardRoutes(multi)).toEqual([
|
|
route('/api/x', 'GET'),
|
|
route('/api/x', 'POST'),
|
|
]);
|
|
});
|
|
|
|
// A framework route without a verb is method-agnostic BY DECLARATION — a
|
|
// Django function view, a Laravel resource. That is a fact, not a weaker
|
|
// observation of the same thing, so the rule must not reach it.
|
|
it('never drops a non-dispatch-guard route', () => {
|
|
const mixed = [
|
|
{ routePath: '/api/x', httpMethod: '', source: undefined },
|
|
route('/api/x', 'GET'),
|
|
];
|
|
expect(reconcileDispatchGuardRoutes(mixed)).toEqual(mixed);
|
|
});
|
|
|
|
it('does not let a framework verb suppress a guard route', () => {
|
|
const mixed = [
|
|
route('/api/x', ''),
|
|
{ routePath: '/api/x', httpMethod: 'GET', source: undefined },
|
|
];
|
|
expect(reconcileDispatchGuardRoutes(mixed)).toEqual(mixed);
|
|
});
|
|
});
|
|
|
|
// Both providers are wired to this extractor, and TypeScript is where the
|
|
// grammar can differ — an annotated parameter, a non-null assertion, an `as`
|
|
// cast all wrap nodes the rules read. Asserted rather than assumed.
|
|
describe('TypeScript', () => {
|
|
const tsParser = new Parser();
|
|
tsParser.setLanguage(TypeScript.typescript);
|
|
|
|
const tsPaths = (source: string): string[] =>
|
|
extractDispatchGuardRoutes(tsParser.parse(source), 'src/server/routes.ts').map(
|
|
(r) => r.routePath,
|
|
);
|
|
|
|
it('extracts through annotated parameters', () => {
|
|
expect(
|
|
tsPaths(`
|
|
export async function handle(req: IncomingMessage, pathname: string): Promise<void> {
|
|
if (req.method === 'GET' && pathname === '/api/live/portfolio') { return }
|
|
}
|
|
`),
|
|
).toEqual(['/api/live/portfolio']);
|
|
});
|
|
|
|
it('extracts a switch on a typed discriminant', () => {
|
|
expect(
|
|
tsPaths(`
|
|
function handle(pathname: string): number {
|
|
switch (pathname) {
|
|
case '/api/health': return 1
|
|
default: return 0
|
|
}
|
|
}
|
|
`),
|
|
).toEqual(['/api/health']);
|
|
});
|
|
|
|
it('folds a typed constant', () => {
|
|
expect(
|
|
tsPaths(
|
|
'const BASE: string = "/api/v1"\n' +
|
|
'function handle(pathname: string) { if (pathname === `${BASE}/orders`) { return 1 } }',
|
|
),
|
|
).toEqual(['/api/v1/orders']);
|
|
});
|
|
});
|
|
|
|
describe('path expressions the rule accepts', () => {
|
|
it('accepts a bare routePath request-path guard', () => {
|
|
expect(paths("function f(routePath) { if (routePath === '/api/x') return 1 }")).toEqual([
|
|
'/api/x',
|
|
]);
|
|
});
|
|
|
|
it('accepts a member access ending in .pathname', () => {
|
|
expect(
|
|
paths(`function f(req) { if (new URL(req.url, base).pathname === '/api/x') { return 1 } }`),
|
|
).toEqual(['/api/x']);
|
|
});
|
|
|
|
it('accepts raw req.url', () => {
|
|
expect(paths(`function f(req) { if (req.url === '/api/x') { return 1 } }`)).toEqual([
|
|
'/api/x',
|
|
]);
|
|
});
|
|
|
|
it('rejects a bare .url on an unrelated receiver', () => {
|
|
// `link.url` is not a request path; only req/request carry the raw form.
|
|
expect(paths(`function f(link) { if (link.url === '/api/x') { return 1 } }`)).toEqual([]);
|
|
});
|
|
});
|
|
});
|