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