mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
316 lines
14 KiB
TypeScript
316 lines
14 KiB
TypeScript
/**
|
|
* E2E Integration Tests: Deep Flow Detection (api_impact, route_map, shape_check)
|
|
*
|
|
* Tests the full stack: LadybugDB seed → MCP tool call → result verification.
|
|
* Covers response shape extraction, mismatch detection, middleware chains,
|
|
* multi-fetch attribution, and error shape separation.
|
|
*
|
|
* Uses hand-crafted Cypher seed data that represents what the pipeline
|
|
* would produce for a Next.js project with API routes and consumers.
|
|
*/
|
|
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';
|
|
import { API_IMPACT_SEED_DATA, API_IMPACT_FTS_INDEXES } from '../fixtures/api-impact-seed.js';
|
|
|
|
vi.mock('../../src/storage/repo-manager.js', () => ({
|
|
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
|
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
|
}));
|
|
|
|
withTestLbugDB('api-impact-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;
|
|
});
|
|
|
|
// ─── Test 1: api_impact round-trip ─────────────────────────────────
|
|
|
|
describe('api_impact round-trip', () => {
|
|
it('returns response shape with success and error keys for /api/grants', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.route).toBe('/api/grants');
|
|
expect(result.handler).toBe('app/api/grants/route.ts');
|
|
|
|
// Verify success keys
|
|
expect(result.responseShape).toBeDefined();
|
|
expect(result.responseShape.success).toContain('data');
|
|
expect(result.responseShape.success).toContain('pagination');
|
|
|
|
// Verify error keys
|
|
expect(result.responseShape.error).toContain('error');
|
|
expect(result.responseShape.error).toContain('message');
|
|
});
|
|
|
|
it('includes GrantsList and useGrants as consumers', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
expect(result.consumers).toBeDefined();
|
|
expect(result.consumers.length).toBeGreaterThanOrEqual(2);
|
|
|
|
const consumerFiles = result.consumers.map((c: any) => c.file);
|
|
expect(consumerFiles).toContain('components/GrantsList.tsx');
|
|
expect(consumerFiles).toContain('hooks/useGrants.ts');
|
|
});
|
|
|
|
it('returns impact summary with risk level', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
expect(result.impactSummary).toBeDefined();
|
|
expect(result.impactSummary.directConsumers).toBeGreaterThanOrEqual(2);
|
|
expect(['LOW', 'MEDIUM', 'HIGH']).toContain(result.impactSummary.riskLevel);
|
|
});
|
|
});
|
|
|
|
// ─── Test 2: Mismatch detection ────────────────────────────────────
|
|
|
|
describe('mismatch detection e2e', () => {
|
|
it('detects mismatches when consumer accesses field not in handler response', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
// useGrants accesses 'items' which is not in grants route responseKeys [data, pagination]
|
|
// useMulti accesses 'meta' which is not in grants route responseKeys
|
|
expect(result.mismatches).toBeDefined();
|
|
expect(result.mismatches.length).toBeGreaterThanOrEqual(1);
|
|
|
|
// Check that at least one mismatch has a field and consumer
|
|
const mismatch = result.mismatches[0];
|
|
expect(mismatch).toHaveProperty('consumer');
|
|
expect(mismatch).toHaveProperty('field');
|
|
expect(mismatch).toHaveProperty('confidence');
|
|
expect(['high', 'low']).toContain(mismatch.confidence);
|
|
});
|
|
|
|
it('flags items access from useGrants as a mismatch with high confidence', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
const itemsMismatch = result.mismatches.find(
|
|
(m: any) => m.field === 'items' && m.consumer === 'hooks/useGrants.ts'
|
|
);
|
|
expect(itemsMismatch).toBeDefined();
|
|
// useGrants only fetches one route, so confidence should be high
|
|
expect(itemsMismatch!.confidence).toBe('high');
|
|
});
|
|
|
|
it('bumps risk level when mismatches exist', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
// With 3 consumers (LOW base) + mismatches → should be at least MEDIUM
|
|
expect(['MEDIUM', 'HIGH']).toContain(result.impactSummary.riskLevel);
|
|
});
|
|
});
|
|
|
|
// ─── Test 3: Multi-fetch attribution ───────────────────────────────
|
|
|
|
describe('multi-fetch attribution', () => {
|
|
it('adds attributionNote for multi-fetch consumers', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
const multiConsumer = result.consumers.find(
|
|
(c: any) => c.file === 'hooks/useMulti.ts'
|
|
);
|
|
expect(multiConsumer).toBeDefined();
|
|
expect(multiConsumer!.attributionNote).toBeDefined();
|
|
expect(multiConsumer!.attributionNote).toContain('fetches 2 routes');
|
|
});
|
|
|
|
it('marks multi-fetch mismatches with low confidence', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
// useMulti accesses 'meta' on /api/grants — but since it fetches 2 routes,
|
|
// the mismatch confidence should be 'low'
|
|
const metaMismatch = result.mismatches.find(
|
|
(m: any) => m.field === 'meta' && m.consumer === 'hooks/useMulti.ts'
|
|
);
|
|
expect(metaMismatch).toBeDefined();
|
|
expect(metaMismatch!.confidence).toBe('low');
|
|
});
|
|
});
|
|
|
|
// ─── Test 4: Middleware chain round-trip ────────────────────────────
|
|
|
|
describe('middleware chain round-trip', () => {
|
|
it('returns middleware array for wrapped route', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/secure' });
|
|
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.middleware).toBeDefined();
|
|
expect(result.middleware).toContain('withAuth');
|
|
expect(result.middleware).toContain('withRateLimit');
|
|
});
|
|
|
|
it('flags middlewareDetection as partial when handler has multiple method exports', async () => {
|
|
// /api/secure has both GET (wrapped) and POST (unwrapped) in the same file
|
|
// The route appears twice in results (one per method export via HANDLES_ROUTE)
|
|
// but fetchRoutesWithConsumers deduplicates by Route node ID, so we get one
|
|
// Route node with 2 HANDLES_ROUTE edges → routeCountByHandler > 1
|
|
const result = await backend.callTool('api_impact', { route: '/api/secure' });
|
|
|
|
// Since two route handler functions point to the same file,
|
|
// and there's only one Route node, middlewareDetection should be partial
|
|
// if the route count per handler > 1
|
|
// Note: The actual behavior depends on whether multiple HANDLES_ROUTE edges
|
|
// cause multiple route entries. Let's verify middleware is present at minimum.
|
|
expect(result.middleware.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('route_map also shows middleware', async () => {
|
|
const result = await backend.callTool('route_map', { route: '/api/secure' });
|
|
|
|
expect(result.routes).toBeDefined();
|
|
const secureRoute = result.routes.find((r: any) => r.route === '/api/secure');
|
|
expect(secureRoute).toBeDefined();
|
|
expect(secureRoute!.middleware).toContain('withAuth');
|
|
expect(secureRoute!.middleware).toContain('withRateLimit');
|
|
});
|
|
});
|
|
|
|
// ─── Test 5: Error shape separation ────────────────────────────────
|
|
|
|
describe('error shape separation', () => {
|
|
it('stores responseKeys and errorKeys separately', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
// Success keys
|
|
expect(result.responseShape.success).toEqual(
|
|
expect.arrayContaining(['data', 'pagination'])
|
|
);
|
|
// Error keys
|
|
expect(result.responseShape.error).toEqual(
|
|
expect.arrayContaining(['error', 'message'])
|
|
);
|
|
// They should be distinct sets
|
|
const successSet = new Set(result.responseShape.success);
|
|
const errorSet = new Set(result.responseShape.error);
|
|
// No overlap between success and error keys in this fixture
|
|
for (const key of errorSet) {
|
|
expect(successSet.has(key)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('consumer accessing error key does not trigger mismatch', async () => {
|
|
// GrantsList accesses 'data' and 'pagination' — both in success keys
|
|
// Neither triggers a mismatch
|
|
const result = await backend.callTool('api_impact', { route: '/api/grants' });
|
|
|
|
const grantsListMismatches = result.mismatches?.filter(
|
|
(m: any) => m.consumer === 'components/GrantsList.tsx'
|
|
) ?? [];
|
|
expect(grantsListMismatches.length).toBe(0);
|
|
});
|
|
|
|
it('shape_check shows both responseKeys and errorKeys', async () => {
|
|
const result = await backend.callTool('shape_check', { route: '/api/grants' });
|
|
|
|
expect(result.routes).toBeDefined();
|
|
const grantsRoute = result.routes.find((r: any) => r.route === '/api/grants');
|
|
expect(grantsRoute).toBeDefined();
|
|
|
|
// shapeCheck returns responseKeys and errorKeys separately
|
|
if (grantsRoute!.responseKeys) {
|
|
expect(grantsRoute!.responseKeys).toContain('data');
|
|
expect(grantsRoute!.responseKeys).toContain('pagination');
|
|
}
|
|
if (grantsRoute!.errorKeys) {
|
|
expect(grantsRoute!.errorKeys).toContain('error');
|
|
expect(grantsRoute!.errorKeys).toContain('message');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Test 6: Tool consistency ──────────────────────────────────────
|
|
|
|
describe('tool consistency across route_map, shape_check, api_impact', () => {
|
|
it('consumer counts match across all 3 tools for /api/grants', async () => {
|
|
const [routeMapResult, shapeCheckResult, apiImpactResult] = await Promise.all([
|
|
backend.callTool('route_map', { route: '/api/grants' }),
|
|
backend.callTool('shape_check', { route: '/api/grants' }),
|
|
backend.callTool('api_impact', { route: '/api/grants' }),
|
|
]);
|
|
|
|
// route_map consumers
|
|
const rmRoute = routeMapResult.routes.find((r: any) => r.route === '/api/grants');
|
|
expect(rmRoute).toBeDefined();
|
|
const rmConsumerCount = rmRoute!.consumers.length;
|
|
|
|
// shape_check consumers
|
|
const scRoute = shapeCheckResult.routes.find((r: any) => r.route === '/api/grants');
|
|
expect(scRoute).toBeDefined();
|
|
const scConsumerCount = scRoute!.consumers.length;
|
|
|
|
// api_impact consumers
|
|
const aiConsumerCount = apiImpactResult.consumers.length;
|
|
|
|
// All three should report the same number of consumers
|
|
expect(rmConsumerCount).toBe(aiConsumerCount);
|
|
expect(scConsumerCount).toBe(aiConsumerCount);
|
|
});
|
|
|
|
it('all tools return the same handler file', async () => {
|
|
const [routeMapResult, shapeCheckResult, apiImpactResult] = await Promise.all([
|
|
backend.callTool('route_map', { route: '/api/grants' }),
|
|
backend.callTool('shape_check', { route: '/api/grants' }),
|
|
backend.callTool('api_impact', { route: '/api/grants' }),
|
|
]);
|
|
|
|
const rmHandler = routeMapResult.routes.find((r: any) => r.route === '/api/grants')?.handler;
|
|
const scHandler = shapeCheckResult.routes.find((r: any) => r.route === '/api/grants')?.handler;
|
|
const aiHandler = apiImpactResult.handler;
|
|
|
|
expect(rmHandler).toBe('app/api/grants/route.ts');
|
|
expect(scHandler).toBe('app/api/grants/route.ts');
|
|
expect(aiHandler).toBe('app/api/grants/route.ts');
|
|
});
|
|
});
|
|
|
|
// ─── Edge cases ────────────────────────────────────────────────────
|
|
|
|
describe('api_impact edge cases', () => {
|
|
it('returns error when neither route nor file is provided', async () => {
|
|
const result = await backend.callTool('api_impact', {});
|
|
expect(result).toHaveProperty('error');
|
|
expect(result.error).toMatch(/required/i);
|
|
});
|
|
|
|
it('returns error for nonexistent route', async () => {
|
|
const result = await backend.callTool('api_impact', { route: '/api/nonexistent' });
|
|
expect(result).toHaveProperty('error');
|
|
expect(result.error).toMatch(/no routes found/i);
|
|
});
|
|
|
|
it('can look up route by file path', async () => {
|
|
const result = await backend.callTool('api_impact', { file: 'app/api/grants/route.ts' });
|
|
expect(result).not.toHaveProperty('error');
|
|
expect(result.route).toBe('/api/grants');
|
|
});
|
|
});
|
|
|
|
}, {
|
|
seed: API_IMPACT_SEED_DATA,
|
|
ftsIndexes: API_IMPACT_FTS_INDEXES,
|
|
poolAdapter: true,
|
|
afterSetup: async (handle) => {
|
|
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
|
{
|
|
name: 'test-api-repo',
|
|
path: '/test/api-repo',
|
|
storagePath: handle.tmpHandle.dbPath,
|
|
indexedAt: new Date().toISOString(),
|
|
lastCommit: 'def456',
|
|
stats: { files: 5, nodes: 10, communities: 1, processes: 0 },
|
|
},
|
|
]);
|
|
|
|
const backend = new LocalBackend();
|
|
await backend.init();
|
|
(handle as any)._backend = backend;
|
|
},
|
|
});
|