GitNexus/gitnexus/test/unit/fastapi-router-bindings.test.ts
azizur100389 acb65b95b6
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Gitleaks / gitleaks (push) Has been cancelled
Publish / Classify release event (push) Has been cancelled
Scorecard / Scorecard analysis (push) Has been cancelled
Trivy Image Scan / Trivy (gitnexus-cli) (push) Has been cancelled
Trivy Image Scan / Trivy (gitnexus-web) (push) Has been cancelled
Publish / RC guard (marker + release-PR skip) (push) Has been cancelled
Publish / ci (push) Has been cancelled
Publish / Publish to npm (push) Has been cancelled
Publish / Build & Push RC Docker images (push) Has been cancelled
fix(fastapi): propagate package router mount prefixes (#3408)
* fix(fastapi): carry package router mount prefixes to child routes

* fix(fastapi): address review feedback on nested router prefixes (#3408)

- Skip unprefixed includes in the parse-impl legacy loop so a bare
  include_router in another file no longer shadows the real prefix.
- Union exact-file prefixes with legacy long/short prefixes via a shared
  mergeMountPrefixes helper in both ingestion and the group extractor.
- Join the parent APIRouter(prefix=...) between the mount prefix and the
  child include prefix.
- Resolve the group layer over every repo path (empty files included) so
  absolute-import ambiguity matches ingestion.
- Memoize (file, prefix) frames so diamond-shaped include graphs stay
  linear; drop the stack.pop() non-null assertion.
- Accept extra keyword arguments and a trailing comma in unprefixed
  include_router calls without double-firing on prefix= calls.
- Document that pass-through is limited to a host named `router`.
- Bump parse-cache SCHEMA_BUMP to 123 for the new capture fields.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(fastapi): seed prefix propagation from bare router mounts (#3408)

- A router mounted without a prefix now seeds traversal with an empty
  prefix (only when no prefixed mount targets the same file), so its own
  APIRouter(prefix=...) reaches unprefixed children on both surfaces.
- An all-empty chain records nothing and leaves the child on its legacy
  fallback.
- The bare-mount integration test no longer asserts that the test app's
  unprefixed mount is absent; it pins only that the real prefix survives.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(fastapi): capture include prefixes after nested-call arguments (#3408)

- Let the Shape A/B and unprefixed include_router patterns step over one
  level of nested calls such as dependencies=[Depends(auth)], so a
  prefix= written after them is captured by the worker (the group
  layer's tree-sitter patterns already handled this shape).
- Replace the unit test that pinned the dropped prefix with one that
  pins the captured prefixes and the unprefixed Depends-only edge; add a
  group-layer parity test.
- Correct the diamond test comment to 2^39 root-to-leaf paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 14:49:44 +01:00

535 lines
19 KiB
TypeScript

/**
* Unit tests for {@link extractFastAPIRouterBindings} — the per-file
* regex extractor that the parse worker calls on every Python file.
* The cross-file aggregation that turns these raw records into prefix
* maps lives in parse-impl and is covered by
* `fastapi-prefix-pipeline.test.ts` (integration) plus
* `http-route-extractor.test.ts` (group layer). This file pins the
* shape the worker emits, so a regression in either regex or in the
* import-list parsing fails here first.
*
* What this file is responsible for:
* • Shape A `app.include_router(<mod>.router, prefix=…)` and
* Shape B `app.include_router(<local>, prefix=…)` are both
* captured.
* • `<host>.include_router` matches any host name, not just `app`.
* • Module path keying is two-tiered: short basename (always) and
* long `<parent>/<stem>` key (whenever the import path was
* multi-segment).
* • Relative imports (`from .calls import …`,
* `from ..siblings.calls import …`) are captured.
* • `as`-aliased imports route the prefix to the alias, not to
* `router`.
* • Unprefixed includes from any host are captured as `prefix: ''`
* propagation edges; calls whose `prefix=` is not a string literal
* are not captured.
*/
import { describe, it, expect } from 'vitest';
import {
extractFastAPIRouterBindings,
lastDottedSegment,
lastTwoSegmentsAsPath,
type ExtractedRouterConstructorPrefix,
type ExtractedRouterInclude,
type ExtractedRouterImport,
type ExtractedRouterModuleAlias,
} from '../../src/core/ingestion/route-extractors/fastapi-router-bindings.js';
import { resolveFastAPIRouterPrefixes } from '../../src/core/ingestion/route-extractors/fastapi-router-prefixes.js';
function run(filePath: string, content: string) {
const includes: ExtractedRouterInclude[] = [];
const imports: ExtractedRouterImport[] = [];
extractFastAPIRouterBindings(filePath, content, includes, imports);
return { includes, imports };
}
function runFull(filePath: string, content: string) {
const includes: ExtractedRouterInclude[] = [];
const imports: ExtractedRouterImport[] = [];
const moduleAliases: ExtractedRouterModuleAlias[] = [];
const constructorPrefixes: ExtractedRouterConstructorPrefix[] = [];
extractFastAPIRouterBindings(
filePath,
content,
includes,
imports,
moduleAliases,
constructorPrefixes,
);
return { includes, imports, moduleAliases, constructorPrefixes };
}
describe('lastDottedSegment', () => {
it('returns the last segment of an absolute dotted path', () => {
expect(lastDottedSegment('api.users')).toBe('users');
expect(lastDottedSegment('api.v2.users')).toBe('users');
});
it('strips leading dots from a relative path', () => {
expect(lastDottedSegment('.users')).toBe('users');
expect(lastDottedSegment('..api.users')).toBe('users');
expect(lastDottedSegment('...users')).toBe('users');
});
it('returns the input when there is no dot after stripping', () => {
expect(lastDottedSegment('users')).toBe('users');
});
it('returns the empty string for pure-dot inputs', () => {
expect(lastDottedSegment('.')).toBe('');
expect(lastDottedSegment('..')).toBe('');
expect(lastDottedSegment('...')).toBe('');
});
});
describe('lastTwoSegmentsAsPath', () => {
it('joins the last two segments with `/`', () => {
expect(lastTwoSegmentsAsPath('api.users')).toBe('api/users');
expect(lastTwoSegmentsAsPath('app.api.users')).toBe('api/users');
});
it('strips leading dots before joining', () => {
expect(lastTwoSegmentsAsPath('..api.users')).toBe('api/users');
});
it('returns the empty string when the path has only one segment', () => {
// Single-segment imports cannot be promoted to a long key.
expect(lastTwoSegmentsAsPath('users')).toBe('');
expect(lastTwoSegmentsAsPath('.users')).toBe('');
});
it('returns the empty string for pure-dot inputs', () => {
expect(lastTwoSegmentsAsPath('.')).toBe('');
expect(lastTwoSegmentsAsPath('..')).toBe('');
});
});
describe('extractFastAPIRouterBindings — Shape A (`<mod>.router`)', () => {
it('captures app.include_router(<mod>.router, prefix=…)', () => {
const { includes } = run(
'main.py',
[
'from fastapi import FastAPI',
'from api import users',
'app = FastAPI()',
"app.include_router(users.router, prefix='/users', tags=['users'])",
'',
].join('\n'),
);
expect(includes).toHaveLength(1);
expect(includes[0]).toMatchObject({
filePath: 'main.py',
routerExpr: 'users.router',
prefix: '/users',
});
// Line number is 1-indexed and points to the include_router call.
expect(includes[0].lineNumber).toBe(4);
});
it('captures non-`app` host variables', () => {
// FINDING 4: production code commonly uses `api`, `application`,
// `asgi_app` etc. Pinning the regex to `app.` would silently drop
// these, which used to leave the ingestion and group layers
// disagreeing on whether a prefix was applied.
const { includes } = run(
'main.py',
[
'from fastapi import FastAPI',
'from api import users',
'api = FastAPI()',
"api.include_router(users.router, prefix='/users')",
'',
].join('\n'),
);
expect(includes).toHaveLength(1);
expect(includes[0].routerExpr).toBe('users.router');
expect(includes[0].prefix).toBe('/users');
});
it('captures multiple Shape-A includes in the same file', () => {
const { includes } = run(
'main.py',
[
'from api import users, calls',
'app = FastAPI()',
"app.include_router(users.router, prefix='/users')",
"app.include_router(calls.router, prefix='/calls')",
'',
].join('\n'),
);
expect(includes).toHaveLength(2);
expect(includes.map((i) => i.routerExpr).sort()).toEqual(['calls.router', 'users.router']);
});
});
describe('extractFastAPIRouterBindings — Shape B (bare local name)', () => {
it('captures app.include_router(<local>, prefix=…) and the import', () => {
const { includes, imports } = run(
'main.py',
[
'from fastapi import FastAPI',
'from api.users import router as users_router',
'app = FastAPI()',
"app.include_router(users_router, prefix='/users')",
'',
].join('\n'),
);
expect(imports).toHaveLength(1);
expect(imports[0]).toMatchObject({
filePath: 'main.py',
localName: 'users_router',
moduleKey: 'users',
moduleKeyLong: 'api/users',
});
expect(includes).toHaveLength(1);
expect(includes[0]).toMatchObject({
filePath: 'main.py',
routerExpr: 'users_router',
prefix: '/users',
});
});
it('captures the unaliased shape `from <mod> import router`', () => {
const { imports } = run('main.py', ['from api.users import router', ''].join('\n'));
expect(imports).toHaveLength(1);
expect(imports[0]).toMatchObject({
localName: 'router',
moduleKey: 'users',
moduleKeyLong: 'api/users',
});
});
it('does NOT re-capture Shape A as Shape B (`<mod>.router` is not bare)', () => {
// Anti-regression: INCLUDE_ROUTER_NAME_RE is intentionally
// permissive (`(identifier)`). Without the lookahead in
// extractFastAPIRouterBindings it would re-capture the bare
// module name `users` from `users.router` and add a phantom
// include with `routerExpr: "users"`.
const { includes } = run(
'main.py',
["app.include_router(users.router, prefix='/users')", ''].join('\n'),
);
const shapes = includes.map((i) => i.routerExpr).sort();
expect(shapes).toEqual(['users.router']);
});
});
describe('nested FastAPI router prefix resolution', () => {
it('follows relative package and child imports without basename bleed', () => {
const root = runFull(
'src/one/app.py',
"from .api import router as api_router\napp.include_router(api_router, prefix='/api')",
);
const pkg = runFull(
'src/one/api/__init__.py',
'from .models import router as models_router\nrouter.include_router(models_router)',
);
const resolved = resolveFastAPIRouterPrefixes(
[
'src/one/app.py',
'src/one/api/__init__.py',
'src/one/api/models.py',
'src/two/api/models.py',
],
[...root.includes, ...pkg.includes],
[...root.imports, ...pkg.imports],
[...root.moduleAliases, ...pkg.moduleAliases],
);
expect(resolved.prefixesByFile.get('src/one/api/models.py')).toEqual(new Set(['/api']));
expect(resolved.prefixesByFile.has('src/two/api/models.py')).toBe(false);
expect(pkg.includes[0]).toMatchObject({
host: 'router',
routerExpr: 'models_router',
prefix: '',
});
});
it('declines an absolute import shared by multiple source roots', () => {
const root = runFull(
'main.py',
"from api.models import router as models_router\napp.include_router(models_router, prefix='/api')",
);
const resolved = resolveFastAPIRouterPrefixes(
['main.py', 'one/api/models.py', 'two/api/models.py'],
root.includes,
root.imports,
root.moduleAliases,
);
expect(resolved.prefixesByFile.size).toBe(0);
expect(resolved.resolvedIncludes.size).toBe(0);
});
});
describe('nested FastAPI router prefix resolution — edge cases', () => {
function resolveSources(sources: Record<string, string>) {
const parts = Object.entries(sources).map(([file, src]) => runFull(file, src));
return resolveFastAPIRouterPrefixes(
Object.keys(sources),
parts.flatMap((p) => p.includes),
parts.flatMap((p) => p.imports),
parts.flatMap((p) => p.moduleAliases),
parts.flatMap((p) => p.constructorPrefixes),
);
}
it('joins the parent APIRouter(prefix=...) between the mount and the child', () => {
const resolved = resolveSources({
'main.py':
"from api import router as api_router\napp.include_router(api_router, prefix='/api')",
'api/__init__.py': [
'from .agents import router as agents_router',
"router = APIRouter(prefix='/v1')",
'router.include_router(agents_router)',
].join('\n'),
'api/agents.py': 'router = APIRouter()',
});
expect(resolved.prefixesByFile.get('api/agents.py')).toEqual(new Set(['/api/v1']));
});
it('carries the parent APIRouter(prefix=...) when the parent is mounted bare', () => {
const resolved = resolveSources({
'main.py': 'from api import router as api_router\napp.include_router(api_router)',
'api/__init__.py': [
'from .agents import router as agents_router',
'from .models import router as models_router',
"router = APIRouter(prefix='/v1')",
'router.include_router(agents_router)',
'router.include_router(models_router)',
].join('\n'),
'api/agents.py': 'router = APIRouter()',
'api/models.py': 'router = APIRouter()',
});
expect(resolved.prefixesByFile.get('api/agents.py')).toEqual(new Set(['/v1']));
expect(resolved.prefixesByFile.has('api/__init__.py')).toBe(false);
});
it('records nothing for an all-empty bare chain', () => {
const resolved = resolveSources({
'main.py': 'from api import router as api_router\napp.include_router(api_router)',
'api/__init__.py':
'from .agents import router as agents_router\nrouter.include_router(agents_router)',
'api/agents.py': 'router = APIRouter()',
});
expect(resolved.prefixesByFile.size).toBe(0);
expect(resolved.resolvedIncludes.size).toBe(0);
});
it('expands a deep diamond-shaped include graph once per (file, prefix)', () => {
// 40 layers of two routers that each include both routers of the next
// layer: 2^39 root-to-leaf paths, 80 distinct files.
const layers = 40;
const sources: Record<string, string> = {
'main.py': "from pkg.l0_0 import router as root\napp.include_router(root, prefix='/api')",
};
for (let i = 0; i < layers; i++) {
for (const j of [0, 1]) {
sources[`pkg/l${i}_${j}.py`] =
i === layers - 1
? 'router = APIRouter()'
: [
`from .l${i + 1}_0 import router as a`,
`from .l${i + 1}_1 import router as b`,
'router.include_router(a)',
'router.include_router(b)',
].join('\n');
}
}
const resolved = resolveSources(sources);
expect(resolved.prefixesByFile.get(`pkg/l${layers - 1}_1.py`)).toEqual(new Set(['/api']));
});
it('passes a prefix through only a host literally named `router`', () => {
const resolved = resolveSources({
'main.py': "from api import api_router\napp.include_router(api_router.router, prefix='/api')",
'api/api_router.py': [
'from .items import router as items_router',
'api_router = APIRouter()',
'api_router.include_router(items_router)',
].join('\n'),
'api/items.py': 'router = APIRouter()',
});
expect(resolved.prefixesByFile.get('api/api_router.py')).toEqual(new Set(['/api']));
expect(resolved.prefixesByFile.has('api/items.py')).toBe(false);
});
});
describe('extractFastAPIRouterBindings — relative imports', () => {
it('captures single-dot relative imports (`from .calls import router as …`)', () => {
// FINDING 2: the previous regex `[A-Za-z_][\w.]*` rejected
// module paths starting with `.`, silently dropping every
// relative-import Shape-B include. The PR description's own
// motivating example used this shape — now pinned.
const { imports } = run(
'main.py',
['from .calls import router as calls_router', ''].join('\n'),
);
expect(imports).toHaveLength(1);
expect(imports[0]).toMatchObject({
localName: 'calls_router',
moduleKey: 'calls',
});
// Single-segment relative paths cannot be promoted to a long key.
expect(imports[0].moduleKeyLong).toBeUndefined();
});
it('captures multi-segment relative imports and emits a long key', () => {
const { imports } = run(
'main.py',
['from ..api.users import router as users_router', ''].join('\n'),
);
expect(imports).toHaveLength(1);
expect(imports[0]).toMatchObject({
localName: 'users_router',
moduleKey: 'users',
moduleKeyLong: 'api/users',
});
});
});
describe('extractFastAPIRouterBindings — long-key precision', () => {
it('emits long key `api/users` for a multi-segment absolute import', () => {
// FINDING 3: short-key-only collides for `api/users.py` vs
// `admin/users.py`. The long key gives parse-impl the precision
// it needs to bind a Shape-B include to the right file.
const { imports } = run('main.py', ['from api.users import router', ''].join('\n'));
expect(imports[0].moduleKeyLong).toBe('api/users');
});
it('omits the long key for a single-segment top-level import', () => {
const { imports } = run('main.py', ['from users import router', ''].join('\n'));
expect(imports[0].moduleKey).toBe('users');
expect(imports[0].moduleKeyLong).toBeUndefined();
});
});
describe('extractFastAPIRouterBindings — negative cases', () => {
it('emits nothing for files without any include_router or import', () => {
const { includes, imports } = run('helpers.py', 'def add(a, b):\n return a + b\n');
expect(includes).toEqual([]);
expect(imports).toEqual([]);
});
it('captures unprefixed include_router calls with other keywords as propagation edges', () => {
const { includes } = run(
'main.py',
[
'app.include_router(users.router, tags=["users"])',
'router.include_router(',
' items_router,',
')',
'',
].join('\n'),
);
expect(includes.map(({ host, routerExpr, prefix }) => ({ host, routerExpr, prefix }))).toEqual([
{ host: 'app', routerExpr: 'users.router', prefix: '' },
{ host: 'router', routerExpr: 'items_router', prefix: '' },
]);
});
it('captures prefix= after nested-call arguments without an extra unprefixed edge', () => {
const { includes } = run(
'main.py',
[
'app.include_router(users.router, tags=["users"], prefix="/users")',
'app.include_router(items.router, dependencies=[Depends(auth)], prefix="/items")',
'app.include_router(orders_router, dependencies=[Depends(auth)], prefix="/orders")',
'router.include_router(audit_router, dependencies=[Depends(auth)])',
'',
].join('\n'),
);
expect(includes.map(({ routerExpr, prefix }) => ({ routerExpr, prefix }))).toEqual([
{ routerExpr: 'users.router', prefix: '/users' },
{ routerExpr: 'items.router', prefix: '/items' },
{ routerExpr: 'orders_router', prefix: '/orders' },
{ routerExpr: 'audit_router', prefix: '' },
]);
});
it('does not capture include_router calls with a non-string prefix', () => {
// The current regex requires a string literal for the prefix
// value. Variables / f-strings / concatenations are not
// resolvable at parse time.
const { includes } = run(
'main.py',
['app.include_router(users.router, prefix=PREFIX_USERS)', ''].join('\n'),
);
expect(includes).toEqual([]);
});
it('ignores non-router names in `from … import` lists', () => {
const { imports } = run('main.py', ['from api.users import schemas, helpers', ''].join('\n'));
expect(imports).toEqual([]);
});
it('correctly handles a mixed import list (router + others)', () => {
const { imports } = run(
'main.py',
['from api.users import router, schemas, helpers', ''].join('\n'),
);
expect(imports).toHaveLength(1);
expect(imports[0].localName).toBe('router');
expect(imports[0].moduleKey).toBe('users');
});
});
describe('extractFastAPIRouterBindings — APIRouter constructor prefix', () => {
it('captures same-file APIRouter(prefix=...) declarations', () => {
const { constructorPrefixes } = runFull(
'api/items.py',
[
'from fastapi import APIRouter',
'router = APIRouter(prefix="/api/items", tags=["items"])',
'',
'@router.get("")',
'def list_items():',
' return []',
'',
].join('\n'),
);
expect(constructorPrefixes).toEqual([{ filePath: 'api/items.py', prefix: '/api/items' }]);
});
it('captures prefix after nested APIRouter arguments', () => {
const { constructorPrefixes } = runFull(
'api/items.py',
[
'from fastapi import APIRouter, Depends',
'router = APIRouter(dependencies=[Depends(get_db)], prefix="/api/items")',
'',
'@router.get("")',
'def list_items():',
' return []',
'',
].join('\n'),
);
expect(constructorPrefixes).toEqual([{ filePath: 'api/items.py', prefix: '/api/items' }]);
});
it('does not emit constructor prefixes for non-router receivers yet', () => {
const { constructorPrefixes } = runFull(
'api/items.py',
[
'from fastapi import APIRouter',
'api_router = APIRouter(prefix="/api")',
'',
'@api_router.get("/items")',
'def list_items():',
' return []',
'',
].join('\n'),
);
expect(constructorPrefixes).toEqual([]);
});
});