package contracts import ( "testing" "github.com/zzet/gortex/internal/graph" ) func makeNodes(filePath string, fns []struct { name string start, end int }) []*graph.Node { var nodes []*graph.Node for _, f := range fns { nodes = append(nodes, &graph.Node{ ID: filePath + "::" + f.name, Kind: graph.KindFunction, Name: f.name, FilePath: filePath, StartLine: f.start, EndLine: f.end, }) } return nodes } // TestHTTPExtractor_Go_Gin_HandlerResolution exercises the T1.3 path: when // the pattern captures the handler identifier (e.g. "listUsers" in // r.GET("/users", listUsers)) AND that identifier resolves to a function // node in the same file, the Contract's SymbolID is the handler — not the // enclosing setupRoutes function. Cross-service traversals landing on the // provider side then reach business logic, not the router glue. // // When the handler doesn't resolve (e.g. lambda, method expr, different // file) the code falls back to enclosing-symbol behavior — covered by // TestHTTPExtractor_Go_Gin above, which deliberately omits handler nodes. func TestHTTPExtractor_Go_Gin_HandlerResolution(t *testing.T) { src := []byte(`package main import "github.com/gin-gonic/gin" func setupRoutes(r *gin.Engine) { r.GET("/api/users", listUsers) r.POST("/api/users", createUser) } func listUsers() {} func createUser() {} `) nodes := makeNodes("main.go", []struct { name string start, end int }{ {"setupRoutes", 5, 8}, {"listUsers", 10, 10}, {"createUser", 11, 11}, }) ext := &HTTPExtractor{} contracts := ext.Extract("main.go", src, nodes, nil) byPath := map[string]Contract{} for _, c := range contracts { byPath[c.ID] = c } get := byPath["http::GET::/api/users"] if get.SymbolID != "main.go::listUsers" { t.Errorf("GET handler: expected SymbolID=main.go::listUsers (handler), got %q", get.SymbolID) } post := byPath["http::POST::/api/users"] if post.SymbolID != "main.go::createUser" { t.Errorf("POST handler: expected SymbolID=main.go::createUser (handler), got %q", post.SymbolID) } } func TestHTTPExtractor_Go_Gin(t *testing.T) { src := []byte(`package main import "github.com/gin-gonic/gin" func setupRoutes(r *gin.Engine) { r.GET("/api/users", listUsers) r.POST("/api/users", createUser) r.GET("/api/users/:id", getUser) r.DELETE("/api/users/:id", deleteUser) } `) nodes := makeNodes("main.go", []struct { name string start, end int }{ {"setupRoutes", 5, 10}, }) ext := &HTTPExtractor{} contracts := ext.Extract("main.go", src, nodes, nil) if len(contracts) != 4 { t.Fatalf("expected 4 contracts, got %d", len(contracts)) } // Check first contract. c := contracts[0] if c.Type != ContractHTTP { t.Errorf("expected type http, got %s", c.Type) } if c.Role != RoleProvider { t.Errorf("expected role provider, got %s", c.Role) } if c.Meta["method"] != "GET" { t.Errorf("expected method GET, got %s", c.Meta["method"]) } if c.Meta["path"] != "/api/users" { t.Errorf("expected path /api/users, got %s", c.Meta["path"]) } if c.ID != "http::GET::/api/users" { t.Errorf("expected ID http::GET::/api/users, got %s", c.ID) } if c.SymbolID != "main.go::setupRoutes" { t.Errorf("expected symbol main.go::setupRoutes, got %s", c.SymbolID) } if c.Confidence != 0.9 { t.Errorf("expected confidence 0.9, got %f", c.Confidence) } // Check path param normalisation. Param names collapse to // positional {p1}, {p2}, ... so a provider's {id} and a // consumer's {userId} hash to the same contract ID. c3 := contracts[2] if c3.Meta["path"] != "/api/users/{p1}" { t.Errorf("expected normalised path /api/users/{p1}, got %s", c3.Meta["path"]) } } func TestHTTPExtractor_TypeScript_Express(t *testing.T) { src := []byte(` import express from 'express'; const app = express(); function registerRoutes() { app.get('/api/products', listProducts); app.post('/api/products', createProduct); app.get('/api/products/:id', getProduct); } `) nodes := makeNodes("routes.ts", []struct { name string start, end int }{ {"registerRoutes", 5, 9}, }) ext := &HTTPExtractor{} contracts := ext.Extract("routes.ts", src, nodes, nil) if len(contracts) != 3 { t.Fatalf("expected 3 contracts, got %d", len(contracts)) } c := contracts[0] if c.Meta["framework"] != "express" { t.Errorf("expected framework express, got %s", c.Meta["framework"]) } if c.Meta["method"] != "GET" { t.Errorf("expected method GET, got %s", c.Meta["method"]) } if c.Meta["path"] != "/api/products" { t.Errorf("expected path /api/products, got %s", c.Meta["path"]) } } func TestHTTPExtractor_Python_FastAPI(t *testing.T) { src := []byte(` from fastapi import FastAPI app = FastAPI() @app.get("/api/items") def list_items(): return [] @app.post("/api/items") def create_item(item: Item): return item @app.get("/api/items/{item_id}") def get_item(item_id: int): return item_id `) nodes := makeNodes("main.py", []struct { name string start, end int }{ {"list_items", 6, 7}, {"create_item", 10, 11}, {"get_item", 14, 15}, }) ext := &HTTPExtractor{} contracts := ext.Extract("main.py", src, nodes, nil) if len(contracts) != 3 { t.Fatalf("expected 3 contracts, got %d", len(contracts)) } if contracts[0].Meta["method"] != "GET" { t.Errorf("expected GET, got %s", contracts[0].Meta["method"]) } if contracts[0].Meta["path"] != "/api/items" { t.Errorf("expected /api/items, got %s", contracts[0].Meta["path"]) } if contracts[0].Meta["framework"] != "fastapi/flask" { t.Errorf("expected fastapi/flask, got %s", contracts[0].Meta["framework"]) } } func TestHTTPExtractor_Java_Spring(t *testing.T) { src := []byte(` @RestController @RequestMapping("/api") public class UserController { @GetMapping("/users") public List listUsers() { return userService.findAll(); } @PostMapping("/users") public User createUser(@RequestBody User user) { return userService.save(user); } @DeleteMapping("/users/{id}") public void deleteUser(@PathVariable Long id) { userService.delete(id); } } `) nodes := makeNodes("UserController.java", []struct { name string start, end int }{ {"listUsers", 6, 8}, {"createUser", 11, 13}, {"deleteUser", 16, 18}, }) ext := &HTTPExtractor{} contracts := ext.Extract("UserController.java", src, nodes, nil) // Should detect @RequestMapping + @GetMapping + @PostMapping + @DeleteMapping if len(contracts) < 3 { t.Fatalf("expected at least 3 contracts, got %d", len(contracts)) } // Find the GetMapping contract. found := false for _, c := range contracts { if c.Meta["method"] == "GET" && c.Meta["path"] == "/users" { found = true if c.Meta["framework"] != "spring" { t.Errorf("expected spring framework, got %s", c.Meta["framework"]) } } } if !found { t.Error("did not find GET /users contract from @GetMapping") } } func TestHTTPExtractor_Consumers(t *testing.T) { src := []byte(`package main import "net/http" func callAPI() { http.Get("http://service-b/api/users") http.NewRequest("POST", "http://service-b/api/orders", nil) } `) nodes := makeNodes("client.go", []struct { name string start, end int }{ {"callAPI", 5, 8}, }) ext := &HTTPExtractor{} contracts := ext.Extract("client.go", src, nodes, nil) if len(contracts) != 2 { t.Fatalf("expected 2 contracts, got %d", len(contracts)) } for _, c := range contracts { if c.Role != RoleConsumer { t.Errorf("expected consumer role, got %s", c.Role) } } } func TestHTTPExtractor_JS_Consumers(t *testing.T) { src := []byte(` async function fetchData() { const users = await fetch('/api/users'); const result = await axios.post('/api/orders', data); } `) nodes := makeNodes("api.js", []struct { name string start, end int }{ {"fetchData", 2, 5}, }) ext := &HTTPExtractor{} contracts := ext.Extract("api.js", src, nodes, nil) if len(contracts) != 2 { t.Fatalf("expected 2 contracts, got %d", len(contracts)) } // fetch is confidence 0.7, axios is 0.9 for _, c := range contracts { if c.Role != RoleConsumer { t.Errorf("expected consumer, got %s", c.Role) } } } func TestHTTPExtractor_Python_Consumers(t *testing.T) { src := []byte(` import requests def call_service(): resp = requests.get("http://service/api/users") resp2 = requests.post("http://service/api/orders") `) nodes := makeNodes("client.py", []struct { name string start, end int }{ {"call_service", 4, 6}, }) ext := &HTTPExtractor{} contracts := ext.Extract("client.py", src, nodes, nil) if len(contracts) != 2 { t.Fatalf("expected 2 contracts, got %d", len(contracts)) } } func TestNormalizeHTTPPath(t *testing.T) { tests := []struct { name string input string expected string }{ // Path parameters are rewritten to positional names ({p1}, // {p2}, ...) so cross-repo contract IDs match regardless of // whether provider and consumer teams picked different names // for the same slot — the well-known `{wid}` vs `{workspaceId}` // mismatch that used to appear as two orphan contracts. {"gin-style param", "/users/:id", "/users/{p1}"}, {"typed angle param", "/users/", "/users/{p1}"}, {"canonical brace param", "/users/{id}", "/users/{p1}"}, {"multiple params rename positionally", "/workspaces/{wid}/tags/{id}", "/workspaces/{p1}/tags/{p2}"}, {"consumer name variant hashes the same", "/workspaces/{workspaceId}/tags/{id}", "/workspaces/{p1}/tags/{p2}"}, {"trailing slash + quotes", `"/api/v1/items/"`, "/api/v1/items"}, {"missing leading slash", "api/users", "/api/users"}, {"root", "/", "/"}, // T1.2: scheme+authority stripping. {"http scheme", "http://api.example.com/v1/users", "/v1/users"}, {"https scheme with port", "https://api.example.com:443/v1/users", "/v1/users"}, {"scheme only", "http://api.example.com", "/"}, // T1.1: JS/TS template-literal base-URL stripping. {"leading tpl placeholder", "${API_URL}/v1/tucks", "/v1/tucks"}, {"leading slash then placeholder", "/${TUCK_API_URL}/v1/tucks", "/v1/tucks"}, {"dotted placeholder", "${process.env.API_URL}/v1/users", "/v1/users"}, {"inline placeholder becomes param", "/v1/users/${id}", "/v1/users/{p1}"}, {"base + inline param", "${BASE}/v1/users/${id}/tags", "/v1/users/{p1}/tags"}, {"tpl inside host", "https://${HOST}/v1/users", "/v1/users"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := NormalizeHTTPPath(tt.input) if got != tt.expected { t.Errorf("NormalizeHTTPPath(%q) = %q, want %q", tt.input, got, tt.expected) } }) } } // TestHTTPExtractor_Go_StdlibMux_NoLegacyDuplicate guards against the // regression where both the Go 1.22+ pattern and the legacy HandleFunc // pattern matched the same route, producing duplicate contracts // (http::POST::/v1/tucks AND http::ANY::/POST /v1/tucks for a single // mux.HandleFunc("POST /v1/tucks", h)). The legacy pattern's path // capture now requires a leading "/", which the 1.22+ form never has // (it starts with the verb), so only the specific extractor fires. func TestHTTPExtractor_Go_StdlibMux_NoLegacyDuplicate(t *testing.T) { src := []byte(`package main import "net/http" func wire(mux *http.ServeMux, h *Handler) { mux.HandleFunc("POST /v1/tucks", h.CreateTuck) } `) nodes := makeNodes("main.go", []struct { name string start, end int }{ {"wire", 5, 7}, {"CreateTuck", 9, 9}, }) ext := &HTTPExtractor{} contracts := ext.Extract("main.go", src, nodes, nil) seen := map[string]int{} for _, c := range contracts { seen[c.ID]++ } if seen["http::ANY::/POST /v1/tucks"] > 0 { t.Errorf("legacy HandleFunc pattern double-matched the 1.22 form: %v", seen) } if seen["http::POST::/v1/tucks"] != 1 { t.Errorf("expected exactly 1 http::POST::/v1/tucks contract, got %d (all: %v)", seen["http::POST::/v1/tucks"], seen) } } // TestHTTPExtractor_Go_HandlerThroughMiddleware guards against the // regression where routes registered as // // mux.HandleFunc("POST /v1/tucks", WithAuth(auth, h.CreateTuck)) // // landed with SymbolID = "WithAuth" (the middleware wrapper) or the // enclosing RegisterRoutes function, rather than the actual handler // h.CreateTuck. The extractor now walks the balanced-paren tail of // the call expression, picks the innermost identifier that resolves // to a function/method in the same file — correctly landing on // CreateTuck. func TestHTTPExtractor_Go_HandlerThroughMiddleware(t *testing.T) { src := []byte(`package main import "net/http" func wire(mux *http.ServeMux, h *Handler) { mux.HandleFunc("POST /v1/tucks", WithAuth(auth, h.CreateTuck)) mux.HandleFunc("GET /v1/tucks/{id}", WithAuth(auth, h.GetTuck)) mux.HandleFunc("DELETE /v1/tucks/{id}", WithAuth(auth, WithAudit(audit, h.DeleteTuck))) } `) nodes := makeNodes("main.go", []struct { name string start, end int }{ {"wire", 5, 9}, {"CreateTuck", 12, 12}, {"GetTuck", 14, 14}, {"DeleteTuck", 16, 16}, }) ext := &HTTPExtractor{} contracts := ext.Extract("main.go", src, nodes, nil) bySymbol := map[string]string{} for _, c := range contracts { if c.Role != RoleProvider { continue } bySymbol[c.ID] = c.SymbolID } want := map[string]string{ "http::POST::/v1/tucks": "main.go::CreateTuck", "http::GET::/v1/tucks/{p1}": "main.go::GetTuck", "http::DELETE::/v1/tucks/{p1}": "main.go::DeleteTuck", } for id, wantSym := range want { gotSym, ok := bySymbol[id] if !ok { t.Errorf("missing provider contract %s", id) continue } if gotSym != wantSym { t.Errorf("%s: SymbolID want %s, got %s (dropped to enclosing or wrapper name)", id, wantSym, gotSym) } } } // TestHTTPExtractor_Dart_Consumers covers T2.1: Dart HTTP client patterns // (dio and package:http) now produce consumer contracts. Exercised via // short snippets resembling the shape of tuck_app's TuckApiClient // methods, including Dart's bare-$id interpolation style that // NormalizeHTTPPath collapses to a positional {p1}. // TestHTTPExtractor_Go_StdlibMux_1_22 covers the Go 1.22+ stdlib mux // pattern where the HTTP method is embedded in the pattern string as // "METHOD /path". Without splitting it, the contract ID ended up as // "http::ANY::/DELETE /v1/..." which never pairs with a consumer // "http::DELETE::/v1/...". core-api uses this pattern on 40+ routes; // this is the regression that kept them all orphan. func TestHTTPExtractor_Go_StdlibMux_1_22(t *testing.T) { src := []byte(`package main import "net/http" func wire(mux *http.ServeMux, h *Handler) { mux.HandleFunc("GET /v1/tucks", h.ListTucks) mux.HandleFunc("POST /v1/tucks", h.CreateTuck) mux.HandleFunc("DELETE /v1/tucks/{id}", h.DeleteTuck) mux.HandleFunc("PATCH /v1/tucks/{id}/progress", h.UpdateProgress) } `) nodes := makeNodes("main.go", []struct { name string start, end int }{ {"wire", 5, 10}, }) ext := &HTTPExtractor{} contracts := ext.Extract("main.go", src, nodes, nil) byID := make(map[string]Contract) for _, c := range contracts { byID[c.ID] = c } want := []struct { id, method, path string }{ {"http::GET::/v1/tucks", "GET", "/v1/tucks"}, {"http::POST::/v1/tucks", "POST", "/v1/tucks"}, {"http::DELETE::/v1/tucks/{p1}", "DELETE", "/v1/tucks/{p1}"}, {"http::PATCH::/v1/tucks/{p1}/progress", "PATCH", "/v1/tucks/{p1}/progress"}, } for _, w := range want { c, ok := byID[w.id] if !ok { t.Errorf("missing contract %s; have: %v", w.id, keysOf(byID)) continue } if c.Role != RoleProvider { t.Errorf("%s: role want provider, got %s", w.id, c.Role) } if c.Meta["method"] != w.method { t.Errorf("%s: method want %s, got %v", w.id, w.method, c.Meta["method"]) } if c.Meta["path"] != w.path { t.Errorf("%s: path want %s, got %v", w.id, w.path, c.Meta["path"]) } } } // TestHTTPExtractor_Go_StdlibMux_Subtree pins the trailing-slash // rewrite. `mux.HandleFunc("POST /v1/tools/", h)` is Go's net/http // subtree-match form — every POST under /v1/tools/ hits this handler. // The contract ID must encode that as a parametric tail so it pairs // with consumers calling per-route paths like /v1/tools/{name} from a // sibling repo in the same workspace. func TestHTTPExtractor_Go_StdlibMux_Subtree(t *testing.T) { src := []byte(`package main import "net/http" func wire(mux *http.ServeMux, h *Handler) { mux.HandleFunc("POST /v1/tools/", h.handleToolCall) mux.HandleFunc("GET /v1/static/", h.handleStatic) mux.HandleFunc("GET /v1/health", h.handleHealth) mux.Handle("/", root) } `) nodes := makeNodes("main.go", []struct { name string start, end int }{ {"wire", 5, 10}, }) ext := &HTTPExtractor{} contracts := ext.Extract("main.go", src, nodes, nil) byID := make(map[string]Contract) for _, c := range contracts { byID[c.ID] = c } // Subtree handlers get a parametric tail and a meta.subtree marker. for _, id := range []string{"http::POST::/v1/tools/{p1}", "http::GET::/v1/static/{p1}"} { c, ok := byID[id] if !ok { t.Errorf("missing subtree contract %s; have: %v", id, keysOf(byID)) continue } if c.Meta["subtree"] != true { t.Errorf("%s: meta.subtree want true, got %v", id, c.Meta["subtree"]) } } // Literal-path handlers stay literal — no parametric tail, no // subtree marker. if c, ok := byID["http::GET::/v1/health"]; !ok { t.Errorf("literal contract /v1/health was rewritten; have: %v", keysOf(byID)) } else if c.Meta["subtree"] == true { t.Errorf("/v1/health: meta.subtree should be unset, got true") } // Root-only Handle("/", root) must NOT become "/{p1}" — that's // not a subtree, it's the catchall route. if _, ok := byID["http::ANY::/{p1}"]; ok { t.Errorf("root path / was wrongly rewritten as subtree; have: %v", keysOf(byID)) } } // TestHTTPExtractor_Go_Subtree_PairsWithParametricConsumer is the // end-to-end pin for the cross-repo bridge that motivated the // trailing-slash rewrite. A `gortex` provider declares // `mux.HandleFunc("POST /v1/tools/", h)` (subtree) and a `web` // consumer in the same workspace calls `fetch('/v1/tools/${name}')` // (parametric). Before the fix the IDs were `/v1/tools` vs // `/v1/tools/{p1}` and the matcher kept both as orphans. After the // fix they share `/v1/tools/{p1}` and pair as a CrossRepo link. func TestHTTPExtractor_Go_Subtree_PairsWithParametricConsumer(t *testing.T) { providerSrc := []byte(`package main import "net/http" func wire(mux *http.ServeMux, h *Handler) { mux.HandleFunc("POST /v1/tools/", h.handleToolCall) } `) providerNodes := makeNodes("server.go", []struct { name string start, end int }{ {"wire", 5, 7}, }) consumerSrc := []byte(`async function callTool(name) { return fetch(` + "`/v1/tools/${name}`" + `, { method: 'POST' }); } `) consumerNodes := makeNodes("api.ts", []struct { name string start, end int }{ {"callTool", 1, 3}, }) ext := &HTTPExtractor{} provContracts := ext.Extract("server.go", providerSrc, providerNodes, nil) consContracts := ext.Extract("api.ts", consumerSrc, consumerNodes, nil) reg := NewRegistry() for _, c := range provContracts { c.RepoPrefix = "gortex" c.WorkspaceID = "gortex" c.ProjectID = "gortex" reg.Add(c) } for _, c := range consContracts { c.RepoPrefix = "web" c.WorkspaceID = "gortex" c.ProjectID = "gortex" reg.Add(c) } result := Match(reg) var paired *CrossLink for i, m := range result.Matched { if m.ContractID == "http::POST::/v1/tools/{p1}" { paired = &result.Matched[i] break } } if paired == nil { t.Fatalf("expected POST /v1/tools/{p1} pair; matches=%d, orphan_providers=%d, orphan_consumers=%d", len(result.Matched), len(result.OrphanProviders), len(result.OrphanConsumers)) } if !paired.CrossRepo { t.Errorf("expected CrossRepo=true (provider in gortex, consumer in web)") } if paired.Provider.RepoPrefix != "gortex" || paired.Consumer.RepoPrefix != "web" { t.Errorf("wrong repo wiring: provider=%s consumer=%s", paired.Provider.RepoPrefix, paired.Consumer.RepoPrefix) } } // keysOf returns the keys of a map of Contracts for failure diagnostics. func keysOf(m map[string]Contract) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } return out } func TestHTTPExtractor_Dart_Consumers(t *testing.T) { src := []byte(`class TuckApiClient { final Dio _dio; TuckApiClient(this._dio); Future createTuck(Map data) async { await _dio.post('/v1/tucks', data: data); } Future deleteTuck(String id) async { await _dio.delete('/v1/tucks/$id'); } Future listHealth() async { final r = await http.get(Uri.parse('/v1/health')); return r.body; } } `) nodes := makeNodes("client.dart", []struct { name string start, end int }{ {"createTuck", 5, 7}, {"deleteTuck", 9, 11}, {"listHealth", 13, 16}, }) ext := &HTTPExtractor{} contracts := ext.Extract("client.dart", src, nodes, nil) want := map[string]string{ "http::POST::/v1/tucks": "client.dart::createTuck", "http::DELETE::/v1/tucks/{p1}": "client.dart::deleteTuck", "http::GET::/v1/health": "client.dart::listHealth", } got := map[string]string{} for _, c := range contracts { if c.Role != RoleConsumer { t.Errorf("expected role consumer for %s, got %s", c.ID, c.Role) } got[c.ID] = c.SymbolID } for id, sym := range want { if got[id] != sym { t.Errorf("missing/mismatched consumer contract:\n want %s → %s\n got %s → %s", id, sym, id, got[id]) } } } func TestHTTPExtractor_SupportedLanguages(t *testing.T) { ext := &HTTPExtractor{} langs := ext.SupportedLanguages() // Spot-check the specific languages rather than the count so adding // a new language's patterns (T2.1 added dart) doesn't force an // unrelated test update. want := []string{"go", "typescript", "javascript", "python", "java", "dart"} set := make(map[string]bool, len(langs)) for _, l := range langs { set[l] = true } for _, w := range want { if !set[w] { t.Errorf("SupportedLanguages missing %q; got %v", w, langs) } } } func TestHTTPExtractor_NoSymbol(t *testing.T) { // When there are no enclosing functions, SymbolID should be empty. src := []byte(`r.GET("/api/test", handler)`) contracts := (&HTTPExtractor{}).Extract("main.go", src, nil, nil) if len(contracts) != 1 { t.Fatalf("expected 1 contract, got %d", len(contracts)) } if contracts[0].SymbolID != "" { t.Errorf("expected empty symbol ID, got %s", contracts[0].SymbolID) } } // Regression: the fiber pattern's `.VERB(` anchor used to match verb names // embedded in string literals — e.g. the prefilter marker `[]byte(".GET(")` // — and the `[^"`+"`"+`]+` path capture would overshoot the closing quote // and chew source code until the next quote on the next line, emitting // contracts whose "path" was a chunk of source. Requiring the captured path // to start with `/` rejects these false positives. func TestHTTPExtractor_Go_Fiber_NoMatchOnVerbInsideStringLiteral(t *testing.T) { src := []byte("package contracts\n" + "\n" + "var markers = [][]byte{\n" + "\t[]byte(\".GET(\"),\n" + "\t[]byte(\".POST(\"),\n" + "\t[]byte(\".PUT(\"),\n" + "\t[]byte(\".DELETE(\"),\n" + "\t[]byte(\".PATCH(\"),\n" + "}\n") contracts := (&HTTPExtractor{}).Extract("markers.go", src, nil, nil) for _, c := range contracts { if c.Meta["framework"] == "fiber" { t.Errorf("fiber pattern matched a verb literal inside a string: id=%q path=%q", c.ID, c.Meta["path"]) } } } // TestHTTPExtractor_PathParamNames asserts that the developer-written // parameter names are preserved on Meta["path_param_names"] alongside // the positional Meta["path_params"]. The contract ID stays positional // (so cross-repo matching is naming-agnostic) but the source-side // names are surfaced for display, OpenAPI export, and drift detection. func TestHTTPExtractor_PathParamNames(t *testing.T) { cases := []struct { name string src string filePath string wantPath string wantNames []string }{ { name: "go net/http stdlib mux", src: `mux.HandleFunc("DELETE /v1/overlay/sessions/{id}", h)`, filePath: "main.go", wantPath: "/v1/overlay/sessions/{p1}", wantNames: []string{"id"}, }, { name: "go gin colon style multi-param", src: `r.GET("/users/:userID/posts/:postID", h)`, filePath: "main.go", wantPath: "/users/{p1}/posts/{p2}", wantNames: []string{"userID", "postID"}, }, { name: "express colon style", src: `app.get('/api/products/:id', h)`, filePath: "routes.ts", wantPath: "/api/products/{p1}", wantNames: []string{"id"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { out := (&HTTPExtractor{}).Extract(tc.filePath, []byte(tc.src), nil, nil) if len(out) == 0 { t.Fatalf("no contracts extracted from %q", tc.src) } c := out[0] if got, _ := c.Meta["path"].(string); got != tc.wantPath { t.Errorf("path = %q, want %q", got, tc.wantPath) } gotNames, _ := c.Meta["path_param_names"].([]string) if len(gotNames) != len(tc.wantNames) { t.Fatalf("path_param_names = %v, want %v", gotNames, tc.wantNames) } for i := range tc.wantNames { if gotNames[i] != tc.wantNames[i] { t.Errorf("path_param_names[%d] = %q, want %q", i, gotNames[i], tc.wantNames[i]) } } }) } } // And the positive case: a real fiber registration still produces a contract. func TestHTTPExtractor_Go_Fiber_RealRoute(t *testing.T) { src := []byte("package main\n" + "\n" + "import \"github.com/gofiber/fiber/v2\"\n" + "\n" + "func register(app *fiber.App) {\n" + "\tapp.GET(\"/v1/users\", listUsers)\n" + "\tapp.DELETE(\"/v1/users/:id\", deleteUser)\n" + "}\n") contracts := (&HTTPExtractor{}).Extract("main.go", src, nil, nil) byID := map[string]Contract{} for _, c := range contracts { byID[c.ID] = c } if _, ok := byID["http::GET::/v1/users"]; !ok { t.Errorf("expected http::GET::/v1/users; got %v", byID) } if _, ok := byID["http::DELETE::/v1/users/{p1}"]; !ok { t.Errorf("expected http::DELETE::/v1/users/{p1}; got %v", byID) } } func TestHTTPExtractor_Swift_Vapor(t *testing.T) { src := []byte(` import Vapor func routes(_ app: Application) throws { app.get("users") { req in return "users" } app.post("users", use: createUser) routes.delete("users", ":id") { req in return "deleted" } } `) nodes := makeNodes("routes.swift", []struct { name string start, end int }{ {"routes", 4, 12}, }) ext := &HTTPExtractor{} contracts := ext.Extract("routes.swift", src, nodes, nil) if len(contracts) != 3 { t.Fatalf("expected 3 Vapor routes, got %d: %+v", len(contracts), contracts) } methods := map[string]bool{} for _, c := range contracts { if c.Meta["framework"] != "vapor" { t.Errorf("framework = %v, want vapor", c.Meta["framework"]) } methods[c.Meta["method"].(string)] = true } for _, m := range []string{"GET", "POST", "DELETE"} { if !methods[m] { t.Errorf("expected a %s route, got methods %v", m, methods) } } } func TestHTTPExtractor_Swift_Alamofire(t *testing.T) { src := []byte(` import Alamofire func fetchUsers() { AF.request("https://api.example.com/users", method: .get) session.request("https://api.example.com/users", method: .post) } `) nodes := makeNodes("client.swift", []struct { name string start, end int }{ {"fetchUsers", 4, 7}, }) ext := &HTTPExtractor{} contracts := ext.Extract("client.swift", src, nodes, nil) if len(contracts) != 2 { t.Fatalf("expected 2 Alamofire consumers, got %d: %+v", len(contracts), contracts) } for _, c := range contracts { if c.Meta["framework"] != "alamofire" { t.Errorf("framework = %v, want alamofire", c.Meta["framework"]) } if c.Role != RoleConsumer { t.Errorf("role = %v, want consumer", c.Role) } } } func TestHTTPExtractor_CSharp_RoutePrefixJoin(t *testing.T) { src := []byte(`using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/[controller]")] public class UsersController : ControllerBase { [HttpGet("{id}")] public IActionResult GetById(int id) { return Ok(); } [Route("search")] public IActionResult Search() { return Ok(); } } `) nodes := []*graph.Node{ {ID: "Users.cs::UsersController", Name: "UsersController", Kind: graph.KindType, FilePath: "Users.cs", StartLine: 5, EndLine: 11}, {ID: "Users.cs::UsersController.GetById", Name: "GetById", Kind: graph.KindMethod, FilePath: "Users.cs", StartLine: 6, EndLine: 7}, {ID: "Users.cs::UsersController.Search", Name: "Search", Kind: graph.KindMethod, FilePath: "Users.cs", StartLine: 9, EndLine: 10}, } cs := (&HTTPExtractor{}).Extract("Users.cs", src, nodes, nil) byID := map[string]Contract{} var ids []string for _, c := range cs { byID[c.ID] = c ids = append(ids, c.ID) } get, ok := byID["http::GET::/api/users/{p1}"] if !ok { t.Fatalf("expected prefix-joined GET route http::GET::/api/users/{p1}, got %v", ids) } if get.SymbolID != "Users.cs::UsersController.GetById" { t.Errorf("GET route should bind GetById, got %q", get.SymbolID) } search, ok := byID["http::ANY::/api/users/search"] if !ok { t.Fatalf("expected verb-less route http::ANY::/api/users/search, got %v", ids) } if search.SymbolID != "Users.cs::UsersController.Search" { t.Errorf("verb-less route should bind Search, got %q", search.SymbolID) } }