From 87e2cc26d893a89c7f72ac35744634bb0e2dcb7a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:28:49 +0800 Subject: [PATCH] fix(environment): align comparison collection identity --- docs/web-network-captures.md | 3 +- .../environment/environmentCompare.test.ts | 63 +++++++++++++++++++ .../environment/environmentCompareMatch.ts | 6 +- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 20592f1fdc..c621f877de 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -625,7 +625,8 @@ refused for them, as the manifest is not. Each `GET` tool whose template ends in a parameter is an item tool, and its type's `collection_template` is the template without that parameter; any other -`GET` tool is a list tool of its own template. Empty segments are ignored, so a +`GET` tool is a list tool of its own template. Interior empty segments remain +meaningful: `/api/books` and `/api//books` name distinct collections. A single trailing slash names the same collection. An item tool proves its type's `id_field` when every initial response carries the requested path value in the same field of one entity object (`id_basis: "path-parameter"`). The object is diff --git a/src/application/environment/environmentCompare.test.ts b/src/application/environment/environmentCompare.test.ts index 677b240408..c69917b786 100644 --- a/src/application/environment/environmentCompare.test.ts +++ b/src/application/environment/environmentCompare.test.ts @@ -130,6 +130,26 @@ describe("matchProbeRequest", () => { expect(item?.args).toEqual({ id: "%31" }); }); + it("keeps trailing slashes significant when matching HTTP route templates", () => { + const manifest = { + ...source.manifest, + tools: source.manifest.tools.map((tool) => ({ + ...tool, + path_template: `${tool.path_template}/`, + })), + }; + const request = { + method: "GET", + path: "/api/books/7/", + query: null, + body: undefined, + }; + expect(matchProbeRequest(manifest, request)?.args).toEqual({ id: "7" }); + expect( + matchProbeRequest(manifest, { ...request, path: "/api/books/7" }), + ).toBeUndefined(); + }); + it("keeps every occurrence of a repeated query value", () => { const item = matchProbeRequest(environmentServeSource().manifest, { method: "GET", @@ -176,6 +196,31 @@ describe("collections under different parents", () => { expect(map.env(110, "/api/orgs/2/users")).toBe(20); }); + it("uses state collection identity for root and trailing-slash routes", () => { + const trailing = { + ...nestedUsers, + path_template: `${nestedUsers.path_template}/`, + }; + const segments = ["api", "orgs", "%32", "users", "101", ""]; + expect(collectionScope(trailing, segments)).toBe("/api/orgs/2/users"); + expect(parameterScopes(trailing, segments)).toEqual( + parameterScopes(nestedUsers, segments), + ); + expect( + collectionScope({ ...nestedUsers, path_template: "/", params: [] }), + ).toBe("/"); + expect( + collectionScope({ + ...nestedUsers, + path_template: "/api//books/", + params: [], + }), + ).toBe("/api//books"); + expect( + collectionScope(trailing, ["api", "orgs", "a%2Fb", "users", "101", ""]), + ).toBe("/api/orgs/a%2Fb/users"); + }); + it("shares a child map when the same parent is spelled with one encoding", () => { const encoded = ["api", "orgs", "%32", "users", "101"]; const plain = ["api", "orgs", "2", "users", "101"]; @@ -465,6 +510,24 @@ const compareRows = ( return report.value.probes[0]!; }; +it("learns ids from trailing-slash lists in the collection their item calls name", () => { + const item = (id: number, name: string) => ({ id, name }); + const probe = compareRows( + [ + ["GET", "/api/books", [item(1, "Book")]], + ["GET", "/api/books/1", item(1, "Book")], + ["GET", "/api/authors/", [item(2, "Ann")]], + ["GET", "/api/authors/2/", item(2, "Ann")], + ], + [ + ["GET", "/api/books", [item(101, "Book")]], + ["GET", "/api/authors/", [item(101, "Ann")]], + ["GET", "/api/authors/101/", item(101, "Ann")], + ], + ); + expect(probe.results.map((step) => step.equal)).toEqual([true, true, true]); +}); + describe("ids the comparison used to misread", () => { it.each([ ["", "", true], diff --git a/src/application/environment/environmentCompareMatch.ts b/src/application/environment/environmentCompareMatch.ts index ec7e3ab5e6..ce19b3dbae 100644 --- a/src/application/environment/environmentCompareMatch.ts +++ b/src/application/environment/environmentCompareMatch.ts @@ -59,9 +59,7 @@ const collectionAt = ( segments?: readonly string[], ): string => { const params = new Set(tool.params.map((param) => param.index)); - return `/${tool.path_template - .slice(1) - .split("/") + return `/${segmentsOf(tool.path_template) .slice(0, end) .map((segment, index) => { if (!params.has(index)) return segment; @@ -78,7 +76,7 @@ export const collectionScope = ( tool: EnvironmentManifestTool, segments?: readonly string[], ): string => { - const length = tool.path_template.slice(1).split("/").length; + const length = segmentsOf(tool.path_template).length; return collectionAt( tool, tool.params.some((param) => param.index === length - 1)