@skyramp/mcp 0.1.2 → 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.
- package/build/prompts/test-maintenance/driftAnalysisSections.js +2 -2
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +26 -21
- package/build/prompts/test-recommendation/recommendationSections.js +42 -10
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +2 -5
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +114 -157
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +250 -18
- package/build/prompts/testbot/testbot-prompts.js +17 -9
- package/build/services/ScenarioGenerationService.js +2 -1
- package/build/services/TestDiscoveryService.js +22 -7
- package/build/services/TestDiscoveryService.test.js +44 -0
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +3 -4
- package/build/tools/generate-tests/generateBatchScenarioRestTool.test.js +9 -0
- package/build/tools/submitReportTool.js +4 -3
- package/build/tools/submitReportTool.test.js +16 -2
- package/build/tools/test-management/analyzeChangesTool.js +264 -140
- package/build/tools/test-management/analyzeChangesTool.test.js +3 -1
- package/build/tools/test-management/analyzeTestHealthTool.js +5 -0
- package/build/types/RepositoryAnalysis.js +8 -0
- package/build/types/TestRecommendation.js +2 -0
- package/build/utils/branchDiff.js +24 -8
- package/build/utils/featureFlags.js +25 -0
- package/build/utils/httpDefaults.js +12 -0
- package/build/utils/repoScanner.js +16 -2
- package/build/utils/routeParsers.js +79 -79
- package/build/utils/routeParsers.test.js +192 -66
- package/build/utils/scenarioDrafting.js +116 -497
- package/build/utils/scenarioDrafting.test.js +260 -480
- package/package.json +1 -1
|
@@ -1,529 +1,306 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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(
|
|
9
|
+
expect(draftMinimalScenarios([], groups, "new_endpoint")).toEqual([]);
|
|
122
10
|
});
|
|
123
|
-
it("
|
|
11
|
+
it("GET endpoint → 1 contract scenario only (no integration)", () => {
|
|
124
12
|
const groups = makeGroups({
|
|
125
|
-
orders: { basePath: "/api/orders", methods: ["GET", "POST"
|
|
13
|
+
orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{id}" },
|
|
126
14
|
});
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
expect(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
24
|
+
products: { basePath: "/api/products", methods: ["GET", "POST"], paramPath: "/api/products/{id}" },
|
|
150
25
|
});
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
expect(
|
|
156
|
-
expect(
|
|
157
|
-
expect(
|
|
158
|
-
expect(
|
|
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("
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
expect(
|
|
175
|
-
|
|
176
|
-
expect(
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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("
|
|
57
|
+
it("each scenario has exactly 1 step", () => {
|
|
182
58
|
const groups = makeGroups({
|
|
183
|
-
|
|
59
|
+
orders: { basePath: "/api/orders", methods: ["GET", "POST", "PUT", "DELETE"], paramPath: "/api/orders/{id}" },
|
|
184
60
|
});
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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("
|
|
71
|
+
it("new_endpoint category → scenario names contain 'new'", () => {
|
|
194
72
|
const groups = makeGroups({
|
|
195
|
-
orders: { basePath: "/api/orders", methods: ["
|
|
73
|
+
orders: { basePath: "/api/orders", methods: ["GET", "POST"], paramPath: "/api/orders/{id}" },
|
|
196
74
|
});
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
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("
|
|
202
|
-
const
|
|
203
|
-
{
|
|
204
|
-
|
|
205
|
-
];
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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("
|
|
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"
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
239
|
-
expect(
|
|
240
|
-
|
|
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("
|
|
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
|
-
//
|
|
249
|
-
const
|
|
250
|
-
expect(
|
|
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("
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
{
|
|
259
|
-
{
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
expect(
|
|
269
|
-
expect(
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
//
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
+
products: { basePath: "/api/v1/products", methods: ["GET", "POST"], paramPath: "/api/v1/products/{id}" },
|
|
404
173
|
});
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
expect(
|
|
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("
|
|
178
|
+
it("/links/archive → falls back to 'links' resource", () => {
|
|
415
179
|
const groups = makeGroups({
|
|
416
|
-
|
|
180
|
+
links: { basePath: "/api/v1/links", methods: ["GET", "POST", "PUT"], paramPath: "/api/v1/links/{id}" },
|
|
417
181
|
});
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
expect(
|
|
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
|
|
186
|
+
it("/health generates a scenario even without a matching group (LLM validates)", () => {
|
|
429
187
|
const groups = makeGroups({
|
|
430
|
-
|
|
188
|
+
users: { basePath: "/api/users", methods: ["GET", "POST"] },
|
|
431
189
|
});
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
expect(
|
|
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("
|
|
194
|
+
it("same action sub-path under different resources → both resolve correctly", () => {
|
|
443
195
|
const groups = makeGroups({
|
|
444
|
-
|
|
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
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
expect(
|
|
451
|
-
|
|
452
|
-
|
|
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("
|
|
209
|
+
it("/api/v1/payments → 'payments' is a real resource, generates scenarios", () => {
|
|
456
210
|
const groups = makeGroups({
|
|
457
|
-
|
|
211
|
+
payments: { basePath: "/api/v1/payments", methods: ["GET", "POST"], paramPath: "/api/v1/payments/{id}" },
|
|
458
212
|
});
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
expect(
|
|
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("
|
|
217
|
+
it("/api/v1/webhooks → 'webhooks' is a real resource, generates scenarios", () => {
|
|
464
218
|
const groups = makeGroups({
|
|
465
|
-
|
|
219
|
+
webhooks: { basePath: "/api/v1/webhooks", methods: ["GET", "POST"], paramPath: "/api/v1/webhooks/{id}" },
|
|
466
220
|
});
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
expect(
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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("
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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("
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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("
|
|
506
|
-
const
|
|
507
|
-
["
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
514
|
-
const
|
|
515
|
-
expect(
|
|
516
|
-
expect(
|
|
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).
|
|
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
|
});
|