GitNexus/gitnexus/test/integration/api-impact-method-e2e.test.ts
Gergő Magyar 57e4afa4c8
Some checks failed
Scorecard / Scorecard analysis (push) Has been cancelled
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
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(mcp): stabilize api_impact response shape for same-URL multi-verb routes (#2308) (#2309)
* fix(mcp): stabilize api_impact response shape for same-URL multi-verb routes

After #2302 made Route identity method-aware, a same URL exposes one Route
node per HTTP verb, so a bare-URL api_impact lookup could silently flip from a
direct route object to the wrapped { routes, total } envelope. Surface each
route's `method` (via the shared fetch) so multi-verb results are
distinguishable, and add an optional `method` selector that narrows a
multi-verb URL/file to one verb and forces the singular shape. A verb that
matches no route returns a clear error. Document the match-count contract in
the tool schema.

Refs #2308

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

* test(mcp): cover same-URL multi-verb api_impact contract

Regression coverage for #2308: bare-URL and bare-file lookups of a same-URL
GET+POST pair return the wrapped form with distinct per-route methods; the
method selector collapses to the singular shape (case-insensitively); an
unmatched verb returns a verb-not-found error; and verbless routes surface a
null method.

Refs #2308

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

* fix(review): apply autofix feedback

- tools.ts: correct api_impact contract docs — `method` narrows to one verb
  but the singular shape only holds when exactly one route remains after
  filtering (substring route/file matches can still wrap); cover file lookups;
  enumerate verbs.
- local-backend.ts: surface `method` in route_map and shape_check output (the
  shared fetch already returns it; agents discover verbs there before
  api_impact).
- local-backend.ts: compute routeCountByHandler from the unfiltered match so a
  method-scoped api_impact still flags a multi-verb handler's partial middleware.
- tests: add file+method and verbless-exclusion cases; assert unconditionally
  via toMatchObject; lowercase the verb-not-found input to exercise error
  uppercasing.

Refs #2308

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

* fix(mcp): treat wildcard '*' routes as matching any api_impact method selector (#2308)

Method-agnostic routes (Django function views) persist with Route method
'*', not null. The api_impact method selector used exact verb equality, so
'*' routes were excluded and api_impact({route, method:'POST'}) falsely
reported 'No routes found' for a route that handles every verb. Treat '*'
as matching any requested verb, and correct the comment + tool-description
strings that wrongly grouped Django wildcards with null/verbless routes.

* fix(mcp): harden api_impact method input against non-string and empty values (#2308)

The MCP envelope is not schema-validated, so a non-string `method` reached
`.toUpperCase()` and threw a TypeError. Widen the param to `unknown` and guard
it with a typeof check that returns a structured error (mirroring the
resolveAliasString pattern from #2175), and collapse empty/whitespace verbs to
no selector.

* fix(mcp): distinguish url-not-found from verb-not-found in api_impact error (#2308)

The verb-not-found error appended 'with method "X"' even when the URL/file
itself did not exist, implying the URL exists with other verbs. Gate the verb
clause on matched.length > 0 so a non-existent URL/file gets the plain message.

* fix(mcp): clarify api_impact middlewareNote wording for verbless siblings (#2308)

The partial-middleware note claimed 'other methods in this handler' even when
the co-located sibling is a verbless (null) route rather than another HTTP
verb. Refer to 'other route exports' instead, which covers both cases.

* docs(mcp): document and test the method field on route_map and shape_check (#2308)

The shared fetchRoutesWithConsumers change surfaced a method key on route_map
and shape_check responses too, but their tool descriptions never mentioned it
and no test covered it. Document the field on both descriptions and add unit
tests asserting it (shape_check rows carry responseKeys + a consumer so they
survive shape_check's keys-and-consumers filter).

* test(mcp): cover middlewareDetection 'partial' survival under a method filter (#2308)

The diff's core behavioral line counts verbs-per-handler from the unfiltered
match set so a method-scoped query still flags a multi-verb handler's partial
middleware, but no test exercised it (every verbRow hardcoded middleware:null).
Add a middleware param to verbRow and a test that fails if the count is taken
from the post-filter set instead. Verified via mutation: matched->routes fails it.

* test(mcp): add live-LadybugDB integration coverage for route method round-trip (#2308)

The new n.method query column was only unit-mocked. Add a self-contained
integration suite that seeds GET+POST /api/orders and a method-agnostic '*'
Django route, then asserts api_impact surfaces method, narrows by verb, and
matches the '*' route end-to-end (the U1 fix), plus route_map surfacing.
Own seed + no FTS so it neither perturbs api-impact-e2e nor silently skips.

* refactor(mcp): type the api_impact response shape instead of Promise<any> (#2308)

Replace apiImpact's Promise<any> with an explicit ApiImpactResult union
(single route | wrapped { routes, total } | { error }) and a typed
ApiImpactRoute. The results.map is annotated so the response builder is
checked against the declared shape. Behavior unchanged; sibling MCP methods
keep their Promise<any> convention.

* fix(mcp): express the route-or-file requirement in the api_impact schema (#2308)

The inputSchema left route/file as bare optionals, so the 'at least one of
route/file' rule the handler enforces was invisible to clients. Add an optional
anyOf to ToolDefinition (forwarded verbatim by the ListTools handler) and an
anyOf:[{required:[route]},{required:[file]}] on api_impact. Matches runtime
(both allowed, route wins); 'at least one' not 'exactly one'.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:50:10 +01:00

112 lines
4.7 KiB
TypeScript

/**
* E2E Integration Tests: api_impact / route_map method round-trip (#2308)
*
* Proves the `n.method` column round-trips through a real LadybugDB — the
* fetchRoutesWithConsumers query, the polymorphic api_impact shape, and the
* method selector (including the method-agnostic '*' case) — beyond the unit
* mocks in calltool-dispatch.test.ts.
*
* Self-contained seed (own routes, no FTS) so it neither perturbs the shared
* api-impact-e2e fixture nor silently skips on an FTS-less box: api_impact and
* route_map use graph queries, not full-text search.
*/
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
// After #2302 a Route node is keyed by (method, url): GET/POST of the same URL
// are distinct nodes, while a method-agnostic route (Django function view)
// keys by URL alone and persists with the literal method '*'.
const METHOD_SEED_DATA = [
`CREATE (f:File {id: 'file:app/api/orders/route.ts', name: 'route.ts', filePath: 'app/api/orders/route.ts', content: 'export async function GET() {} export async function POST() {}'})`,
`CREATE (f:File {id: 'file:orders/views.py', name: 'views.py', filePath: 'orders/views.py', content: 'def order_view(request): ...'})`,
`CREATE (r:Route {id: 'Route:GET /api/orders', name: '/api/orders', filePath: 'app/api/orders/route.ts', method: 'GET', responseKeys: ['data'], errorKeys: [], middleware: []})`,
`CREATE (r:Route {id: 'Route:POST /api/orders', name: '/api/orders', filePath: 'app/api/orders/route.ts', method: 'POST', responseKeys: ['id'], errorKeys: [], middleware: []})`,
`CREATE (r:Route {id: 'Route:/django/view', name: '/django/view', filePath: 'orders/views.py', method: '*', responseKeys: [], errorKeys: [], middleware: []})`,
];
withTestLbugDB(
'api-impact-method-e2e',
(handle) => {
let backend: LocalBackend;
beforeAll(async () => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) {
throw new Error(
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
);
}
backend = ext._backend;
});
describe('api_impact method round-trip (live LadybugDB)', () => {
it('returns the wrapped form for a same-URL multi-verb route, each with its method', async () => {
const result = await backend.callTool('api_impact', { route: '/api/orders' });
expect(result).not.toHaveProperty('error');
expect(result.total).toBe(2);
expect(result.routes.map((r: { method: string | null }) => r.method).sort()).toEqual([
'GET',
'POST',
]);
});
it('narrows a multi-verb URL to one route when method is given', async () => {
const result = await backend.callTool('api_impact', {
route: '/api/orders',
method: 'POST',
});
expect(result.method).toBe('POST');
expect(result.route).toBe('/api/orders');
expect(result.routes).toBeUndefined();
});
it('matches a method-agnostic (*) route against a specific method selector', async () => {
const result = await backend.callTool('api_impact', {
route: '/django/view',
method: 'POST',
});
expect(result).not.toHaveProperty('error');
expect(result.method).toBe('*');
expect(result.route).toBe('/django/view');
});
});
describe('route_map method round-trip (live LadybugDB)', () => {
it('surfaces each route method', async () => {
const result = await backend.callTool('route_map', { route: '/api/orders' });
expect(result.routes.map((r: { method: string | null }) => r.method).sort()).toEqual([
'GET',
'POST',
]);
});
});
},
{
seed: METHOD_SEED_DATA,
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'test-method-repo',
path: '/test/method-repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc789',
stats: { files: 2, nodes: 5, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as typeof handle & { _backend?: LocalBackend })._backend = backend;
},
},
);