GitNexus/gitnexus/test/unit/route-tool-detection.test.ts
Abhigyan Patwari aa33a6e94c
fix(routes): link base-URL fetch calls made through wrapper chains (#3628)
* fix(routes): link base-URL fetch calls made through wrapper chains (#3488)

GitNexus's own web client calls `fetchWithTimeout(`${_backendUrl}/api/repos`)`,
which reaches fetch through `resilientFetch` and a local `fetchImpl` alias.
`api_impact` found none of those consumers, for three independent reasons:

- normalizeFetchURL rejected any URL that did not start with `/`, so a
  leading base-URL binding dropped the call. It now strips a leading
  `${...}` run followed by `/`, with the same rules as the group layer's
  consumer normalizer (#3111); the helper moves to route-path.ts so both
  layers share it. A path whose every segment is a param (`${base}/${id}`,
  `${base}/${id}.json`) is rejected, since it would match every route of
  that depth. Interpolations are collapsed brace-balanced before the query
  split, so `${repo ? `?repo=${repo}` : ''}` no longer ends the path, and
  one glued to the last static segment is treated as a query or suffix.
- The wrapper call collector required a `/`-leading literal, so the same
  template never became a wrapper candidate.
- Wrapper detection only looked five AST levels deep for a call to an
  identifier named `fetch`. Arrow-const wrappers sit deeper than that, and
  `resilientFetch` calls an alias of `globalThis.fetch`. Detection now walks
  the whole definition and accepts `globalThis`/`window`/`self.fetch` and
  aliases whose value names fetch (through `??`, `||`, `?:`), read from the
  AST rather than the text. It records whether the first parameter reaches
  fetch's URL argument and which callees receive that parameter. The routes
  phase closes the wrapper set over that forwarding: `fetchWithTimeout` is a
  wrapper because it forwards its URL to `resilientFetch`. A callee declared
  in the caller's file or imported from a relative module must carry a URL
  in exactly that module; a package-imported name is followed only when
  every recorded function of that name carries one. A fetch caller whose
  first parameter is not the URL (`track(event)`) or a same-named function
  elsewhere (an IPC `request(channel)`) lends nothing.

When `[param]` matches several sibling routes (`/api/analyze/upload` and
`/api/analyze/:jobId`), the fetch now links to the route whose segments
match by kind instead of the first one registered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): match wrapper calls by declaring module and decline ambiguous fetch matches

Review follow-ups for #3488:

- A propagated wrapper's name went into a repo-wide set, so a literal call
  to an unrelated same-named function in another module was taken as a
  fetch. Wrapper call candidates now carry the module declaring a bare
  callee when the file declares or imports it relatively, and must reach a
  wrapper defined in exactly that module. Package and member callees keep
  the name match.
- An alias declared in a nested helper (`const fetchImpl = globalThis.fetch`)
  marked an outer call to a same-named parameter as a fetch. An alias now
  counts only where its declaring function encloses the call.
- A first parameter redeclared inside the function (`{ const url = ...;
  fetch(url) }`) was taken as the URL. A shadowed parameter is no longer
  followed.
- A fetch whose `[param]` ties between sibling static routes
  (`/api/kinds/foo`, `/api/kinds/bar`) linked to the first registered one.
  A tie now links none.
- The fixture's `resilientFetch` returns the last response instead of
  rejecting with `undefined`, and its client encodes the repo query value.

The release tool-accuracy gate now passes `api.template-wrapper`, so its
known-gap allowance is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): keep call-site wrapper names and ignore helpers that never run

- A wrapper call through a renamed relative import (`import { request as
  send }`) stored the exported name, so a configured `fetchWrappers` entry
  naming the call-site `send` no longer matched. The call keeps its
  call-site name and carries the declared name separately for the module
  lookup.
- A nested helper bound to a local that is never referenced again (`const
  later = () => resilientFetch(url)`) made its enclosing function forward
  its URL. Calls inside such a helper are ignored; a helper that is called
  or passed on, such as a retry callback, still counts.
- The fixture's `resilientFetch` no longer retries after a timeout or abort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): let a nested parameter shadow a fetch alias

A function parameter between a fetch alias and a call shadows the alias
there (`function send(fetchImpl) { return fetchImpl(event) }` beside an
outer `const fetchImpl = globalThis.fetch`), so that call no longer marks
the enclosing function as a fetch wrapper. The fixture's retry helper also
stops returning a stale 5xx response after a later attempt throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): drop fetch aliases whose name is redeclared with another value

