@skyramp/mcp 0.1.3 → 0.1.4

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,529 +1,306 @@
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", () => {
1
+ import { draftScenariosFromEndpoints, draftMinimalScenarios, } from "./scenarioDrafting.js";
2
+ // ---------------------------------------------------------------------------
3
+ // draftMinimalScenarios
4
+ // ---------------------------------------------------------------------------
5
+ describe("draftMinimalScenarios", () => {
118
6
  const makeGroups = (defs) => new Map(Object.entries(defs).map(([k, v]) => [k, { ...v, methods: new Set(v.methods) }]));
119
- it("returns empty array when newEndpoints is empty", () => {
7
+ it("returns empty array when endpoints is empty", () => {
120
8
  const groups = makeGroups({ orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{id}" } });
121
- expect(draftDiffDirectScenarios([], groups)).toEqual([]);
9
+ expect(draftMinimalScenarios([], groups, "new_endpoint")).toEqual([]);
122
10
  });
123
- it("generates new_endpoint-category scenarios for a new PUT endpoint", () => {
11
+ it("GET endpoint 1 contract scenario only (no integration)", () => {
124
12
  const groups = makeGroups({
125
- orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT"], paramPath: "/api/orders/{order_id}" },
13
+ orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{id}" },
126
14
  });
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);
142
- expect(contract).toBeDefined();
143
- // Not-found edge case
144
- const notFound = scenarios.find(s => s.steps.some(st => st.expectedStatusCode === 404));
145
- expect(notFound).toBeDefined();
146
- });
147
- it("generates mutation-recalc scenario for a new PATCH endpoint", () => {
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", () => {
148
23
  const groups = makeGroups({
149
- orders: { basePath: "/api/orders", methods: ["GET", "POST", "PATCH"], paramPath: "/api/orders/{order_id}" },
24
+ products: { basePath: "/api/products", methods: ["GET", "POST"], paramPath: "/api/products/{id}" },
150
25
  });
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
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");
166
34
  });
167
- it("integration scenario minimum steps: POST resource then PUT LLM discovers prereqs from source code", () => {
35
+ it("PUT endpoint 1 contract + 1 integration", () => {
168
36
  const groups = makeGroups({
169
37
  orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT"], paramPath: "/api/orders/{order_id}" },
170
38
  });
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
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");
54
+ expect(contract).toBeDefined();
55
+ expect(integration).toBeDefined();
180
56
  });
181
- it("generates 3 scenarios for a new POST endpoint", () => {
57
+ it("each scenario has exactly 1 step", () => {
182
58
  const groups = makeGroups({
183
- products: { basePath: "/api/products", methods: ["GET", "POST"], paramPath: "/api/products/{id}" },
59
+ orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT", "DELETE"], paramPath: "/api/orders/{id}" },
184
60
  });
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);
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
+ }
192
70
  });
193
- it("ignores unknown paths and skips action-verb segments", () => {
71
+ it("new_endpoint category scenario names contain 'new'", () => {
194
72
  const groups = makeGroups({
195
- orders: { basePath: "/api/orders", methods: ["POST", "DELETE"], paramPath: "/api/orders/{id}" },
73
+ orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{id}" },
196
74
  });
197
- const scenarios = draftDiffDirectScenarios([{ method: "POST", path: "/api/health" }], // 'health' is not a real resource
198
- groups);
199
- expect(scenarios).toEqual([]);
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
+ }
200
80
  });
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);
81
+ it("business_rule category scenario names contain 'changed'", () => {
82
+ const groups = makeGroups({
83
+ orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{id}" },
84
+ });
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
+ }
211
90
  });
212
- it("resolves router-relative param paths (e.g. /{order_id}) against known resourceGroups", () => {
91
+ it("passes through router-relative param paths as-is (LLM resolves from source)", () => {
213
92
  const groups = makeGroups({
214
- orders: { basePath: "/api/v1/orders", methods: ["GET", "POST", "PATCH", "DELETE"], paramPath: "/api/v1/orders/{order_id}" },
215
- products: { basePath: "/api/v1/products", methods: ["GET", "POST"], paramPath: "/api/v1/products/{id}" },
93
+ orders: { basePath: "/api/v1/orders", methods: ["GET", "POST", "PATCH"], paramPath: "/api/v1/orders/{order_id}" },
216
94
  });
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}");
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}");
233
99
  });
234
100
  it("resolves router-relative base paths (e.g. /orders) against known resourceGroups", () => {
235
101
  const groups = makeGroups({
236
102
  orders: { basePath: "/api/v1/orders", methods: ["GET", "POST"], paramPath: "/api/v1/orders/{order_id}" },
237
103
  });
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");
104
+ const result = draftMinimalScenarios([{ method: "POST", path: "/orders" }], groups, "new_endpoint");
105
+ expect(result).toHaveLength(2);
106
+ expect(result[0].scenarioName).toContain("orders");
243
107
  });
244
- it("skips unresolvable router-relative paths", () => {
108
+ it("generates scenarios for any endpoint path (LLM validates relevance from source)", () => {
245
109
  const groups = makeGroups({
246
110
  products: { basePath: "/api/v1/products", methods: ["GET", "POST"], paramPath: "/api/v1/products/{id}" },
247
111
  });
248
- // /{nonexistent_id} doesn't match any known resource
249
- const scenarios = draftDiffDirectScenarios([{ method: "PATCH", path: "/{nonexistent_id}" }], groups);
250
- expect(scenarios).toEqual([]);
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
251
115
  });
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);
116
+ it("deduplicates scenarios by method+path+testType key", () => {
117
+ const groups = makeGroups({
118
+ orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{id}" },
119
+ });
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", () => {
128
+ const groups = makeGroups({
129
+ products: { basePath: "/api/v1/products", methods: ["GET", "POST"], paramPath: "/api/v1/products/{id}" },
130
+ });
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);
134
+ });
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);
283
163
  });
284
164
  });
285
- describe("diffDirectMutationRecalc — diverse app types (no hardcoded field names)", () => {
165
+ // ---------------------------------------------------------------------------
166
+ // extractResourceName — parent-segment fallback (tested indirectly)
167
+ // ---------------------------------------------------------------------------
168
+ describe("resolveEndpoint — prefix matching for sub-paths", () => {
286
169
  const makeGroups = (defs) => new Map(Object.entries(defs).map(([k, v]) => [k, { ...v, methods: new Set(v.methods) }]));
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", () => {
170
+ it("/products/search falls back to 'products' resource", () => {
402
171
  const groups = makeGroups({
403
- items: { basePath: "/items", methods: ["GET", "POST", "PATCH"] },
172
+ products: { basePath: "/api/v1/products", methods: ["GET", "POST"], paramPath: "/api/v1/products/{id}" },
404
173
  });
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");
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);
413
177
  });
414
- it("resource without POST: no create step, no chaining", () => {
178
+ it("/links/archive falls back to 'links' resource", () => {
415
179
  const groups = makeGroups({
416
- configs: { basePath: "/api/configs", methods: ["GET", "PATCH"], paramPath: "/api/configs/{config_id}" },
180
+ links: { basePath: "/api/v1/links", methods: ["GET", "POST", "PUT"], paramPath: "/api/v1/links/{id}" },
417
181
  });
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();
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);
427
185
  });
428
- it("generates auth-boundary scenario for each new endpoint (security_boundary, not new_endpoint)", () => {
186
+ it("/health generates a scenario even without a matching group (LLM validates)", () => {
429
187
  const groups = makeGroups({
430
- orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT"], paramPath: "/api/orders/{order_id}" },
188
+ users: { basePath: "/api/users", methods: ["GET", "POST"] },
431
189
  });
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);
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");
441
193
  });
442
- it("auth-boundary scenario is not duplicated for same method/path", () => {
194
+ it("same action sub-path under different resources both resolve correctly", () => {
443
195
  const groups = makeGroups({
444
- orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT", "DELETE"], paramPath: "/api/orders/{order_id}" },
196
+ products: { basePath: "/api/products", methods: ["GET", "POST"], paramPath: "/api/products/{id}" },
197
+ orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{id}" },
445
198
  });
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
- ]);
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);
454
208
  });
455
- it("does NOT generate auth-boundary for GET endpoints (often public)", () => {
209
+ it("/api/v1/payments 'payments' is a real resource, generates scenarios", () => {
456
210
  const groups = makeGroups({
457
- orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{order_id}" },
211
+ payments: { basePath: "/api/v1/payments", methods: ["GET", "POST"], paramPath: "/api/v1/payments/{id}" },
458
212
  });
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();
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);
462
216
  });
463
- it("does NOT generate auth-boundary for POST", () => {
217
+ it("/api/v1/webhooks 'webhooks' is a real resource, generates scenarios", () => {
464
218
  const groups = makeGroups({
465
- products: { basePath: "/api/products", methods: ["GET", "POST"], paramPath: "/api/products/{id}" },
219
+ webhooks: { basePath: "/api/v1/webhooks", methods: ["GET", "POST"], paramPath: "/api/v1/webhooks/{id}" },
466
220
  });
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();
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);
470
224
  });
471
225
  });
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);
226
+ // ---------------------------------------------------------------------------
227
+ // draftScenariosFromEndpoints integration tests
228
+ // ---------------------------------------------------------------------------
229
+ describe("draftScenariosFromEndpoints", () => {
230
+ it("returns empty array for no endpoints", () => {
231
+ expect(draftScenariosFromEndpoints([])).toEqual([]);
489
232
  });
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);
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);
496
243
  });
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");
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);
504
268
  });
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
- }],
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" },
512
293
  ]);
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);
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");
518
298
  });
