mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* fix(spring): extract RequestMapping route methods Co-authored-by: Cursor <cursoragent@cursor.com> * fix(spring): address RequestMapping review findings * fix(spring): accept trivia in request methods --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
309 lines
12 KiB
TypeScript
309 lines
12 KiB
TypeScript
/**
|
||
* Parity test for the two Spring route extractors (#2078 maintainer request,
|
||
* #2138 follow-up).
|
||
*
|
||
* GitNexus parses Spring `@(Get|Post|...)Mapping` annotations in TWO places:
|
||
* - ingestion `route-extractors/spring.ts` → `extractSpringRoutes` (produces
|
||
* graph `Route` nodes)
|
||
* - group `http-patterns/java.ts` → `JAVA_HTTP_PLUGIN.scan` (produces
|
||
* cross-repo HTTP contracts)
|
||
*
|
||
* They serve different layers and stay separate, but they MUST agree on the
|
||
* set of provider (method, path) routes they recognise for the same source —
|
||
* otherwise the graph under-covers what the group scan sees, which is exactly
|
||
* the divergence behind the #2265 array-form gap (the group query matched
|
||
* `@GetMapping({"/a","/b"})`, ingestion's didn't). This test runs one shared
|
||
* fixture through both and asserts the provider sets are identical for the
|
||
* shapes ingestion claims to cover: bare, named-arg, and array-form method
|
||
* routes under a *scalar* (or absent) class prefix.
|
||
*
|
||
* Known, deliberate divergence (NOT covered by the equality assertions): a
|
||
* method-level array route nested under a class-level *array-form*
|
||
* @RequestMapping. Ingestion suppresses it (it can't resolve which of several
|
||
* class prefixes to apply, and a dropped-prefix route is a wrong signal), while
|
||
* the group layer emits the full cross-product. The last test pins this so the
|
||
* suppression can't silently regress into emitting wrong routes; full
|
||
* class-array support is tracked in #2280.
|
||
*/
|
||
import { describe, it, expect } from 'vitest';
|
||
import Parser from 'tree-sitter';
|
||
import Java from 'tree-sitter-java';
|
||
import { extractSpringRoutes } from '../../../src/core/ingestion/route-extractors/spring.js';
|
||
import { JAVA_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/java.js';
|
||
|
||
function parse(src: string): Parser.Tree {
|
||
const p = new Parser();
|
||
p.setLanguage(Java);
|
||
return p.parse(src);
|
||
}
|
||
|
||
/** Canonical `METHOD /a/b` form so prefix-join / slash / case differences wash out. */
|
||
function canon(method: string, ...segments: string[]): string {
|
||
const path = `/${segments.join('/').split('/').filter(Boolean).join('/')}`;
|
||
return `${method.toUpperCase()} ${path.toLowerCase()}`;
|
||
}
|
||
|
||
/** ingestion side: join the class prefix + method path the way the routes phase does. */
|
||
function ingestionProviders(src: string): Set<string> {
|
||
return new Set(
|
||
extractSpringRoutes(parse(src), 'X.java').map((r) =>
|
||
canon(r.httpMethod, r.prefix ?? '', r.routePath),
|
||
),
|
||
);
|
||
}
|
||
|
||
/** group side: provider detections (path already prefix-joined by the plugin). */
|
||
function groupProviders(src: string): Set<string> {
|
||
return new Set(
|
||
JAVA_HTTP_PLUGIN.scan(parse(src))
|
||
.filter((d) => d.role === 'provider')
|
||
.map((d) => canon(d.method, d.path)),
|
||
);
|
||
}
|
||
|
||
function groupConsumers(src: string): Set<string> {
|
||
return new Set(
|
||
JAVA_HTTP_PLUGIN.scan(parse(src))
|
||
.filter((d) => d.role === 'consumer' && d.framework === 'openfeign')
|
||
.map((d) => canon(d.method, d.path)),
|
||
);
|
||
}
|
||
|
||
describe('Spring route extractor parity — ingestion spring.ts vs group java.ts', () => {
|
||
it('agree on bare, named-arg, and array-form method routes under a class prefix', () => {
|
||
const src = `package com.example;
|
||
import org.springframework.web.bind.annotation.*;
|
||
|
||
@RestController
|
||
@RequestMapping("/api/orders")
|
||
public class OrderController {
|
||
@GetMapping("/list") public Object list() { return null; }
|
||
@PostMapping(path = "/make") public Object make() { return null; }
|
||
@PutMapping(value = "/update") public Object update() { return null; }
|
||
@GetMapping({"/a", "/b"}) public Object multi() { return null; }
|
||
}
|
||
`;
|
||
const ingestion = ingestionProviders(src);
|
||
const group = groupProviders(src);
|
||
|
||
// The array form is the regression that motivated this: both must see all four.
|
||
expect(group).toEqual(
|
||
new Set([
|
||
'GET /api/orders/list',
|
||
'POST /api/orders/make',
|
||
'PUT /api/orders/update',
|
||
'GET /api/orders/a',
|
||
'GET /api/orders/b',
|
||
]),
|
||
);
|
||
expect(ingestion).toEqual(group);
|
||
});
|
||
|
||
it('agree on a no-prefix controller with a positional array', () => {
|
||
const src = `package com.example;
|
||
import org.springframework.web.bind.annotation.*;
|
||
|
||
@RestController
|
||
public class PlainController {
|
||
@GetMapping("/solo") public Object solo() { return null; }
|
||
@DeleteMapping({"/x", "/y", "/z"}) public Object many() { return null; }
|
||
}
|
||
`;
|
||
expect(ingestionProviders(src)).toEqual(groupProviders(src));
|
||
});
|
||
|
||
it('agree on named-arg ARRAY forms (path = {...} / value = {...})', () => {
|
||
// Guards the spring.ts named-array query branch specifically: positional
|
||
// arrays alone would not exercise it.
|
||
const src = `package com.example;
|
||
import org.springframework.web.bind.annotation.*;
|
||
|
||
@RestController
|
||
@RequestMapping("/api")
|
||
public class NamedArrayController {
|
||
@PutMapping(value = {"/update", "/modify"}) public Object upd() { return null; }
|
||
@DeleteMapping(path = {"/x", "/y"}) public Object del() { return null; }
|
||
}
|
||
`;
|
||
const group = groupProviders(src);
|
||
expect(group).toEqual(
|
||
new Set(['PUT /api/update', 'PUT /api/modify', 'DELETE /api/x', 'DELETE /api/y']),
|
||
);
|
||
expect(ingestionProviders(src)).toEqual(group);
|
||
});
|
||
|
||
it('agree on method-level @RequestMapping verbs and wildcard mappings', () => {
|
||
const src = `package com.example;
|
||
import org.springframework.web.bind.annotation.*;
|
||
|
||
@RestController
|
||
@RequestMapping("/api")
|
||
public class LegacyController {
|
||
@RequestMapping("/bare") public Object bare() { return null; }
|
||
@RequestMapping(value = "/named") public Object named() { return null; }
|
||
@RequestMapping(value = "/save", method = RequestMethod.POST)
|
||
public Object save() { return null; }
|
||
@RequestMapping(path = "/multi", method = {RequestMethod.GET, RequestMethod.HEAD})
|
||
public Object multi() { return null; }
|
||
@RequestMapping(path = {"/one", "/two"}, method = {RequestMethod.GET, RequestMethod.POST})
|
||
public Object crossProduct() { return null; }
|
||
@RequestMapping(path = "/empty", method = {})
|
||
public Object empty() { return null; }
|
||
@RequestMapping(path = "/spaced", method = RequestMethod . PUT)
|
||
public Object spaced() { return null; }
|
||
@RequestMapping(path = "/commented", method = {
|
||
RequestMethod.GET, // read
|
||
/* write */ RequestMethod.POST,
|
||
})
|
||
public Object commented() { return null; }
|
||
@org.springframework.web.bind.annotation.RequestMapping(
|
||
path = "/fqn",
|
||
method = org.springframework.web.bind.annotation.RequestMethod.DELETE
|
||
)
|
||
public Object fqn() { return null; }
|
||
}
|
||
`;
|
||
const expected = new Set([
|
||
'* /api/bare',
|
||
'* /api/named',
|
||
'POST /api/save',
|
||
'GET /api/multi',
|
||
'HEAD /api/multi',
|
||
'GET /api/one',
|
||
'POST /api/one',
|
||
'GET /api/two',
|
||
'POST /api/two',
|
||
'* /api/empty',
|
||
'PUT /api/spaced',
|
||
'GET /api/commented',
|
||
'POST /api/commented',
|
||
'DELETE /api/fqn',
|
||
]);
|
||
|
||
expect(groupProviders(src)).toEqual(expected);
|
||
expect(ingestionProviders(src)).toEqual(expected);
|
||
});
|
||
|
||
it('intersects class verbs and emits pathless method mappings at the class prefix', () => {
|
||
const src = `package com.example;
|
||
import org.springframework.web.bind.annotation.*;
|
||
|
||
@RestController
|
||
@RequestMapping(path = "/api", method = RequestMethod.GET)
|
||
public class ConstrainedController {
|
||
@RequestMapping("/wild") public Object wildcard() { return null; }
|
||
@RequestMapping(method = RequestMethod.GET) public Object root() { return null; }
|
||
@GetMapping public Object shortcut() { return null; }
|
||
@PostMapping("/blocked") public Object blocked() { return null; }
|
||
}
|
||
`;
|
||
|
||
const expected = new Set(['GET /api/wild', 'GET /api']);
|
||
expect(groupProviders(src)).toEqual(expected);
|
||
expect(ingestionProviders(src)).toEqual(expected);
|
||
});
|
||
|
||
it('uses GET for verb-less Feign RequestMapping and rejects multi-method contracts', () => {
|
||
const src = `package com.example;
|
||
import org.springframework.cloud.openfeign.FeignClient;
|
||
import org.springframework.web.bind.annotation.*;
|
||
|
||
@FeignClient(name = "orders")
|
||
@RequestMapping("/api")
|
||
public interface OrdersClient {
|
||
@RequestMapping("/default") Object defaultCall();
|
||
@RequestMapping(path = "/multi", method = {RequestMethod.GET, RequestMethod.POST})
|
||
Object ambiguous();
|
||
}
|
||
`;
|
||
|
||
expect(groupConsumers(src)).toEqual(new Set(['GET /api/default']));
|
||
});
|
||
it('fails closed when @RequestMapping method is not statically resolvable', () => {
|
||
const src = `package com.example;
|
||
import org.springframework.web.bind.annotation.*;
|
||
|
||
@RestController
|
||
public class DynamicController {
|
||
private static final RequestMethod VERB = RequestMethod.POST;
|
||
@RequestMapping(path = "/dynamic", method = VERB)
|
||
public Object dynamic() { return null; }
|
||
}
|
||
`;
|
||
|
||
expect(groupProviders(src)).toEqual(new Set());
|
||
expect(ingestionProviders(src)).toEqual(new Set());
|
||
});
|
||
|
||
it('do not leak non-route arrays (consumes/produces) as routes — array analogue', () => {
|
||
// The scalar `produces` anti-regression already exists in the route tests;
|
||
// this is its array form. `consumes`/`produces` arrays must never surface as
|
||
// provider routes; only the path value does.
|
||
const src = `package com.example;
|
||
import org.springframework.web.bind.annotation.*;
|
||
|
||
@RestController
|
||
public class ContentTypeController {
|
||
@GetMapping(value = "/v", consumes = {"application/json", "application/xml"}, produces = {"application/json"})
|
||
public Object v() { return null; }
|
||
@PostMapping(consumes = {"application/json"})
|
||
public Object noPath() { return null; }
|
||
}
|
||
`;
|
||
const ingestion = ingestionProviders(src);
|
||
const group = groupProviders(src);
|
||
// Non-route arrays never become paths; the pathless POST binds to root.
|
||
expect(group).toEqual(new Set(['GET /v', 'POST /']));
|
||
expect(ingestion).toEqual(group);
|
||
|
||
// Pure consumes/produces members still describe pathless root mappings;
|
||
// their media-type values must never be misread as route paths.
|
||
const consumesOnly = `package com.example;
|
||
import org.springframework.web.bind.annotation.*;
|
||
|
||
@RestController
|
||
public class ConsumesOnlyController {
|
||
@PostMapping(consumes = {"application/json", "application/xml"})
|
||
public Object a() { return null; }
|
||
@PutMapping(produces = {"application/json"})
|
||
public Object b() { return null; }
|
||
}
|
||
`;
|
||
expect(groupProviders(consumesOnly)).toEqual(new Set(['POST /', 'PUT /']));
|
||
expect(ingestionProviders(consumesOnly)).toEqual(new Set(['POST /', 'PUT /']));
|
||
});
|
||
|
||
it('pins the deliberate class-array divergence: ingestion suppresses, group emits cross-product (#2280)', () => {
|
||
// A method-level array under a class-level ARRAY-form @RequestMapping. There
|
||
// is no single class prefix to apply, so ingestion suppresses the route
|
||
// rather than emit it unprefixed (a wrong signal). The group layer emits the
|
||
// full cross-product. This is a KNOWN gap (#2280), pinned here so the
|
||
// suppression can't silently regress into emitting wrong unprefixed routes.
|
||
const src = `package com.example;
|
||
import org.springframework.web.bind.annotation.*;
|
||
|
||
@RestController
|
||
@RequestMapping({"/base/one", "/base/two"})
|
||
public class MultiPrefixController {
|
||
@GetMapping({"/primary", "/alias"}) public Object x() { return null; }
|
||
}
|
||
`;
|
||
const ingestion = ingestionProviders(src);
|
||
const group = groupProviders(src);
|
||
|
||
// group: correct cross-product of the two class prefixes × two method paths.
|
||
expect(group).toEqual(
|
||
new Set([
|
||
'GET /base/one/primary',
|
||
'GET /base/two/primary',
|
||
'GET /base/one/alias',
|
||
'GET /base/two/alias',
|
||
]),
|
||
);
|
||
// ingestion: suppressed — emits NO route for the array method (never a wrong
|
||
// unprefixed `GET /primary` / `GET /alias`).
|
||
expect(ingestion).toEqual(new Set());
|
||
// And the divergence is asymmetric-by-design: ingestion ⊊ group here.
|
||
expect(ingestion).not.toEqual(group);
|
||
});
|
||
});
|