A block that redeclares an alias name with a non-fetch value (`{ const
fetchImpl = other; fetchImpl('/x') }`) let the inner call read as a fetch.
A name declared anywhere in the function with a value that does not name
fetch is no longer an alias, which covers every block-scoped shadow without
lexical scope tracking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): ignore uncalled nested declarations and shadowed global objects

- An uncalled nested `function helper() { fetch('/x') }` made its enclosing
  function a fetch wrapper. Nested function declarations that are never
  referenced are now ignored, as unused function-valued locals already were.
- `window.fetch(url)` through a parameter or local named `window` (or
  `self`, `globalThis`) was read as the platform fetch. Such a root is now
  that value's method. A bare `fetch` parameter is still taken as fetch,
  since injecting a fetch implementation is the common reason for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(bench): refresh the receiver-resolution baseline for the fetch fixture

The new `fetch-base-url-wrapper` fixture in `test/fixtures/lang-resolution`
adds one unresolved TypeScript chain-field call to the committed check
corpus (127 to 128 call drops). Regenerated with
`measure.mjs --update-baseline` after a fresh build and cleared parse caches;
no other counter moved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-10-18 22:11:37 +01:00

784 lines
30 KiB
TypeScript

/**
* Unit tests for route extractors, tool detection patterns, and response shape parsing.
*/
import { describe, it, expect } from 'vitest';
import {
nextjsFileToRouteURL,
normalizeFetchURL,
routeMatches,
} from '../../src/core/ingestion/route-extractors/nextjs.js';
import { phpFileToRouteURL } from '../../src/core/ingestion/route-extractors/php.js';
import {
extractMiddlewareChain,
extractNextjsMiddlewareConfig,
middlewareMatcherMatchesRoute,
} from '../../src/core/ingestion/route-extractors/middleware.js';
import {
detectStatusCode,
extractResponseShapes,
extractPHPResponseShapes,
} from '../../src/core/ingestion/route-extractors/response-shapes.js';
// ---------------------------------------------------------------------------
// Next.js route extractor
// ---------------------------------------------------------------------------
describe('nextjsFileToRouteURL', () => {
it('extracts App Router API routes', () => {
expect(nextjsFileToRouteURL('app/api/grants/route.ts')).toBe('/api/grants');
expect(nextjsFileToRouteURL('app/api/users/route.js')).toBe('/api/users');
expect(nextjsFileToRouteURL('app/api/auth/login/route.tsx')).toBe('/api/auth/login');
});
it('handles dynamic segments', () => {
expect(nextjsFileToRouteURL('app/api/organizations/[slug]/grants/route.ts')).toBe(
'/api/organizations/[slug]/grants',
);
expect(nextjsFileToRouteURL('app/api/users/[id]/route.ts')).toBe('/api/users/[id]');
});
it('only matches api/ routes in App Router', () => {
// Non-API App Router routes should be excluded
expect(nextjsFileToRouteURL('app/dashboard/route.ts')).toBeNull();
expect(nextjsFileToRouteURL('app/(marketing)/about/route.ts')).toBeNull();
});
it('strips route groups from App Router paths', () => {
expect(nextjsFileToRouteURL('app/(admin)/api/users/route.ts')).toBe('/api/users');
expect(nextjsFileToRouteURL('app/(marketing)/api/newsletter/route.ts')).toBe('/api/newsletter');
});
it('extracts Pages Router API routes', () => {
expect(nextjsFileToRouteURL('pages/api/auth/login.ts')).toBe('/api/auth/login');
expect(nextjsFileToRouteURL('pages/api/users.ts')).toBe('/api/users');
});
it('strips /index suffix from Pages Router', () => {
expect(nextjsFileToRouteURL('pages/api/index.ts')).toBe('/api');
});
it('returns null for non-route files', () => {
expect(nextjsFileToRouteURL('src/components/Button.tsx')).toBeNull();
expect(nextjsFileToRouteURL('src/lib/utils.ts')).toBeNull();
expect(nextjsFileToRouteURL('app/page.tsx')).toBeNull();
});
it('handles Windows-style backslash paths', () => {
expect(nextjsFileToRouteURL('app\\api\\grants\\route.ts')).toBe('/api/grants');
});
});
// ---------------------------------------------------------------------------
// PHP route extractor
// ---------------------------------------------------------------------------
describe('phpFileToRouteURL', () => {
it('extracts routes from api/ directory', () => {
expect(phpFileToRouteURL('api/upload.php')).toBe('/api/upload');
expect(phpFileToRouteURL('api/next_sign.php')).toBe('/api/next_sign');
expect(phpFileToRouteURL('api/auth.php')).toBe('/api/auth');
});
it('handles nested api directories', () => {
expect(phpFileToRouteURL('api/v2/users.php')).toBe('/api/v2/users');
});
it('returns null for non-api PHP files', () => {
expect(phpFileToRouteURL('index.php')).toBeNull();
expect(phpFileToRouteURL('includes/database.php')).toBeNull();
expect(phpFileToRouteURL('vendor/lib/api/config.php')).toBeNull();
});
it('filters out non-handler files in api/', () => {
expect(phpFileToRouteURL('api/_helpers.php')).toBeNull();
expect(phpFileToRouteURL('api/helper_utils.php')).toBeNull();
expect(phpFileToRouteURL('api/test_upload.php')).toBeNull();
expect(phpFileToRouteURL('api/fixture_data.php')).toBeNull();
});
it('does not false-filter legitimate endpoints with substring matches', () => {
// "contest" contains "test", "attestation" contains "test" — should NOT be filtered
// Word-boundary regex only matches _test_ not substrings
expect(phpFileToRouteURL('api/contest.php')).toBe('/api/contest');
expect(phpFileToRouteURL('api/attestation.php')).toBe('/api/attestation');
expect(phpFileToRouteURL('api/latest.php')).toBe('/api/latest');
expect(phpFileToRouteURL('api/base64_encode.php')).toBe('/api/base64_encode');
});
it('returns null for non-PHP files', () => {
expect(phpFileToRouteURL('api/readme.md')).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Fetch URL normalization
// ---------------------------------------------------------------------------
describe('normalizeFetchURL', () => {
it('passes through clean API URLs', () => {
expect(normalizeFetchURL('/api/grants')).toBe('/api/grants');
expect(normalizeFetchURL('/api/users/123')).toBe('/api/users/123');
});
it('strips query strings', () => {
expect(normalizeFetchURL('/api/grants?page=1&limit=10')).toBe('/api/grants');
});
it('replaces template expressions with [param]', () => {
expect(normalizeFetchURL('/api/organizations/${slug}/grants')).toBe(
'/api/organizations/[param]/grants',
);
});
it('strips backticks from template literals', () => {
expect(normalizeFetchURL('`/api/grants`')).toBe('/api/grants');
});
it('accepts non-/api/ absolute paths', () => {
expect(normalizeFetchURL('/v1/users')).toBe('/v1/users');
expect(normalizeFetchURL('/graphql')).toBe('/graphql');
expect(normalizeFetchURL('/dashboard')).toBe('/dashboard');
});
it('returns null for unresolvable patterns', () => {
// String concatenation in source code: '/api/' + endpoint
expect(normalizeFetchURL('/api/+endpoint')).toBeNull();
// Function call wrapper
expect(normalizeFetchURL('getApiUrl()')).toBeNull();
});
// A leading `${base}` before `/` is the backend's base URL, not a route
// segment (#3488), as in the group layer (#3111).
it.each([
['`${_backendUrl}/api/repos`', '/api/repos'],
['${_backendUrl}/api/file?${params}', '/api/file'],
['${scheme}${host}/api/x/${id}', '/api/x/[param]'],
['/${locale}/api/x', '/[param]/api/x'],
['${base}api/x', null],
['${proto}//host/api/x', null],
['${scheme}://${host}/api/x', null],
// Nothing but params left would match every route of that depth.
['${base}/${id}', null],
['${base}/', null],
['${base}/${path}${query}', null],
['${base}/${id}.json', null],
['${base}/${dir}/${file}.php', null],
// A `?` inside an interpolation does not end the path, and an
// interpolation glued to the last static segment is a query or suffix.
['${_backendUrl}/api/processes${repo ? `?repo=${repo}` : ""}', '/api/processes'],
['/api/items${suffix}', '/api/items'],
])('reads the base-URL template %s as %s', (raw, expected) => {
expect(normalizeFetchURL(raw)).toBe(expected);
});
});
// ---------------------------------------------------------------------------
// Route matching
// ---------------------------------------------------------------------------
describe('routeMatches', () => {
it('matches exact routes', () => {
expect(routeMatches('/api/grants', '/api/grants')).toBe(true);
});
it('does not match different routes', () => {
expect(routeMatches('/api/grants', '/api/users')).toBe(false);
});
it('does not match different segment counts', () => {
expect(routeMatches('/api/grants', '/api/grants/123')).toBe(false);
});
it('matches dynamic segments on either side', () => {
expect(routeMatches('/api/orgs/[param]', '/api/orgs/[slug]')).toBe(true);
expect(routeMatches('/api/orgs/acme', '/api/orgs/[slug]')).toBe(true);
expect(routeMatches('/api/orgs/[param]/grants', '/api/orgs/[slug]/grants')).toBe(true);
});
it('matches catch-all routes against longer paths', () => {
expect(routeMatches('/api/docs/a/b/c', '/api/[...slug]')).toBe(true);
expect(routeMatches('/api/proxy/x', '/api/[...slug]')).toBe(true);
expect(routeMatches('/api/proxy/x/y/z', '/api/[...slug]')).toBe(true);
});
it('does not match catch-all when prefix segments differ', () => {
expect(routeMatches('/v1/docs/a', '/api/[...slug]')).toBe(false);
});
it('does not match catch-all with too few segments', () => {
expect(routeMatches('/', '/api/[...slug]')).toBe(false);
});
it('matches optional catch-all routes [[...slug]]', () => {
expect(routeMatches('/api/docs/a/b', '/api/[[...slug]]')).toBe(true);
expect(routeMatches('/api/proxy/x', '/api/[[...slug]]')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Response shape extraction (brace-depth parser edge cases)
// ---------------------------------------------------------------------------
describe('response shape extraction edge cases', () => {
// Helper that simulates the pipeline's brace-depth parser
function extractKeysFromContent(content: string): string[] {
const keys: string[] = [];
const jsonPattern = /\.json\s*\(/g;
let jsonMatch;
while ((jsonMatch = jsonPattern.exec(content)) !== null) {
const startIdx = jsonMatch.index + jsonMatch[0].length;
let i = startIdx;
while (i < content.length && content[i] !== '{' && content[i] !== ')') i++;
if (i >= content.length || content[i] !== '{') continue;
let depth = 0;
let keyStart = -1;
let inString: string | null = null;
for (let j = i; j < content.length; j++) {
const ch = content[j];
if (inString) {
if (ch === '\\') {
j++;
continue;
}
if (ch === inString) inString = null;
continue;
}
if (ch === '"' || ch === "'" || ch === '`') {
inString = ch;
continue;
}
if (ch === '{') {
depth++;
continue;
}
if (ch === '}') {
depth--;
if (depth === 0) break;
continue;
}
if (depth !== 1) continue;
if (keyStart === -1 && /[a-zA-Z_$]/.test(ch)) {
keyStart = j;
} else if (keyStart !== -1 && !/[a-zA-Z0-9_$]/.test(ch)) {
const key = content.slice(keyStart, j);
const rest = content.slice(j).trimStart();
if (rest[0] === ':' || rest[0] === ',' || rest[0] === '}') {
keys.push(key);
}
keyStart = -1;
}
}
}
return [...new Set(keys)];
}
it('extracts simple shorthand properties', () => {
const keys = extractKeysFromContent('res.json({ data, total })');
expect(keys).toEqual(['data', 'total']);
});
it('extracts key-value properties (values that look like identifiers are also captured)', () => {
// Limitation: the text-based parser can't distinguish `data: grants` (key-value)
// from `grants` (shorthand). Identifier values followed by , are captured as keys.
// This is acceptable — false positive keys are better than missed keys.
const keys = extractKeysFromContent('res.json({ data: grants, count: 5 })');
expect(keys).toContain('data');
expect(keys).toContain('count');
});
it('handles nested objects without extracting inner keys', () => {
const keys = extractKeysFromContent(
'res.json({ data: grants, pagination: { page: 1, total: 10 }, meta: "ok" })',
);
expect(keys).toContain('data');
expect(keys).toContain('pagination');
expect(keys).toContain('meta');
expect(keys).not.toContain('page');
expect(keys).not.toContain('total');
});
it('handles braces inside string literals', () => {
const keys = extractKeysFromContent('res.json({ message: "Use { and } carefully", count: 5 })');
expect(keys).toContain('message');
expect(keys).toContain('count');
expect(keys).not.toContain('and');
expect(keys).not.toContain('carefully');
});
it('handles escaped quotes in strings', () => {
const keys = extractKeysFromContent('res.json({ msg: "He said \\"hello\\"", ok: true })');
expect(keys).toContain('msg');
expect(keys).toContain('ok');
});
it('handles NextResponse.json pattern', () => {
const keys = extractKeysFromContent(
'return NextResponse.json({ data: grants, pagination: { page: 1 } })',
);
expect(keys).toContain('data');
expect(keys).toContain('pagination');
expect(keys).not.toContain('page');
});
it('returns empty for non-object arguments', () => {
const keys = extractKeysFromContent('res.json("error")');
expect(keys).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// Middleware chain extraction
// ---------------------------------------------------------------------------
describe('middleware chain extraction', () => {
it('extracts triple-nested middleware chain', () => {
const content = `export const POST = withRateLimit(withCSRF(withAuth(async (req) => { return NextResponse.json({ ok: true }); })));`;
expect(extractMiddlewareChain(content)).toEqual({
chain: ['withRateLimit', 'withCSRF', 'withAuth'],
method: 'POST',
});
});
it('extracts double-nested middleware chain', () => {
const content = `export const GET = withAuth(withCache(handler));`;
expect(extractMiddlewareChain(content)).toEqual({
chain: ['withAuth', 'withCache'],
method: 'GET',
});
});
it('extracts single withX wrapper', () => {
const content = `export const POST = withAuth(handler);`;
expect(extractMiddlewareChain(content)).toEqual({ chain: ['withAuth'], method: 'POST' });
});
it('extracts from export default', () => {
const content = `export default withAuth(handler);`;
expect(extractMiddlewareChain(content)).toEqual({ chain: ['withAuth'], method: 'default' });
});
it('extracts from export default with nesting', () => {
const content = `export default withRateLimit(withAuth(handler));`;
expect(extractMiddlewareChain(content)).toEqual({
chain: ['withRateLimit', 'withAuth'],
method: 'default',
});
});
it('stops at async keyword (arrow function body)', () => {
const content = `export const POST = withAuth(async (req) => { return res.json({}); });`;
expect(extractMiddlewareChain(content)).toEqual({ chain: ['withAuth'], method: 'POST' });
});
it('returns undefined for plain handler without wrappers', () => {
const content = `export const POST = handler;`;
expect(extractMiddlewareChain(content)).toBeUndefined();
});
it('returns undefined for non-middleware single wrapper', () => {
// createHandler is not a withX pattern and chain length is 1
const content = `export const POST = createHandler(config);`;
expect(extractMiddlewareChain(content)).toBeUndefined();
});
it('handles multiple HTTP methods — uses first match', () => {
const content = `
export const GET = withCache(handler);
export const POST = withAuth(withCSRF(handler));
`;
expect(extractMiddlewareChain(content)).toEqual({ chain: ['withCache'], method: 'GET' });
});
it('handles whitespace and newlines in chain', () => {
const content = `export const POST = withRateLimit(\n withAuth(\n async (req) => {}\n )\n);`;
expect(extractMiddlewareChain(content)).toEqual({
chain: ['withRateLimit', 'withAuth'],
method: 'POST',
});
});
it('handles DELETE method', () => {
const content = `export const DELETE = withAuth(withAdmin(handler));`;
expect(extractMiddlewareChain(content)).toEqual({
chain: ['withAuth', 'withAdmin'],
method: 'DELETE',
});
});
it('returns undefined when no export pattern exists', () => {
const content = `const handler = withAuth(doSomething);`;
expect(extractMiddlewareChain(content)).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Error response shape detection (detectStatusCode + separation logic)
// ---------------------------------------------------------------------------
describe('detectStatusCode', () => {
it('detects Express .status(N).json() pattern', () => {
const content = 'return res.status(400).json({ error: "bad request" })';
const jsonPos = content.indexOf('.json');
expect(detectStatusCode(content, jsonPos, -1)).toBe(400);
});
it('detects NextResponse.json({...}, { status: 400 }) pattern', () => {
const content = 'return NextResponse.json({ error: "not found" }, { status: 404 })';
const jsonPos = content.indexOf('.json');
// Simulate closingBracePos at the end of first arg object
const firstArgClose = content.indexOf('}');
expect(detectStatusCode(content, jsonPos, firstArgClose)).toBe(404);
});
it('detects NextResponse.json({...}, { status: 200 }) as success', () => {
const content = 'return NextResponse.json({ data: results }, { status: 200 })';
const jsonPos = content.indexOf('.json');
const firstArgClose = content.indexOf('}');
expect(detectStatusCode(content, jsonPos, firstArgClose)).toBe(200);
});
it('returns undefined when no status code present', () => {
const content = 'return NextResponse.json({ data: results })';
const jsonPos = content.indexOf('.json');
const firstArgClose = content.indexOf('}');
expect(detectStatusCode(content, jsonPos, firstArgClose)).toBeUndefined();
});
it('detects .status(500).json() for server error', () => {
const content =
'res.status(500).json({ error: "Internal server error", code: "SERVER_ERROR" })';
const jsonPos = content.indexOf('.json');
expect(detectStatusCode(content, jsonPos, -1)).toBe(500);
});
it('detects status in second arg with extra properties', () => {
const content = 'return NextResponse.json({ error: "fail" }, { status: 422, headers: {} })';
const jsonPos = content.indexOf('.json');
const firstArgClose = content.indexOf('}');
expect(detectStatusCode(content, jsonPos, firstArgClose)).toBe(422);
});
});
describe('error response shape separation', () => {
it('separates success and error responses in NextResponse pattern', () => {
const content = `
export async function GET() {
try {
const data = await fetchData();
return NextResponse.json({ data, pagination: { page: 1 } });
} catch (e) {
return NextResponse.json({ error: "Failed", message: e.message }, { status: 500 });
}
}
`;
const shapes = extractResponseShapes(content);
expect(shapes.responseKeys).toEqual(['data', 'pagination']);
expect(shapes.errorKeys).toEqual(['error', 'message']);
});
it('separates success and error responses in Express pattern', () => {
const content = `
app.get('/api/users', (req, res) => {
try {
const users = getUsers();
res.json({ users, total });
} catch (e) {
res.status(500).json({ error: "Server error", code: "INTERNAL" });
}
});
`;
const shapes = extractResponseShapes(content);
expect(shapes.responseKeys).toEqual(['users', 'total']);
expect(shapes.errorKeys).toEqual(['error', 'code']);
});
it('handles multiple error status codes', () => {
const content = `
export async function POST(req) {
if (!req.body) return NextResponse.json({ error: "No body" }, { status: 400 });
if (!authorized) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return NextResponse.json({ data, id });
}
`;
const shapes = extractResponseShapes(content);
expect(shapes.responseKeys).toEqual(['data', 'id']);
expect(shapes.errorKeys).toEqual(['error']);
});
it('falls back to all keys as responseKeys when no status codes present', () => {
const content = `
export async function GET() {
return NextResponse.json({ data, count });
}
`;
const shapes = extractResponseShapes(content);
expect(shapes.responseKeys).toEqual(['data', 'count']);
expect(shapes.errorKeys).toBeUndefined();
});
it('treats status 200 as success', () => {
const content = `return NextResponse.json({ ok }, { status: 200 })`;
const shapes = extractResponseShapes(content);
expect(shapes.responseKeys).toEqual(['ok']);
expect(shapes.errorKeys).toBeUndefined();
});
it('treats status 201 as success', () => {
const content = `return NextResponse.json({ created, id }, { status: 201 })`;
const shapes = extractResponseShapes(content);
expect(shapes.responseKeys).toEqual(['created', 'id']);
expect(shapes.errorKeys).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Next.js project-level middleware config extraction
// ---------------------------------------------------------------------------
describe('extractNextjsMiddlewareConfig', () => {
it('extracts matcher array and named function export', () => {
const content = `
import { NextResponse, type NextRequest } from "next/server";
export function middleware(request: NextRequest) {
return NextResponse.next();
}
export const config = {
matcher: ["/api/:path*", "/dashboard/:path*"],
};
`;
const result = extractNextjsMiddlewareConfig(content);
expect(result).toBeDefined();
expect(result!.matchers).toEqual(['/api/:path*', '/dashboard/:path*']);
expect(result!.exportedName).toBe('middleware');
});
it('extracts single string matcher', () => {
const content = `
export function middleware(req) {}
export const config = { matcher: '/dashboard/:path*' };
`;
const result = extractNextjsMiddlewareConfig(content);
expect(result!.matchers).toEqual(['/dashboard/:path*']);
});
it('extracts default export name as wrappedFunction', () => {
const content = `
import { auth } from '@/lib/auth';
export default auth;
export const config = { matcher: ['/api/:path*'] };
`;
const result = extractNextjsMiddlewareConfig(content);
expect(result!.exportedName).toBe('auth');
expect(result!.wrappedFunctions).toContain('auth');
expect(result!.matchers).toEqual(['/api/:path*']);
});
it('extracts chain composition', () => {
const content = `
export default chain([withAuth, withI18n]);
export const config = { matcher: ['/((?!api|_next).*)'] };
`;
const result = extractNextjsMiddlewareConfig(content);
expect(result!.wrappedFunctions).toEqual(['withAuth', 'withI18n']);
});
it('extracts nested wrapper composition from default export', () => {
const content = `
export default withRateLimit(withAuth(handler));
export const config = { matcher: ['/api/:path*'] };
`;
const result = extractNextjsMiddlewareConfig(content);
expect(result!.wrappedFunctions).toEqual(['withRateLimit', 'withAuth']);
});
it('extracts regex-style negative lookahead matcher', () => {
const content = `
export function middleware(req) {}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\\\..*$).*)"],
};
`;
const result = extractNextjsMiddlewareConfig(content);
expect(result!.matchers).toHaveLength(1);
expect(result!.matchers[0]).toContain('(?!api|_next');
});
it('treats middleware without config.matcher as match-all', () => {
const content = `export function middleware(req) { return NextResponse.next(); }`;
const result = extractNextjsMiddlewareConfig(content);
expect(result).toBeDefined();
expect(result!.matchers).toEqual([]);
expect(result!.exportedName).toBe('middleware');
});
it('detects arrow function const export', () => {
const content = `
export const middleware = (req) => {
return NextResponse.next();
};
export const config = { matcher: ['/dashboard/:path*'] };
`;
const result = extractNextjsMiddlewareConfig(content);
expect(result).toBeDefined();
expect(result!.exportedName).toBe('middleware');
expect(result!.matchers).toEqual(['/dashboard/:path*']);
});
it('handles export default function middleware(...)', () => {
const content = `export default function middleware(req) { return NextResponse.next(); }`;
const result = extractNextjsMiddlewareConfig(content);
expect(result).toBeDefined();
expect(result!.exportedName).toBe('middleware');
});
});
// ---------------------------------------------------------------------------
// Middleware matcher to route matching
// ---------------------------------------------------------------------------
describe('middlewareMatcherMatchesRoute', () => {
it('matches prefix pattern with :path*', () => {
expect(middlewareMatcherMatchesRoute('/api/:path*', '/api/users')).toBe(true);
expect(middlewareMatcherMatchesRoute('/api/:path*', '/api/auth/login')).toBe(true);
expect(middlewareMatcherMatchesRoute('/api/:path*', '/api')).toBe(true);
});
it('does not match unrelated routes with :path*', () => {
expect(middlewareMatcherMatchesRoute('/dashboard/:path*', '/api/users')).toBe(false);
expect(middlewareMatcherMatchesRoute('/api/:path*', '/v1/users')).toBe(false);
});
it('matches exact route', () => {
expect(middlewareMatcherMatchesRoute('/login', '/login')).toBe(true);
expect(middlewareMatcherMatchesRoute('/login', '/login/callback')).toBe(false);
});
it('matches regex-style negative lookahead pattern', () => {
const matcher = '/((?!api|_next).*)';
expect(middlewareMatcherMatchesRoute(matcher, '/dashboard')).toBe(true);
expect(middlewareMatcherMatchesRoute(matcher, '/settings/profile')).toBe(true);
expect(middlewareMatcherMatchesRoute(matcher, '/api')).toBe(false);
expect(middlewareMatcherMatchesRoute(matcher, '/api/users')).toBe(false);
});
});
describe('PHP response shape extraction', () => {
it('extracts keys from short array syntax', () => {
const content = `echo json_encode(['success' => true, 'data' => $items], JSON_UNESCAPED_UNICODE);`;
const shapes = extractPHPResponseShapes(content);
expect(shapes.responseKeys).toEqual(['success', 'data']);
expect(shapes.errorKeys).toBeUndefined();
});
it('extracts keys from long array syntax', () => {
const content = `echo json_encode(array('ok' => true, 'count' => $n));`;
const shapes = extractPHPResponseShapes(content);
expect(shapes.responseKeys).toEqual(['ok', 'count']);
});
it('classifies error responses by http_response_code', () => {
const content = `
http_response_code(401);
echo json_encode(['error' => 'Unauthorized'], JSON_UNESCAPED_UNICODE);
`;
const shapes = extractPHPResponseShapes(content);
expect(shapes.responseKeys).toBeUndefined();
expect(shapes.errorKeys).toEqual(['error']);
});
it('separates success and error responses', () => {
const content = `
header('Content-Type: application/json');
if (!is_logged_in()) {
http_response_code(401);
echo json_encode(['error' => 'Not logged in'], JSON_UNESCAPED_UNICODE);
exit;
}
echo json_encode(['ok' => true, 'new_status' => $status], JSON_UNESCAPED_UNICODE);
`;
const shapes = extractPHPResponseShapes(content);
expect(shapes.responseKeys).toEqual(['ok', 'new_status']);
expect(shapes.errorKeys).toEqual(['error']);
});
it('handles multiple error status codes with exit boundaries', () => {
const content = `
if (!$user) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized'], JSON_UNESCAPED_UNICODE);
exit;
}
if (!$valid) {
http_response_code(400);
echo json_encode(['error' => 'Invalid data', 'field' => 'name'], JSON_UNESCAPED_UNICODE);
exit;
}
echo json_encode(['ok' => true, 'id' => $id], JSON_UNESCAPED_UNICODE);
`;
const shapes = extractPHPResponseShapes(content);
expect(shapes.responseKeys).toEqual(['ok', 'id']);
expect(shapes.errorKeys).toEqual(['error', 'field']);
});
it('detects status from header() pattern', () => {
const content = `
header('HTTP/1.1 403 Forbidden');
echo json_encode(['error' => 'Forbidden', 'message' => 'Access denied']);
`;
const shapes = extractPHPResponseShapes(content);
expect(shapes.errorKeys).toEqual(['error', 'message']);
});
it('skips json_encode with variable argument', () => {
const content = `echo json_encode($data, JSON_UNESCAPED_UNICODE);`;
const shapes = extractPHPResponseShapes(content);
expect(shapes.responseKeys).toBeUndefined();
expect(shapes.errorKeys).toBeUndefined();
});
it('extracts only top-level keys from nested arrays', () => {
const content = `echo json_encode(['data' => ['nested' => true], 'total' => $count]);`;
const shapes = extractPHPResponseShapes(content);
expect(shapes.responseKeys).toEqual(['data', 'total']);
});
it('handles json_encode with flags after array', () => {
const content = `echo json_encode(['export' => $data], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);`;
const shapes = extractPHPResponseShapes(content);
expect(shapes.responseKeys).toEqual(['export']);
});
it('recognizes exit(N) as a boundary', () => {
const content = `
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit(0);
echo json_encode(['ok' => true, 'data' => $result]);
`;
const shapes = extractPHPResponseShapes(content);
expect(shapes.responseKeys).toEqual(['ok', 'data']);
expect(shapes.errorKeys).toEqual(['error']);
});
it('recognizes die("msg") as a boundary', () => {
const content = `
http_response_code(500);
echo json_encode(['error' => 'DB error']);
die('Fatal');
echo json_encode(['items' => $list]);
`;
const shapes = extractPHPResponseShapes(content);
expect(shapes.responseKeys).toEqual(['items']);
expect(shapes.errorKeys).toEqual(['error']);
});
it('detects CGI Status header format', () => {
const content = `
header('Status: 404 Not Found');
echo json_encode(['error' => 'Not found', 'code' => 'MISSING']);
`;
const shapes = extractPHPResponseShapes(content);
expect(shapes.errorKeys).toEqual(['error', 'code']);
expect(shapes.responseKeys).toBeUndefined();
});
});