519
299
  });
520
300
  // ---------------------------------------------------------------------------
521
301
  // capScenarios — enforced via draftScenariosFromEndpoints
522
302
  // ---------------------------------------------------------------------------
523
303
  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.
527
304
  function makeEndpointsAndNewEps(resourceNames) {
528
305
  const endpoints = resourceNames.flatMap(r => [
529
306
  { path: `/api/${r}`, methods: [{ method: "GET" }, { method: "POST" }] },
@@ -532,6 +309,8 @@ describe("draftScenariosFromEndpoints — capScenarios hard limit", () => {
532
309
  const newEndpoints = resourceNames.flatMap(r => [
533
310
  { method: "PUT", path: `/api/${r}/{id}` },
534
311
  { method: "PATCH", path: `/api/${r}/{id}` },
312
+ { method: "POST", path: `/api/${r}` },
313
+ { method: "DELETE", path: `/api/${r}/{id}` },
535
314
  ]);
536
315
  return { endpoints, newEndpoints };
537
316
  }
@@ -545,24 +324,9 @@ describe("draftScenariosFromEndpoints — capScenarios hard limit", () => {
545
324
  const resources = ["users", "products", "orders", "invoices", "shipments", "reviews", "payments", "discounts"];
546
325
  const { endpoints, newEndpoints } = makeEndpointsAndNewEps(resources);
547
326
  const result = draftScenariosFromEndpoints(endpoints, newEndpoints);
548
- // new_endpoint is CRITICAL — verify at least some survive
549
327
  const criticalOnes = result.filter((s) => s.category === "new_endpoint");
550
328
  expect(criticalOnes.length).toBeGreaterThan(0);
551
329
  });
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
- });
566
330
  it("does not cap when total scenarios are within the limit", () => {
567
331
  const endpoints = [
568
332
  { path: "/api/users", methods: [{ method: "GET" }, { method: "POST" }] },
@@ -572,8 +336,24 @@ describe("draftScenariosFromEndpoints — capScenarios hard limit", () => {
572
336
  { method: "PUT", path: "/api/users/{id}" },
573
337
  ];
574
338
  const result = draftScenariosFromEndpoints(endpoints, newEndpoints);
575
- // A single PUT endpoint produces 5 scenarios — well within the 30-scenario cap
576
339
  expect(result.length).toBeLessThanOrEqual(30);
577
- expect(result.length).toBeGreaterThanOrEqual(4);
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);
578
358
  });
579
359
  });