@skyramp/mcp 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,306 +1,529 @@
1
- import { draftScenariosFromEndpoints, draftMinimalScenarios, } from "./scenarioDrafting.js";
2
- // ---------------------------------------------------------------------------
3
- // draftMinimalScenarios
4
- // ---------------------------------------------------------------------------
5
- describe("draftMinimalScenarios", () => {
1
+ // @ts-ignore
2
+ import { isRealResource, draftScenariosFromEndpoints, draftDiffDirectScenarios, inferResourceRelationships, } from "./scenarioDrafting.js";
3
+ describe("isRealResource", () => {
4
+ it("accepts normal resource names", () => {
5
+ expect(isRealResource("users")).toBe(true);
6
+ expect(isRealResource("products")).toBe(true);
7
+ expect(isRealResource("orders")).toBe(true);
8
+ });
9
+ it("accepts hyphenated resource names (not action verbs)", () => {
10
+ expect(isRealResource("saved-searches")).toBe(true);
11
+ expect(isRealResource("order-items")).toBe(true);
12
+ expect(isRealResource("user-profiles")).toBe(true);
13
+ expect(isRealResource("api-keys")).toBe(true);
14
+ });
15
+ it("rejects action-like single words", () => {
16
+ expect(isRealResource("login")).toBe(false);
17
+ expect(isRealResource("logout")).toBe(false);
18
+ expect(isRealResource("verify")).toBe(false);
19
+ expect(isRealResource("health")).toBe(false);
20
+ expect(isRealResource("ping")).toBe(false);
21
+ expect(isRealResource("dashboard")).toBe(false);
22
+ expect(isRealResource("webhook")).toBe(false);
23
+ });
24
+ it("rejects action-verb-hyphenated patterns", () => {
25
+ expect(isRealResource("forgot-password")).toBe(false);
26
+ expect(isRealResource("reset-password")).toBe(false);
27
+ expect(isRealResource("verify-email")).toBe(false);
28
+ expect(isRealResource("confirm-order")).toBe(false);
29
+ expect(isRealResource("send-notification")).toBe(false);
30
+ expect(isRealResource("create-session")).toBe(false);
31
+ expect(isRealResource("delete-cache")).toBe(false);
32
+ });
33
+ it("is case-insensitive", () => {
34
+ expect(isRealResource("Login")).toBe(false);
35
+ expect(isRealResource("VERIFY")).toBe(false);
36
+ expect(isRealResource("Forgot-Password")).toBe(false);
37
+ });
38
+ });
39
+ describe("draftScenariosFromEndpoints — irregular plural singularization", () => {
40
+ it("uses 'category' (not 'categorie') in FK field names for -ies plurals", () => {
41
+ const endpoints = [
42
+ { path: "/api/categories", methods: [{ method: "GET" }, { method: "POST" }] },
43
+ { path: "/api/categories/{id}", methods: [{ method: "GET" }, { method: "PUT" }, { method: "DELETE" }] },
44
+ { path: "/api/items", methods: [{ method: "GET" }, { method: "POST" }] },
45
+ { path: "/api/items/{id}", methods: [{ method: "GET" }, { method: "DELETE" }] },
46
+ ];
47
+ const scenarios = draftScenariosFromEndpoints(endpoints, [{ method: "POST", path: "/api/categories" }]);
48
+ const allText = JSON.stringify(scenarios);
49
+ expect(allText).not.toContain("categorie_id");
50
+ expect(allText).not.toContain("\"categorie\"");
51
+ // The diff-direct integration scenario description should use "category" (singular)
52
+ const integrationScenario = scenarios.find((s) => s.scenarioName === "categories-post-lifecycle");
53
+ expect(integrationScenario).toBeDefined();
54
+ expect(integrationScenario.steps[0].description).toContain("category");
55
+ });
56
+ it("uses 'status' (not 'statu') in FK field names for -ses plurals", () => {
57
+ const endpoints = [
58
+ { path: "/api/statuses", methods: [{ method: "GET" }, { method: "POST" }] },
59
+ { path: "/api/statuses/{id}", methods: [{ method: "GET" }, { method: "DELETE" }] },
60
+ ];
61
+ const scenarios = draftScenariosFromEndpoints(endpoints, [{ method: "POST", path: "/api/statuses" }]);
62
+ const allText = JSON.stringify(scenarios);
63
+ expect(allText).not.toContain("statu_id");
64
+ // The diff-direct integration scenario description should use "status" (singular)
65
+ const integrationScenario = scenarios.find((s) => s.scenarioName === "statuses-post-lifecycle");
66
+ expect(integrationScenario).toBeDefined();
67
+ expect(integrationScenario.steps[0].description).toContain("status");
68
+ });
69
+ });
70
+ describe("draftScenariosFromEndpoints", () => {
71
+ it("returns empty array for no endpoints", () => {
72
+ expect(draftScenariosFromEndpoints([])).toEqual([]);
73
+ });
74
+ });
75
+ describe("inferResourceRelationships", () => {
76
+ it("returns empty map for endpoints with no nesting or FK fields", () => {
77
+ const endpoints = [
78
+ { path: "/api/users", methods: [{ method: "GET" }] },
79
+ { path: "/api/products", methods: [{ method: "GET" }] },
80
+ ];
81
+ expect(inferResourceRelationships(endpoints).size).toBe(0);
82
+ });
83
+ it("detects parent→child relationship from path nesting (/parent/{id}/child)", () => {
84
+ const endpoints = [
85
+ { path: "/api/collections/{id}/links", methods: [{ method: "GET" }] },
86
+ ];
87
+ const rel = inferResourceRelationships(endpoints);
88
+ expect(rel.has("links")).toBe(true);
89
+ expect(rel.get("links").has("collections")).toBe(true);
90
+ });
91
+ it("detects FK relationship from request body field ({resource}_id)", () => {
92
+ const endpoints = [
93
+ { path: "/api/orders", methods: [{ method: "POST", interactions: [{ request: { body: { product_id: "abc" } } }] }] },
94
+ { path: "/api/products", methods: [{ method: "GET" }] },
95
+ ];
96
+ const rel = inferResourceRelationships(endpoints);
97
+ expect(rel.has("orders")).toBe(true);
98
+ expect(rel.get("orders").has("products")).toBe(true);
99
+ });
100
+ it("does not create self-reference", () => {
101
+ const endpoints = [
102
+ { path: "/api/orders", methods: [{ method: "POST", interactions: [{ request: { body: { order_id: "abc" } } }] }] },
103
+ ];
104
+ const rel = inferResourceRelationships(endpoints);
105
+ // orders should not reference itself
106
+ expect(rel.get("orders")?.has("orders")).toBeFalsy();
107
+ });
108
+ it("ignores FK fields that point to unknown resources", () => {
109
+ const endpoints = [
110
+ { path: "/api/orders", methods: [{ method: "POST", interactions: [{ request: { body: { nonexistent_resource_id: "abc" } } }] }] },
111
+ ];
112
+ const rel = inferResourceRelationships(endpoints);
113
+ // nonexistent_resource is not a known endpoint resource, should be ignored
114
+ expect(rel.get("orders")?.size ?? 0).toBe(0);
115
+ });
116
+ });
117
+ describe("draftDiffDirectScenarios", () => {
6
118
  const makeGroups = (defs) => new Map(Object.entries(defs).map(([k, v]) => [k, { ...v, methods: new Set(v.methods) }]));
7
- it("returns empty array when endpoints is empty", () => {
119
+ it("returns empty array when newEndpoints is empty", () => {
8
120
  const groups = makeGroups({ orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{id}" } });
9
- expect(draftMinimalScenarios([], groups, "new_endpoint")).toEqual([]);
10
- });
11
- it("GET endpoint → 1 contract scenario only (no integration)", () => {
12
- const groups = makeGroups({
13
- orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{id}" },
14
- });
15
- const result = draftMinimalScenarios([{ method: "GET", path: "/api/orders/{id}" }], groups, "new_endpoint");
16
- expect(result).toHaveLength(1);
17
- expect(result[0].testType).toBe("contract");
18
- expect(result[0].scenarioName).toBe("orders-by-id-get-new-contract");
19
- expect(result[0].steps).toHaveLength(1);
20
- expect(result[0].steps[0].expectedStatusCode).toBe(200);
21
- });
22
- it("POST endpoint → 1 contract + 1 integration", () => {
23
- const groups = makeGroups({
24
- products: { basePath: "/api/products", methods: ["GET", "POST"], paramPath: "/api/products/{id}" },
25
- });
26
- const result = draftMinimalScenarios([{ method: "POST", path: "/api/products" }], groups, "new_endpoint");
27
- expect(result).toHaveLength(2);
28
- const contract = result.find(s => s.testType === "contract");
29
- const integration = result.find(s => s.testType === "integration");
30
- expect(contract).toBeDefined();
31
- expect(integration).toBeDefined();
32
- expect(contract.scenarioName).toBe("products-post-new-contract");
33
- expect(integration.scenarioName).toBe("products-post-new-integration");
121
+ expect(draftDiffDirectScenarios([], groups)).toEqual([]);
34
122
  });
35
- it("PUT endpoint 1 contract + 1 integration", () => {
123
+ it("generates new_endpoint-category scenarios for a new PUT endpoint", () => {
36
124
  const groups = makeGroups({
37
125
  orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT"], paramPath: "/api/orders/{order_id}" },
38
126
  });
39
- const result = draftMinimalScenarios([{ method: "PUT", path: "/api/orders/{order_id}" }], groups, "new_endpoint");
40
- expect(result).toHaveLength(2);
41
- expect(result.find(s => s.testType === "contract")).toBeDefined();
42
- expect(result.find(s => s.testType === "integration")).toBeDefined();
43
- // Both use the resolved path
44
- expect(result[0].steps[0].path).toBe("/api/orders/{order_id}");
45
- });
46
- it("DELETE endpoint 1 contract + 1 integration", () => {
47
- const groups = makeGroups({
48
- orders: { basePath: "/api/orders", methods: ["GET", "POST", "DELETE"], paramPath: "/api/orders/{id}" },
49
- });
50
- const result = draftMinimalScenarios([{ method: "DELETE", path: "/api/orders/{id}" }], groups, "new_endpoint");
51
- expect(result).toHaveLength(2);
52
- const contract = result.find(s => s.testType === "contract");
53
- const integration = result.find(s => s.testType === "integration");
127
+ const scenarios = draftDiffDirectScenarios([{ method: "PUT", path: "/api/orders/{order_id}" }], groups);
128
+ // PUT generates: mutation-recalc, contract, not-found (3 scenarios)
129
+ // Note: we intentionally skip the redundant "happy-path" integration for PUT/PATCH
130
+ // because mutation-recalc already provides comprehensive happy-path coverage
131
+ expect(scenarios.length).toBeGreaterThanOrEqual(3);
132
+ const newEndpointScenarios = scenarios.filter(s => s.category === "new_endpoint");
133
+ expect(newEndpointScenarios.length).toBeGreaterThanOrEqual(2);
134
+ // Mutation-recalculation scenario is drafted first for PUT/PATCH
135
+ const mutationRecalc = scenarios.find(s => s.testType === "integration" && s.scenarioName.includes("mutation-verify"));
136
+ expect(mutationRecalc).toBeDefined();
137
+ expect(mutationRecalc.steps.some(st => st.method === "PUT")).toBe(true);
138
+ expect(mutationRecalc.description).toContain("Mutation test");
139
+ // Note: happy-path integration removed for PUT/PATCH - mutation-recalc covers this
140
+ // Contract test
141
+ const contract = scenarios.find(s => s.testType === "contract" && s.steps[0].expectedStatusCode === 200);
54
142
  expect(contract).toBeDefined();
55
- expect(integration).toBeDefined();
143
+ // Not-found edge case
144
+ const notFound = scenarios.find(s => s.steps.some(st => st.expectedStatusCode === 404));
145
+ expect(notFound).toBeDefined();
56
146
  });
57
- it("each scenario has exactly 1 step", () => {
147
+ it("generates mutation-recalc scenario for a new PATCH endpoint", () => {
58
148
  const groups = makeGroups({
59
- orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT", "DELETE"], paramPath: "/api/orders/{id}" },
149
+ orders: { basePath: "/api/orders", methods: ["GET", "POST", "PATCH"], paramPath: "/api/orders/{order_id}" },
60
150
  });
61
- const result = draftMinimalScenarios([
62
- { method: "GET", path: "/api/orders/{id}" },
63
- { method: "POST", path: "/api/orders" },
64
- { method: "PUT", path: "/api/orders/{id}" },
65
- { method: "DELETE", path: "/api/orders/{id}" },
66
- ], groups, "new_endpoint");
67
- for (const s of result) {
68
- expect(s.steps).toHaveLength(1);
69
- }
151
+ const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/api/orders/{order_id}" }], groups);
152
+ // Mutation-recalculation scenario is drafted for PATCH
153
+ const mutationRecalc = scenarios.find(s => s.scenarioName === "orders-patch-mutation-verify");
154
+ expect(mutationRecalc).toBeDefined();
155
+ expect(mutationRecalc.testType).toBe("integration");
156
+ expect(mutationRecalc.category).toBe("new_endpoint");
157
+ expect(mutationRecalc.priority).toBe("high");
158
+ expect(mutationRecalc.description).toContain("Mutation test");
159
+ expect(mutationRecalc.description).toContain("derived calculations");
160
+ // Should have 3 steps: POST (create) → PATCH (add items) → GET (verify)
161
+ expect(mutationRecalc.steps).toHaveLength(3);
162
+ expect(mutationRecalc.steps[0].method).toBe("POST");
163
+ expect(mutationRecalc.steps[1].method).toBe("PATCH");
164
+ expect(mutationRecalc.steps[2].method).toBe("GET");
165
+ // Note: happy-path integration removed for PUT/PATCH - mutation-recalc covers this
70
166
  });
71
- it("new_endpoint category scenario names contain 'new'", () => {
167
+ it("integration scenario minimum steps: POST resource then PUT — LLM discovers prereqs from source code", () => {
72
168
  const groups = makeGroups({
73
- orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{id}" },
169
+ orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT"], paramPath: "/api/orders/{order_id}" },
74
170
  });
75
- const result = draftMinimalScenarios([{ method: "POST", path: "/api/orders" }], groups, "new_endpoint");
76
- for (const s of result) {
77
- expect(s.category).toBe("new_endpoint");
78
- expect(s.scenarioName).toContain("-new-");
79
- }
171
+ const scenarios = draftDiffDirectScenarios([{ method: "PUT", path: "/api/orders/{order_id}" }], groups);
172
+ // Mutation-recalculation scenario comes first
173
+ const mutationRecalc = scenarios.find(s => s.testType === "integration" && s.scenarioName.includes("mutation-verify"));
174
+ expect(mutationRecalc).toBeDefined();
175
+ expect(mutationRecalc.steps.some(st => st.method === "POST" && st.path === "/api/orders")).toBe(true);
176
+ expect(mutationRecalc.steps.some(st => st.method === "PUT")).toBe(true);
177
+ // Note: happy-path integration removed for PUT/PATCH
178
+ // mutation-recalc already provides comprehensive happy-path coverage with POST → PUT → GET
179
+ // The minimum steps (POST + PUT) are already verified in the mutationRecalc check above
80
180
  });
81
- it("business_rule category scenario names contain 'changed'", () => {
181
+ it("generates 3 scenarios for a new POST endpoint", () => {
82
182
  const groups = makeGroups({
83
- orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{id}" },
183
+ products: { basePath: "/api/products", methods: ["GET", "POST"], paramPath: "/api/products/{id}" },
84
184
  });
85
- const result = draftMinimalScenarios([{ method: "POST", path: "/api/orders" }], groups, "business_rule");
86
- for (const s of result) {
87
- expect(s.category).toBe("business_rule");
88
- expect(s.scenarioName).toContain("-changed-");
89
- }
185
+ const scenarios = draftDiffDirectScenarios([{ method: "POST", path: "/api/products" }], groups);
186
+ expect(scenarios.length).toBe(3); // integration, contract, validation
187
+ const types = scenarios.map(s => s.testType);
188
+ expect(types).toContain("integration");
189
+ expect(types).toContain("contract");
190
+ // validation error scenario (422)
191
+ expect(scenarios.some(s => s.steps.some(st => st.expectedStatusCode === 422))).toBe(true);
90
192
  });
91
- it("passes through router-relative param paths as-is (LLM resolves from source)", () => {
193
+ it("ignores unknown paths and skips action-verb segments", () => {
92
194
  const groups = makeGroups({
93
- orders: { basePath: "/api/v1/orders", methods: ["GET", "POST", "PATCH"], paramPath: "/api/v1/orders/{order_id}" },
195
+ orders: { basePath: "/api/orders", methods: ["POST", "DELETE"], paramPath: "/api/orders/{id}" },
94
196
  });
95
- const result = draftMinimalScenarios([{ method: "PATCH", path: "/{order_id}" }], groups, "new_endpoint");
96
- expect(result).toHaveLength(2);
97
- // Path is passed through as-is — the LLM resolves full path during source enrichment
98
- expect(result[0].steps[0].path).toBe("/{order_id}");
197
+ const scenarios = draftDiffDirectScenarios([{ method: "POST", path: "/api/health" }], // 'health' is not a real resource
198
+ groups);
199
+ expect(scenarios).toEqual([]);
99
200
  });
100
- it("resolves router-relative base paths (e.g. /orders) against known resourceGroups", () => {
101
- const groups = makeGroups({
102
- orders: { basePath: "/api/v1/orders", methods: ["GET", "POST"], paramPath: "/api/v1/orders/{order_id}" },
103
- });
104
- const result = draftMinimalScenarios([{ method: "POST", path: "/orders" }], groups, "new_endpoint");
105
- expect(result).toHaveLength(2);
106
- expect(result[0].scenarioName).toContain("orders");
201
+ it("draftScenariosFromEndpoints passes newEndpoints through to diff-direct scenarios", () => {
202
+ const endpoints = [
203
+ { path: "/api/orders", methods: ["GET", "POST", "PUT"] },
204
+ { path: "/api/orders/{order_id}", methods: ["GET", "PUT", "DELETE"] },
205
+ ];
206
+ const newEndpoints = [{ method: "PUT", path: "/api/orders/{order_id}" }];
207
+ const scenarios = draftScenariosFromEndpoints(endpoints, newEndpoints);
208
+ const diffDirect = scenarios.filter(s => s.category === "new_endpoint");
209
+ expect(diffDirect.length).toBeGreaterThan(0);
210
+ expect(diffDirect.some(s => s.steps.some(st => st.method === "PUT"))).toBe(true);
107
211
  });
108
- it("generates scenarios for any endpoint path (LLM validates relevance from source)", () => {
212
+ it("resolves router-relative param paths (e.g. /{order_id}) against known resourceGroups", () => {
109
213
  const groups = makeGroups({
214
+ orders: { basePath: "/api/v1/orders", methods: ["GET", "POST", "PATCH", "DELETE"], paramPath: "/api/v1/orders/{order_id}" },
110
215
  products: { basePath: "/api/v1/products", methods: ["GET", "POST"], paramPath: "/api/v1/products/{id}" },
111
216
  });
112
- // Even unresolvable paths get seed scenarios the LLM discards irrelevant ones
113
- const result = draftMinimalScenarios([{ method: "PATCH", path: "/{nonexistent_id}" }], groups, "new_endpoint");
114
- expect(result).toHaveLength(2); // contract + integration
217
+ // Diff scanner reports router-relative path, not the full API path
218
+ const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/{order_id}" }], groups);
219
+ expect(scenarios.length).toBeGreaterThanOrEqual(3);
220
+ // Mutation-recalculation scenario is drafted
221
+ const mutationRecalc = scenarios.find(s => s.scenarioName === "orders-patch-mutation-verify");
222
+ expect(mutationRecalc).toBeDefined();
223
+ expect(mutationRecalc.testType).toBe("integration");
224
+ expect(mutationRecalc.category).toBe("new_endpoint");
225
+ expect(mutationRecalc.steps[1].method).toBe("PATCH");
226
+ // Contract and not-found scenarios use the resolved full path
227
+ const contract = scenarios.find(s => s.scenarioName.includes("contract"));
228
+ expect(contract).toBeDefined();
229
+ expect(contract.steps[0].path).toBe("/api/v1/orders/{order_id}");
230
+ const notFound = scenarios.find(s => s.scenarioName.includes("not-found"));
231
+ expect(notFound).toBeDefined();
232
+ expect(notFound.steps[0].path).toBe("/api/v1/orders/{order_id}");
115
233
  });
116
- it("deduplicates scenarios by method+path+testType key", () => {
234
+ it("resolves router-relative base paths (e.g. /orders) against known resourceGroups", () => {
117
235
  const groups = makeGroups({
118
- orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{id}" },
236
+ orders: { basePath: "/api/v1/orders", methods: ["GET", "POST"], paramPath: "/api/v1/orders/{order_id}" },
119
237
  });
120
- // Same endpoint twice
121
- const result = draftMinimalScenarios([
122
- { method: "POST", path: "/api/orders" },
123
- { method: "POST", path: "/api/orders" },
124
- ], groups, "new_endpoint");
125
- expect(result).toHaveLength(2); // 1 contract + 1 integration, not doubled
126
- });
127
- it("action sub-path falls back to parent resource", () => {
238
+ const scenarios = draftDiffDirectScenarios([{ method: "POST", path: "/orders" }], groups);
239
+ expect(scenarios.length).toBeGreaterThanOrEqual(2);
240
+ const integration = scenarios.find(s => s.testType === "integration");
241
+ expect(integration).toBeDefined();
242
+ expect(integration.category).toBe("new_endpoint");
243
+ });
244
+ it("skips unresolvable router-relative paths", () => {
128
245
  const groups = makeGroups({
129
246
  products: { basePath: "/api/v1/products", methods: ["GET", "POST"], paramPath: "/api/v1/products/{id}" },
130
247
  });
131
- const result = draftMinimalScenarios([{ method: "GET", path: "/api/v1/products/search" }], groups, "new_endpoint");
132
- expect(result.length).toBeGreaterThan(0);
133
- expect(result.some(s => s.scenarioName.includes("products"))).toBe(true);
248
+ // /{nonexistent_id} doesn't match any known resource
249
+ const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/{nonexistent_id}" }], groups);
250
+ expect(scenarios).toEqual([]);
134
251
  });
135
- it("different paths resolving to same resource both produce scenarios (no silent dedup)", () => {
136
- const groups = makeGroups({
137
- orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{id}" },
138
- });
139
- // GET /api/orders and GET /api/orders/search both involve "orders" resource
140
- const result = draftMinimalScenarios([
141
- { method: "GET", path: "/api/orders" },
142
- { method: "GET", path: "/api/orders/search" },
143
- ], groups, "new_endpoint");
144
- // Both should produce contract scenarios (not deduplicated)
145
- expect(result).toHaveLength(2);
146
- const paths = result.map(s => s.steps[0].path);
147
- expect(paths).toContain("/api/orders");
148
- expect(paths).toContain("/api/orders/search");
149
- // Scenario names should be distinct
150
- expect(result[0].scenarioName).not.toBe(result[1].scenarioName);
151
- });
152
- it("multiple methods on same path correct scenario count", () => {
153
- const groups = makeGroups({
154
- orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT", "DELETE"], paramPath: "/api/orders/{id}" },
155
- });
156
- const result = draftMinimalScenarios([
157
- { method: "GET", path: "/api/orders/{id}" },
158
- { method: "PUT", path: "/api/orders/{id}" },
159
- { method: "DELETE", path: "/api/orders/{id}" },
160
- ], groups, "new_endpoint");
161
- // GET → 1 contract; PUT → 1 contract + 1 integration; DELETE → 1 contract + 1 integration = 5
162
- expect(result).toHaveLength(5);
252
+ it("PR #195 regression: PATCH /{order_id} produces mutation-recalculate scenario in GENERATE slots", () => {
253
+ // Simulates the exact data from PR #195:
254
+ // - Full endpoint list with orders, products, reviews
255
+ // - newEndpoints has PATCH /{order_id} (router-relative)
256
+ const endpoints = [
257
+ { path: "/api/v1/orders", methods: ["GET", "POST"] },
258
+ { path: "/api/v1/orders/{order_id}", methods: ["GET", "PATCH", "DELETE"] },
259
+ { path: "/api/v1/products", methods: ["GET", "POST"] },
260
+ { path: "/api/v1/products/{product_id}", methods: ["GET", "PUT", "DELETE"] },
261
+ { path: "/api/v1/reviews", methods: ["GET", "POST"] },
262
+ { path: "/api/v1/reset", methods: ["POST"] },
263
+ ];
264
+ const newEndpoints = [{ method: "PATCH", path: "/{order_id}" }];
265
+ const allScenarios = draftScenariosFromEndpoints(endpoints, newEndpoints);
266
+ // The diff-direct scenarios should include orders-patch-mutation-verify
267
+ const mutationRecalc = allScenarios.find(s => s.scenarioName === "orders-patch-mutation-verify");
268
+ expect(mutationRecalc).toBeDefined();
269
+ expect(mutationRecalc.category).toBe("new_endpoint"); //CRITICAL priority tier
270
+ expect(mutationRecalc.steps).toHaveLength(3);
271
+ expect(mutationRecalc.steps[0].method).toBe("POST");
272
+ expect(mutationRecalc.steps[1].method).toBe("PATCH");
273
+ expect(mutationRecalc.steps[2].method).toBe("GET");
274
+ expect(mutationRecalc.description).toContain("derived calculations");
275
+ // Verify it outranks all non-new_endpoint scenarios (cascade-delete, unique-constraint, etc.)
276
+ const newEndpointScenarios = allScenarios.filter(s => s.category === "new_endpoint");
277
+ const otherScenarios = allScenarios.filter(s => s.category !== "new_endpoint");
278
+ expect(newEndpointScenarios.length).toBeGreaterThanOrEqual(3);
279
+ // All new_endpoint scenarios should include our mutation-recalculate
280
+ expect(newEndpointScenarios.some(s => s.scenarioName === "orders-patch-mutation-verify")).toBe(true);
281
+ // Non-new_endpoint scenarios should NOT include mutation-recalculate
282
+ expect(otherScenarios.every(s => s.scenarioName !== "orders-patch-mutation-verify")).toBe(true);
163
283
  });
164
284
  });
165
- // ---------------------------------------------------------------------------
166
- // extractResourceName — parent-segment fallback (tested indirectly)
167
- // ---------------------------------------------------------------------------
168
- describe("resolveEndpoint — prefix matching for sub-paths", () => {
285
+ describe("diffDirectMutationRecalc — diverse app types (no hardcoded field names)", () => {
169
286
  const makeGroups = (defs) => new Map(Object.entries(defs).map(([k, v]) => [k, { ...v, methods: new Set(v.methods) }]));
170
- it("/products/search falls back to 'products' resource", () => {
287
+ const testCases = [
288
+ {
289
+ name: "E-commerce: PATCH /orders/{order_id}",
290
+ resource: "orders",
291
+ group: { basePath: "/api/v1/orders", methods: ["GET", "POST", "PATCH"], paramPath: "/api/v1/orders/{order_id}" },
292
+ method: "PATCH",
293
+ expectedParamName: "order_id",
294
+ },
295
+ {
296
+ name: "Project management: PUT /projects/{id}",
297
+ resource: "projects",
298
+ group: { basePath: "/api/projects", methods: ["GET", "POST", "PUT"], paramPath: "/api/projects/{id}" },
299
+ method: "PUT",
300
+ expectedParamName: "id",
301
+ },
302
+ {
303
+ name: "Invoicing: PATCH /invoices/{invoice_id}",
304
+ resource: "invoices",
305
+ group: { basePath: "/v2/invoices", methods: ["GET", "POST", "PATCH"], paramPath: "/v2/invoices/{invoice_id}" },
306
+ method: "PATCH",
307
+ expectedParamName: "invoice_id",
308
+ },
309
+ {
310
+ name: "Education LMS: PUT /courses/{course_id}",
311
+ resource: "courses",
312
+ group: { basePath: "/api/courses", methods: ["GET", "POST", "PUT"], paramPath: "/api/courses/{course_id}" },
313
+ method: "PUT",
314
+ expectedParamName: "course_id",
315
+ },
316
+ {
317
+ name: "Healthcare: PATCH /prescriptions/{prescription_id}",
318
+ resource: "prescriptions",
319
+ group: { basePath: "/api/prescriptions", methods: ["GET", "POST", "PATCH"], paramPath: "/api/prescriptions/{prescription_id}" },
320
+ method: "PATCH",
321
+ expectedParamName: "prescription_id",
322
+ },
323
+ {
324
+ name: "Logistics: PUT /shipments/{shipment_id}",
325
+ resource: "shipments",
326
+ group: { basePath: "/api/shipments", methods: ["GET", "POST", "PUT"], paramPath: "/api/shipments/{shipment_id}" },
327
+ method: "PUT",
328
+ expectedParamName: "shipment_id",
329
+ },
330
+ {
331
+ name: "Restaurant: PATCH /menus/{menuId}",
332
+ resource: "menus",
333
+ group: { basePath: "/api/menus", methods: ["GET", "POST", "PATCH"], paramPath: "/api/menus/{menuId}" },
334
+ method: "PATCH",
335
+ expectedParamName: "menuId",
336
+ },
337
+ {
338
+ name: "Cloud billing: PUT /subscriptions/{subscription_id}",
339
+ resource: "subscriptions",
340
+ group: { basePath: "/billing/subscriptions", methods: ["GET", "POST", "PUT"], paramPath: "/billing/subscriptions/{subscription_id}" },
341
+ method: "PUT",
342
+ expectedParamName: "subscription_id",
343
+ },
344
+ {
345
+ name: "Warehouse: PATCH /inventories/{uuid}",
346
+ resource: "inventories",
347
+ group: { basePath: "/api/inventories", methods: ["GET", "POST", "PATCH"], paramPath: "/api/inventories/{uuid}" },
348
+ method: "PATCH",
349
+ expectedParamName: "uuid",
350
+ },
351
+ {
352
+ name: "Minimal: PATCH /items (no paramPath)",
353
+ resource: "items",
354
+ group: { basePath: "/items", methods: ["GET", "POST", "PATCH"] },
355
+ method: "PATCH",
356
+ expectedParamName: "item_id",
357
+ },
358
+ ];
359
+ it.each(testCases)("$name — produces valid mutation-recalc scenario", ({ resource, group, method, expectedParamName }) => {
360
+ const groups = makeGroups({ [resource]: group });
361
+ const scenarios = draftDiffDirectScenarios([{ method, path: group.paramPath ?? group.basePath }], groups);
362
+ const mutationRecalc = scenarios.find(s => s.scenarioName.endsWith("-mutation-verify"));
363
+ expect(mutationRecalc).toBeDefined();
364
+ expect(mutationRecalc.category).toBe("new_endpoint");
365
+ expect(mutationRecalc.testType).toBe("integration");
366
+ expect(mutationRecalc.priority).toBe("high");
367
+ // Step 1: POST to create resource (all test cases have POST)
368
+ expect(mutationRecalc.steps[0].method).toBe("POST");
369
+ expect(mutationRecalc.steps[0].path).toBe(group.basePath);
370
+ // Step 2: PUT/PATCH with mutation
371
+ const mutationStep = mutationRecalc.steps[1];
372
+ expect(mutationStep.method).toBe(method);
373
+ // Note: bodyMustInclude removed in intent-based refactor - LLM discovers fields from source
374
+ // Verify NO hardcoded domain-specific field names
375
+ // Verify hints are descriptive (contain "e.g." or similar guidance)
376
+ // Chaining uses the actual path param name (only when paramPath exists)
377
+ if (group.paramPath) {
378
+ expect(mutationStep.chainsFrom).toBeDefined();
379
+ const chaining = mutationStep.chainsFrom;
380
+ expect(chaining.targetParam).toBe(expectedParamName);
381
+ expect(chaining.sourceStep).toBe(1);
382
+ expect(chaining.sourceField).toBe("id");
383
+ }
384
+ else {
385
+ expect(mutationStep.chainsFrom).toBeUndefined();
386
+ }
387
+ // Step 3: GET verification (if paramPath exists)
388
+ if (group.paramPath) {
389
+ expect(mutationRecalc.steps).toHaveLength(3);
390
+ const getStep = mutationRecalc.steps[2];
391
+ expect(getStep.method).toBe("GET");
392
+ // Note: expectedResponseFields removed in intent-based refactor
393
+ }
394
+ // chainingKeys uses the actual path param name, not hardcoded singular_id
395
+ expect(mutationRecalc.chainingKeys).toContain("id");
396
+ expect(mutationRecalc.chainingKeys).toContain(expectedParamName);
397
+ // Description is domain-agnostic
398
+ expect(mutationRecalc.description).toContain("Mutation test");
399
+ expect(mutationRecalc.description).toContain("derived calculations");
400
+ });
401
+ it("no paramPath: PATCH step targets basePath, no GET verification, fallback param name", () => {
171
402
  const groups = makeGroups({
172
- products: { basePath: "/api/v1/products", methods: ["GET", "POST"], paramPath: "/api/v1/products/{id}" },
403
+ items: { basePath: "/items", methods: ["GET", "POST", "PATCH"] },
173
404
  });
174
- const result = draftMinimalScenarios([{ method: "GET", path: "/api/v1/products/search" }], groups, "new_endpoint");
175
- expect(result.length).toBeGreaterThan(0);
176
- expect(result.some(s => s.scenarioName.includes("products"))).toBe(true);
405
+ const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/items" }], groups);
406
+ const mutationRecalc = scenarios.find(s => s.scenarioName.endsWith("-mutation-verify"));
407
+ expect(mutationRecalc).toBeDefined();
408
+ // No paramPath → PATCH targets basePath, no GET step
409
+ expect(mutationRecalc.steps).toHaveLength(2);
410
+ expect(mutationRecalc.steps[1].path).toBe("/items");
411
+ // Fallback param name: singular + _id
412
+ expect(mutationRecalc.chainingKeys).toContain("item_id");
177
413
  });
178
- it("/links/archive falls back to 'links' resource", () => {
414
+ it("resource without POST: no create step, no chaining", () => {
179
415
  const groups = makeGroups({
180
- links: { basePath: "/api/v1/links", methods: ["GET", "POST", "PUT"], paramPath: "/api/v1/links/{id}" },
416
+ configs: { basePath: "/api/configs", methods: ["GET", "PATCH"], paramPath: "/api/configs/{config_id}" },
181
417
  });
182
- const result = draftMinimalScenarios([{ method: "POST", path: "/api/v1/links/archive" }], groups, "new_endpoint");
183
- expect(result.length).toBeGreaterThan(0);
184
- expect(result.some(s => s.scenarioName.includes("links"))).toBe(true);
418
+ const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/api/configs/{config_id}" }], groups);
419
+ const mutationRecalc = scenarios.find(s => s.scenarioName.endsWith("-mutation-verify"));
420
+ expect(mutationRecalc).toBeDefined();
421
+ // No POST → starts with PATCH directly, no chaining
422
+ expect(mutationRecalc.steps[0].method).toBe("PATCH");
423
+ expect(mutationRecalc.steps[0].chainsFrom).toBeUndefined();
424
+ // Still has GET verification
425
+ expect(mutationRecalc.steps[1].method).toBe("GET");
426
+ expect(mutationRecalc.steps[1].chainsFrom).toBeUndefined();
185
427
  });
186
- it("/health generates a scenario even without a matching group (LLM validates)", () => {
428
+ it("generates auth-boundary scenario for each new endpoint (security_boundary, not new_endpoint)", () => {
187
429
  const groups = makeGroups({
188
- users: { basePath: "/api/users", methods: ["GET", "POST"] },
430
+ orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT"], paramPath: "/api/orders/{order_id}" },
189
431
  });
190
- const result = draftMinimalScenarios([{ method: "GET", path: "/health" }], groups, "new_endpoint");
191
- expect(result).toHaveLength(1); // GET contract only
192
- expect(result[0].steps[0].path).toBe("/health");
432
+ const scenarios = draftDiffDirectScenarios([{ method: "PUT", path: "/api/orders/{order_id}" }], groups);
433
+ const authBoundary = scenarios.find(s => s.scenarioName === "orders-put-auth-boundary");
434
+ expect(authBoundary).toBeDefined();
435
+ expect(authBoundary.category).toBe("security_boundary");
436
+ expect(authBoundary.testType).toBe("contract");
437
+ expect(authBoundary.steps[0].method).toBe("PUT");
438
+ expect(authBoundary.steps[0].path).toBe("/api/orders/{order_id}");
439
+ expect(authBoundary.steps[0].expectedStatusCode).toBe(401);
440
+ expect(authBoundary.requiresAuth).toBe(false);
193
441
  });
194
- it("same action sub-path under different resources both resolve correctly", () => {
442
+ it("auth-boundary scenario is not duplicated for same method/path", () => {
195
443
  const groups = makeGroups({
196
- products: { basePath: "/api/products", methods: ["GET", "POST"], paramPath: "/api/products/{id}" },
197
- orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{id}" },
444
+ orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT", "DELETE"], paramPath: "/api/orders/{order_id}" },
198
445
  });
199
- const result = draftMinimalScenarios([
200
- { method: "GET", path: "/api/products/search" },
201
- { method: "GET", path: "/api/orders/search" },
202
- ], groups, "new_endpoint");
203
- expect(result).toHaveLength(2);
204
- const names = result.map(s => s.scenarioName);
205
- expect(names[0]).not.toBe(names[1]);
206
- expect(names.some(n => n.includes("products"))).toBe(true);
207
- expect(names.some(n => n.includes("orders"))).toBe(true);
446
+ const scenarios = draftDiffDirectScenarios([{ method: "PUT", path: "/api/orders/{order_id}" }, { method: "DELETE", path: "/api/orders/{order_id}" }], groups);
447
+ const authBoundaries = scenarios.filter(s => s.scenarioName.includes("auth-boundary"));
448
+ // One for PUT, one for DELETE (not GET, not POST)
449
+ expect(authBoundaries).toHaveLength(2);
450
+ expect(authBoundaries.map(s => s.scenarioName).sort()).toEqual([
451
+ "orders-delete-auth-boundary",
452
+ "orders-put-auth-boundary",
453
+ ]);
208
454
  });
209
- it("/api/v1/payments 'payments' is a real resource, generates scenarios", () => {
455
+ it("does NOT generate auth-boundary for GET endpoints (often public)", () => {
210
456
  const groups = makeGroups({
211
- payments: { basePath: "/api/v1/payments", methods: ["GET", "POST"], paramPath: "/api/v1/payments/{id}" },
457
+ orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{order_id}" },
212
458
  });
213
- const result = draftMinimalScenarios([{ method: "POST", path: "/api/v1/payments" }], groups, "new_endpoint");
214
- expect(result.length).toBeGreaterThan(0);
215
- expect(result.some(s => s.scenarioName.includes("payments"))).toBe(true);
459
+ const scenarios = draftDiffDirectScenarios([{ method: "GET", path: "/api/orders/{order_id}" }], groups);
460
+ const authBoundary = scenarios.find(s => s.scenarioName.includes("auth-boundary"));
461
+ expect(authBoundary).toBeUndefined();
216
462
  });
217
- it("/api/v1/webhooks 'webhooks' is a real resource, generates scenarios", () => {
463
+ it("does NOT generate auth-boundary for POST", () => {
218
464
  const groups = makeGroups({
219
- webhooks: { basePath: "/api/v1/webhooks", methods: ["GET", "POST"], paramPath: "/api/v1/webhooks/{id}" },
465
+ products: { basePath: "/api/products", methods: ["GET", "POST"], paramPath: "/api/products/{id}" },
220
466
  });
221
- const result = draftMinimalScenarios([{ method: "POST", path: "/api/v1/webhooks" }], groups, "new_endpoint");
222
- expect(result.length).toBeGreaterThan(0);
223
- expect(result.some(s => s.scenarioName.includes("webhooks"))).toBe(true);
467
+ const scenarios = draftDiffDirectScenarios([{ method: "POST", path: "/api/products" }], groups);
468
+ const authBoundary = scenarios.find(s => s.scenarioName.includes("auth-boundary"));
469
+ expect(authBoundary).toBeUndefined();
224
470
  });
225
471
  });
226
- // ---------------------------------------------------------------------------
227
- // draftScenariosFromEndpoints integration tests
228
- // ---------------------------------------------------------------------------
229
- describe("draftScenariosFromEndpoints", () => {
230
- it("returns empty array for no endpoints", () => {
231
- expect(draftScenariosFromEndpoints([])).toEqual([]);
472
+ describe("draftDiffDirectScenarios — GET without path param", () => {
473
+ const resourceGroups = new Map([
474
+ ["products", {
475
+ basePath: "/api/v1/products",
476
+ methods: new Set(["GET", "POST"]),
477
+ paramPath: "/api/v1/products/{product_id}",
478
+ }],
479
+ ]);
480
+ it("GET no-param: produces contract + collection scenario (LLM determines if query params apply)", () => {
481
+ const result = draftDiffDirectScenarios([{ method: "GET", path: "/api/v1/products/search" }], resourceGroups);
482
+ const names = result.map(s => s.scenarioName);
483
+ expect(names).toContain("products-get-new-endpoint-contract");
484
+ expect(names).toContain("products-search-list-query");
485
+ const scenario = result.find(s => s.scenarioName === "products-search-list-query");
486
+ expect(scenario.category).toBe("new_endpoint");
487
+ // Description instructs LLM to read source for query param logic
488
+ expect(scenario.description).toMatch(/read diff source|query param/i);
232
489
  });
233
- it("passes newEndpoints through as new_endpoint category", () => {
234
- const endpoints = [
235
- { path: "/api/orders", methods: ["GET", "POST", "PUT"] },
236
- { path: "/api/orders/{order_id}", methods: ["GET", "PUT", "DELETE"] },
237
- ];
238
- const newEndpoints = [{ method: "PUT", path: "/api/orders/{order_id}" }];
239
- const result = draftScenariosFromEndpoints(endpoints, newEndpoints);
240
- const newScenarios = result.filter(s => s.category === "new_endpoint");
241
- expect(newScenarios.length).toBeGreaterThan(0);
242
- expect(newScenarios.some(s => s.steps.some(st => st.method === "PUT"))).toBe(true);
490
+ it("GET no-param: produces zero cascade-delete or unique-constraint scenarios", () => {
491
+ const result = draftDiffDirectScenarios([{ method: "GET", path: "/api/v1/products/search" }], resourceGroups);
492
+ const names = result.map(s => s.scenarioName);
493
+ expect(names.some(n => n.includes("cascade"))).toBe(false);
494
+ expect(names.some(n => n.includes("unique"))).toBe(false);
495
+ expect(names.some(n => n.includes("delete-blocked"))).toBe(false);
243
496
  });
244
- it("changed + new endpoints on same resource new takes priority (no duplicates)", () => {
245
- const endpoints = [
246
- { path: "/api/orders", methods: [{ method: "GET" }, { method: "POST" }, { method: "PUT" }] },
247
- { path: "/api/orders/{order_id}", methods: [{ method: "GET" }, { method: "PUT" }, { method: "DELETE" }] },
248
- ];
249
- const newEndpoints = [{ method: "PUT", path: "/api/orders/{order_id}" }];
250
- const changedEndpoints = [{ method: "PUT", path: "/api/orders/{order_id}" }];
251
- const result = draftScenariosFromEndpoints(endpoints, newEndpoints, changedEndpoints);
252
- const putScenarios = result.filter(s => s.steps.some(st => st.method === "PUT"));
253
- const newPut = putScenarios.filter(s => s.category === "new_endpoint");
254
- const changedPut = putScenarios.filter(s => s.category === "business_rule");
255
- expect(newPut.length).toBeGreaterThan(0);
256
- expect(changedPut.length).toBe(0); // deduped — new takes priority
257
- });
258
- it("changed endpoints produce scenarios even when no new endpoints exist", () => {
259
- const endpoints = [
260
- { path: "/api/orders", methods: [{ method: "GET" }, { method: "POST" }] },
261
- { path: "/api/orders/{order_id}", methods: [{ method: "GET" }, { method: "PATCH" }] },
262
- ];
263
- const result = draftScenariosFromEndpoints(endpoints, [], [
264
- { method: "PATCH", path: "/api/orders/{order_id}" },
265
- ]);
266
- expect(result.length).toBeGreaterThan(0);
267
- expect(result.every(s => s.category === "business_rule")).toBe(true);
497
+ it("collection GET with POST: produces integration scenario (POST then GET)", () => {
498
+ const result = draftDiffDirectScenarios([{ method: "GET", path: "/api/v1/products" }], resourceGroups);
499
+ const scenario = result.find(s => s.scenarioName === "products-list-query");
500
+ expect(scenario).toBeDefined();
501
+ expect(scenario.testType).toBe("integration");
502
+ expect(scenario.steps[0].method).toBe("POST");
503
+ expect(scenario.steps[1].method).toBe("GET");
268
504
  });
269
- it("modified GET contract only; modified PUT contract + integration", () => {
270
- const endpoints = [
271
- { path: "/api/orders", methods: [{ method: "GET" }, { method: "POST" }, { method: "PUT" }] },
272
- { path: "/api/orders/{order_id}", methods: [{ method: "GET" }, { method: "PUT" }] },
273
- ];
274
- const result = draftScenariosFromEndpoints(endpoints, [], [
275
- { method: "GET", path: "/api/orders/{order_id}" },
276
- { method: "PUT", path: "/api/orders/{order_id}" },
277
- ]);
278
- const getScenarios = result.filter(s => s.steps[0].method === "GET");
279
- const putScenarios = result.filter(s => s.steps[0].method === "PUT");
280
- expect(getScenarios).toHaveLength(1); // contract only
281
- expect(putScenarios).toHaveLength(2); // contract + integration
282
- });
283
- it("unrelated endpoints sharing last segment both produce scenarios", () => {
284
- // /products/search and /orders/search both extract "search" as resource name.
285
- // Without disambiguated keys, the second endpoint is silently dropped.
286
- const endpoints = [
287
- { path: "/api/products/search", methods: ["GET"] },
288
- { path: "/api/orders/search", methods: ["GET"] },
289
- ];
290
- const result = draftScenariosFromEndpoints(endpoints, [
291
- { method: "GET", path: "/api/products/search" },
292
- { method: "GET", path: "/api/orders/search" },
505
+ it("collection GET without POST: produces contract scenario (single GET step)", () => {
506
+ const readOnlyGroups = new Map([
507
+ ["reports", {
508
+ basePath: "/api/v1/reports",
509
+ methods: new Set(["GET"]),
510
+ paramPath: undefined,
511
+ }],
293
512
  ]);
294
- expect(result).toHaveLength(2);
295
- const paths = result.map(s => s.steps[0].path);
296
- expect(paths).toContain("/api/products/search");
297
- expect(paths).toContain("/api/orders/search");
513
+ const result = draftDiffDirectScenarios([{ method: "GET", path: "/api/v1/reports" }], readOnlyGroups);
514
+ const scenario = result.find(s => s.scenarioName === "reports-list-query");
515
+ expect(scenario).toBeDefined();
516
+ expect(scenario.testType).toBe("contract");
517
+ expect(scenario.steps).toHaveLength(1);
298
518
  });
299
519
  });
300
520
  // ---------------------------------------------------------------------------
301
521
  // capScenarios — enforced via draftScenariosFromEndpoints
302
522
  // ---------------------------------------------------------------------------
303
523
  describe("draftScenariosFromEndpoints — capScenarios hard limit", () => {
524
+ // Each PUT/PATCH new endpoint produces ~5 scenarios (mutation-verify, boundary-values,
525
+ // contract, not-found, auth-boundary). Seven resources × 2 methods each = 70 new
526
+ // endpoints → well above the 30-scenario cap.
304
527
  function makeEndpointsAndNewEps(resourceNames) {
305
528
  const endpoints = resourceNames.flatMap(r => [
306
529
  { path: `/api/${r}`, methods: [{ method: "GET" }, { method: "POST" }] },
@@ -309,8 +532,6 @@ describe("draftScenariosFromEndpoints — capScenarios hard limit", () => {
309
532
  const newEndpoints = resourceNames.flatMap(r => [
310
533
  { method: "PUT", path: `/api/${r}/{id}` },
311
534
  { method: "PATCH", path: `/api/${r}/{id}` },
312
- { method: "POST", path: `/api/${r}` },
313
- { method: "DELETE", path: `/api/${r}/{id}` },
314
535
  ]);
315
536
  return { endpoints, newEndpoints };
316
537
  }
@@ -324,9 +545,24 @@ describe("draftScenariosFromEndpoints — capScenarios hard limit", () => {
324
545
  const resources = ["users", "products", "orders", "invoices", "shipments", "reviews", "payments", "discounts"];
325
546
  const { endpoints, newEndpoints } = makeEndpointsAndNewEps(resources);
326
547
  const result = draftScenariosFromEndpoints(endpoints, newEndpoints);
548
+ // new_endpoint is CRITICAL — verify at least some survive
327
549
  const criticalOnes = result.filter((s) => s.category === "new_endpoint");
328
550
  expect(criticalOnes.length).toBeGreaterThan(0);
329
551
  });
552
+ it("CRITICAL items (new_endpoint) fill the cap first; security_boundary may be dropped", () => {
553
+ // With many new endpoints, diffDirectContract/NotFound/MutationVerify/BoundaryValues all
554
+ // produce category "new_endpoint" (CRITICAL). When CRITICAL items alone exceed 30, the hard
555
+ // cap slices them first — breadth picks for non-CRITICAL categories are not guaranteed.
556
+ const resources = ["users", "products", "orders", "invoices", "shipments", "reviews", "payments", "discounts"];
557
+ const { endpoints, newEndpoints } = makeEndpointsAndNewEps(resources);
558
+ const result = draftScenariosFromEndpoints(endpoints, newEndpoints);
559
+ // All 30 surviving scenarios should be CRITICAL (new_endpoint)
560
+ const nonCritical = result.filter((s) => s.category !== "new_endpoint");
561
+ // Non-critical (security_boundary) may be 0 — this is the documented cap behavior
562
+ expect(nonCritical.length).toBeLessThanOrEqual(result.length);
563
+ // And the cap is enforced
564
+ expect(result.length).toBeLessThanOrEqual(30);
565
+ });
330
566
  it("does not cap when total scenarios are within the limit", () => {
331
567
  const endpoints = [
332
568
  { path: "/api/users", methods: [{ method: "GET" }, { method: "POST" }] },
@@ -336,24 +572,8 @@ describe("draftScenariosFromEndpoints — capScenarios hard limit", () => {
336
572
  { method: "PUT", path: "/api/users/{id}" },
337
573
  ];
338
574
  const result = draftScenariosFromEndpoints(endpoints, newEndpoints);
575
+ // A single PUT endpoint produces 5 scenarios — well within the 30-scenario cap
339
576
  expect(result.length).toBeLessThanOrEqual(30);
340
- expect(result.length).toBe(2); // contract + integration
341
- });
342
- it("respects MAX_TOTAL_SCENARIOS with combined new+changed", () => {
343
- const resources = ["users", "products", "orders", "invoices", "shipments", "reviews", "payments", "discounts"];
344
- const endpoints = resources.flatMap(r => [
345
- { path: `/api/${r}`, methods: [{ method: "GET" }, { method: "POST" }] },
346
- { path: `/api/${r}/{id}`, methods: [{ method: "GET" }, { method: "PUT" }, { method: "PATCH" }, { method: "DELETE" }] },
347
- ]);
348
- const newEndpoints = resources.slice(0, 4).flatMap(r => [
349
- { method: "PUT", path: `/api/${r}/{id}` },
350
- { method: "POST", path: `/api/${r}` },
351
- ]);
352
- const changedEndpoints = resources.slice(4).flatMap(r => [
353
- { method: "PATCH", path: `/api/${r}/{id}` },
354
- { method: "GET", path: `/api/${r}/{id}` },
355
- ]);
356
- const result = draftScenariosFromEndpoints(endpoints, newEndpoints, changedEndpoints);
357
- expect(result.length).toBeLessThanOrEqual(30);
577
+ expect(result.length).toBeGreaterThanOrEqual(4);
358
578
  });
359
579
  });