create-ailk 0.23.0 → 0.24.0
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/README.md +27 -14
- package/dist/add-module.js +6 -1
- package/dist/add-route.js +7 -1
- package/dist/derive-architecture.js +17 -0
- package/dist/lib/scaffold-render-sweep.d.ts +33 -0
- package/dist/lib/scaffold-render-sweep.js +45 -0
- package/dist/module-architecture.js +17 -0
- package/dist/sync-routes.d.ts +30 -4
- package/dist/sync-routes.js +271 -18
- package/package.json +3 -3
- package/templates/.env.example +4 -0
- package/templates/CLAUDE.md +4 -2
- package/templates/apps/api/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +25 -11
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +4 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
- package/templates/apps/api/src/lib/site.ts +4 -1
- package/templates/apps/api/src/lib/stripe.ts +3 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
- package/templates/apps/api/src/openapi/spec.ts +333 -0
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +18 -11
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +126 -0
- package/templates/apps/api/src/routes/content/get-page.ts +34 -119
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
- package/templates/apps/api/src/routes/content/search.ts +16 -45
- package/templates/apps/api/src/routes/leads/index.ts +364 -298
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +2 -0
- package/templates/apps/api/src/server.ts +25 -0
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +10 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +28 -0
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +6 -0
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
- package/templates/apps/api/src/services/abandon-email.ts +28 -5
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +2 -7
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +53 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +107 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +4 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +4 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +10 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +23 -5
- package/templates/apps/api/src/services/offerings/connection.ts +83 -3
- package/templates/apps/api/src/services/offerings/index.ts +6 -6
- package/templates/apps/api/src/services/person.ts +23 -0
- package/templates/apps/api/src/services/project-listings.ts +43 -31
- package/templates/apps/mcp/CLAUDE.md +11 -5
- package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +91 -10
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
- package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
- package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
- package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
- package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
- package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
- package/templates/apps/mcp/package.json +1 -0
- package/templates/apps/mcp/src/abuse/index.ts +18 -11
- package/templates/apps/mcp/src/http-transport.ts +47 -9
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
- package/templates/apps/mcp/src/lib/site-config.ts +53 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
- package/templates/apps/mcp/src/tools/index.ts +22 -21
- package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
- package/templates/apps/mcp/src/tools/read_content.ts +46 -40
- package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
- package/templates/apps/mcp/src/tools/search_content.ts +20 -24
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
- package/templates/apps/web/CLAUDE.md +12 -3
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +14 -4
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +21 -4
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +7 -0
- package/templates/apps/web/lib/magnet-routes.ts +3 -1
- package/templates/apps/web/lib/newsletter.actions.ts +9 -6
- package/templates/database/CHANGELOG.md +20 -0
- package/templates/database/__tests__/seed.test.ts +33 -1
- package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +27 -0
- package/templates/tools/lint-architecture.ts +205 -1
- package/versions.json +34 -34
- package/templates/apps/api/src/routes/content/slug.ts +0 -9
|
@@ -2,15 +2,19 @@
|
|
|
2
2
|
* Tests for the search_content MCP tool handler.
|
|
3
3
|
*
|
|
4
4
|
* Acceptance criteria verified here:
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* - ailkSearchContent happy path returns
|
|
10
|
-
* - ailkSearchContent returns empty
|
|
5
|
+
* - contentSearchQuerySchema validates correct inputs (q required, type + limit optional)
|
|
6
|
+
* - contentSearchQuerySchema rejects q longer than 200 chars
|
|
7
|
+
* - contentSearchQuerySchema rejects limit > 50
|
|
8
|
+
* - contentSearchQuerySchema rejects empty q
|
|
9
|
+
* - ailkSearchContent happy path returns the route's ItemList with items of the correct shape
|
|
10
|
+
* - ailkSearchContent returns an empty ItemList for a query with no matches
|
|
11
11
|
* - Malformed input causes Zod rejection
|
|
12
|
-
* - #5831:
|
|
12
|
+
* - #5831: contentSearchQuerySchema accepts a registry category slug and
|
|
13
13
|
* rejects one outside the registry; the handler forwards it to the adapter
|
|
14
|
+
*
|
|
15
|
+
* #7167 (the operator's decision of 2026-10-19): the tool adopted its route's
|
|
16
|
+
* arguments (`q`, not `query`) and result (the `ItemList`, not `{ results }`),
|
|
17
|
+
* so the cases below read the new names.
|
|
14
18
|
*/
|
|
15
19
|
|
|
16
20
|
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -19,102 +23,108 @@ import { join, resolve } from "node:path";
|
|
|
19
23
|
|
|
20
24
|
import {
|
|
21
25
|
ailkSearchContent,
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
contentSearchQuerySchema,
|
|
27
|
+
contentSearchResultSchema,
|
|
24
28
|
} from "../src/tools/search_content.js";
|
|
25
29
|
|
|
26
30
|
const REPO_ROOT = resolve(__dirname, "../../..");
|
|
27
31
|
const CONTENT_ROOT = `${REPO_ROOT}/content`;
|
|
28
32
|
|
|
29
|
-
describe("
|
|
30
|
-
it("accepts a valid
|
|
31
|
-
const result =
|
|
33
|
+
describe("contentSearchQuerySchema", () => {
|
|
34
|
+
it("accepts a valid q", () => {
|
|
35
|
+
const result = contentSearchQuerySchema.safeParse({ q: "hello" });
|
|
32
36
|
expect(result.success).toBe(true);
|
|
33
37
|
});
|
|
34
38
|
|
|
35
|
-
it("accepts
|
|
36
|
-
const result =
|
|
37
|
-
|
|
39
|
+
it("accepts q with optional type filter", () => {
|
|
40
|
+
const result = contentSearchQuerySchema.safeParse({
|
|
41
|
+
q: "hello",
|
|
38
42
|
type: "HomePage",
|
|
39
43
|
});
|
|
40
44
|
expect(result.success).toBe(true);
|
|
41
45
|
});
|
|
42
46
|
|
|
43
|
-
it("accepts
|
|
44
|
-
const result =
|
|
45
|
-
|
|
47
|
+
it("accepts q with optional limit", () => {
|
|
48
|
+
const result = contentSearchQuerySchema.safeParse({
|
|
49
|
+
q: "hello",
|
|
46
50
|
limit: 10,
|
|
47
51
|
});
|
|
48
52
|
expect(result.success).toBe(true);
|
|
49
53
|
});
|
|
50
54
|
|
|
51
|
-
it("rejects an empty
|
|
52
|
-
const result =
|
|
55
|
+
it("rejects an empty q", () => {
|
|
56
|
+
const result = contentSearchQuerySchema.safeParse({ q: "" });
|
|
53
57
|
expect(result.success).toBe(false);
|
|
54
58
|
});
|
|
55
59
|
|
|
56
|
-
it("rejects a
|
|
57
|
-
const result =
|
|
58
|
-
|
|
60
|
+
it("rejects a q longer than 200 chars", () => {
|
|
61
|
+
const result = contentSearchQuerySchema.safeParse({
|
|
62
|
+
q: "a".repeat(201),
|
|
59
63
|
});
|
|
60
64
|
expect(result.success).toBe(false);
|
|
61
65
|
});
|
|
62
66
|
|
|
63
|
-
it("accepts a
|
|
64
|
-
const result =
|
|
65
|
-
|
|
67
|
+
it("accepts a q exactly 200 chars", () => {
|
|
68
|
+
const result = contentSearchQuerySchema.safeParse({
|
|
69
|
+
q: "a".repeat(200),
|
|
66
70
|
});
|
|
67
71
|
expect(result.success).toBe(true);
|
|
68
72
|
});
|
|
69
73
|
|
|
70
74
|
it("rejects limit of 0", () => {
|
|
71
|
-
const result =
|
|
72
|
-
|
|
75
|
+
const result = contentSearchQuerySchema.safeParse({
|
|
76
|
+
q: "hello",
|
|
73
77
|
limit: 0,
|
|
74
78
|
});
|
|
75
79
|
expect(result.success).toBe(false);
|
|
76
80
|
});
|
|
77
81
|
|
|
78
82
|
it("rejects limit > 50", () => {
|
|
79
|
-
const result =
|
|
80
|
-
|
|
83
|
+
const result = contentSearchQuerySchema.safeParse({
|
|
84
|
+
q: "hello",
|
|
81
85
|
limit: 51,
|
|
82
86
|
});
|
|
83
87
|
expect(result.success).toBe(false);
|
|
84
88
|
});
|
|
85
89
|
|
|
86
90
|
it("accepts limit of 50", () => {
|
|
87
|
-
const result =
|
|
88
|
-
|
|
91
|
+
const result = contentSearchQuerySchema.safeParse({
|
|
92
|
+
q: "hello",
|
|
89
93
|
limit: 50,
|
|
90
94
|
});
|
|
91
95
|
expect(result.success).toBe(true);
|
|
92
96
|
});
|
|
93
97
|
|
|
98
|
+
it("coerces a string limit, as the route's query string parses (#7167 DD1)", () => {
|
|
99
|
+
const result = contentSearchQuerySchema.safeParse({ q: "hello", limit: "5" });
|
|
100
|
+
expect(result.success).toBe(true);
|
|
101
|
+
expect(result.data!.limit).toBe(5);
|
|
102
|
+
});
|
|
103
|
+
|
|
94
104
|
it("rejects an unknown type string", () => {
|
|
95
|
-
const result =
|
|
96
|
-
|
|
105
|
+
const result = contentSearchQuerySchema.safeParse({
|
|
106
|
+
q: "hello",
|
|
97
107
|
type: "NotAType",
|
|
98
108
|
});
|
|
99
109
|
expect(result.success).toBe(false);
|
|
100
110
|
});
|
|
101
111
|
|
|
102
|
-
it("rejects missing
|
|
103
|
-
const result =
|
|
112
|
+
it("rejects missing q (malformed input)", () => {
|
|
113
|
+
const result = contentSearchQuerySchema.safeParse({});
|
|
104
114
|
expect(result.success).toBe(false);
|
|
105
115
|
});
|
|
106
116
|
|
|
107
117
|
it("accepts a category slug from the registry (#5831)", () => {
|
|
108
|
-
const result =
|
|
109
|
-
|
|
118
|
+
const result = contentSearchQuerySchema.safeParse({
|
|
119
|
+
q: "roof",
|
|
110
120
|
category: "roofing",
|
|
111
121
|
});
|
|
112
122
|
expect(result.success).toBe(true);
|
|
113
123
|
});
|
|
114
124
|
|
|
115
125
|
it("rejects a category slug outside the registry with an enum error (#5831)", () => {
|
|
116
|
-
const result =
|
|
117
|
-
|
|
126
|
+
const result = contentSearchQuerySchema.safeParse({
|
|
127
|
+
q: "roof",
|
|
118
128
|
category: "not-a-real-category",
|
|
119
129
|
});
|
|
120
130
|
expect(result.success).toBe(false);
|
|
@@ -125,8 +135,8 @@ describe("searchContentInputSchema", () => {
|
|
|
125
135
|
});
|
|
126
136
|
|
|
127
137
|
it("rejects a GROUP slug — groups are derived, never tagged (#5831)", () => {
|
|
128
|
-
const result =
|
|
129
|
-
|
|
138
|
+
const result = contentSearchQuerySchema.safeParse({
|
|
139
|
+
q: "roof",
|
|
130
140
|
category: "professional-services",
|
|
131
141
|
});
|
|
132
142
|
expect(result.success).toBe(false);
|
|
@@ -182,15 +192,15 @@ describe("ailkSearchContent — category filter (#5831)", () => {
|
|
|
182
192
|
|
|
183
193
|
it("forwards category to the adapter — only the tagged page comes back", async () => {
|
|
184
194
|
const result = await ailkSearchContent(
|
|
185
|
-
{
|
|
195
|
+
{ q: "roof", category: "roofing" },
|
|
186
196
|
root,
|
|
187
197
|
);
|
|
188
|
-
expect(result.
|
|
198
|
+
expect(result.items.map((r) => r.slug)).toEqual(["roof-replacement"]);
|
|
189
199
|
});
|
|
190
200
|
|
|
191
201
|
it("returns every match when no category is passed", async () => {
|
|
192
|
-
const result = await ailkSearchContent({
|
|
193
|
-
expect(result.
|
|
202
|
+
const result = await ailkSearchContent({ q: "roof" }, root);
|
|
203
|
+
expect(result.items.map((r) => r.slug).sort()).toEqual([
|
|
194
204
|
"attic-ventilation",
|
|
195
205
|
"roof-replacement",
|
|
196
206
|
]);
|
|
@@ -198,22 +208,26 @@ describe("ailkSearchContent — category filter (#5831)", () => {
|
|
|
198
208
|
|
|
199
209
|
it("returns [] for a registry slug nothing is tagged with", async () => {
|
|
200
210
|
const result = await ailkSearchContent(
|
|
201
|
-
{
|
|
211
|
+
{ q: "roof", category: "legal" },
|
|
202
212
|
root,
|
|
203
213
|
);
|
|
204
|
-
expect(result.
|
|
214
|
+
expect(result.items).toEqual([]);
|
|
205
215
|
});
|
|
206
216
|
});
|
|
207
217
|
|
|
208
218
|
describe("ailkSearchContent handler", () => {
|
|
209
|
-
it("returns
|
|
210
|
-
const result = await ailkSearchContent({
|
|
211
|
-
expect(
|
|
219
|
+
it("returns the route's ItemList on a known query", async () => {
|
|
220
|
+
const result = await ailkSearchContent({ q: "launch" }, CONTENT_ROOT);
|
|
221
|
+
expect(result["@context"]).toBe("https://schema.org");
|
|
222
|
+
expect(result["@type"]).toBe("ItemList");
|
|
223
|
+
expect(result.query).toBe("launch");
|
|
224
|
+
expect(Array.isArray(result.items)).toBe(true);
|
|
225
|
+
expect(result.totalItems).toBe(result.items.length);
|
|
212
226
|
});
|
|
213
227
|
|
|
214
228
|
it("result items have required shape fields", async () => {
|
|
215
|
-
const result = await ailkSearchContent({
|
|
216
|
-
for (const item of result.
|
|
229
|
+
const result = await ailkSearchContent({ q: "launch" }, CONTENT_ROOT);
|
|
230
|
+
for (const item of result.items) {
|
|
217
231
|
expect(typeof item.slug).toBe("string");
|
|
218
232
|
expect(typeof item.type).toBe("string");
|
|
219
233
|
expect(typeof item.title).toBe("string");
|
|
@@ -222,25 +236,26 @@ describe("ailkSearchContent handler", () => {
|
|
|
222
236
|
}
|
|
223
237
|
});
|
|
224
238
|
|
|
225
|
-
it("returns empty
|
|
239
|
+
it("returns an empty ItemList for a query that matches nothing", async () => {
|
|
226
240
|
const result = await ailkSearchContent(
|
|
227
|
-
{
|
|
241
|
+
{ q: "xqzxqzxqzxqz_no_match_ever" },
|
|
228
242
|
CONTENT_ROOT,
|
|
229
243
|
);
|
|
230
|
-
expect(result.
|
|
244
|
+
expect(result.items).toEqual([]);
|
|
245
|
+
expect(result.totalItems).toBe(0);
|
|
231
246
|
});
|
|
232
247
|
|
|
233
248
|
it("respects limit option", async () => {
|
|
234
249
|
const result = await ailkSearchContent(
|
|
235
|
-
{
|
|
250
|
+
{ q: "a", limit: 1 },
|
|
236
251
|
CONTENT_ROOT,
|
|
237
252
|
);
|
|
238
|
-
expect(result.
|
|
253
|
+
expect(result.items.length).toBeLessThanOrEqual(1);
|
|
239
254
|
});
|
|
240
255
|
|
|
241
|
-
it("output validates against
|
|
242
|
-
const result = await ailkSearchContent({
|
|
243
|
-
const parsed =
|
|
256
|
+
it("output validates against contentSearchResultSchema", async () => {
|
|
257
|
+
const result = await ailkSearchContent({ q: "launch" }, CONTENT_ROOT);
|
|
258
|
+
const parsed = contentSearchResultSchema.safeParse(result);
|
|
244
259
|
expect(parsed.success).toBe(true);
|
|
245
260
|
});
|
|
246
261
|
});
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for the
|
|
2
|
+
* Tests for the capture_lead MCP tool handler.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* The tool takes the body `POST /v1/leads/submit` takes and returns the
|
|
5
|
+
* route's 201 body (#7171 AC-11 — the tool adopted the API). Verified here:
|
|
5
6
|
* 1. Malformed input → { ok: false, error: { kind: 'invalid_input', issues } }, api-client never called
|
|
6
|
-
* 2. Happy path →
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* 2. Happy path → posts exactly the parsed body to apiClient.leads.submit —
|
|
8
|
+
* no `siteId`, whatever AILK_SITE_ID says, and no stamped `source` — and
|
|
9
|
+
* returns the route's body unchanged, `email` included
|
|
10
|
+
* 3. API 400 / 503 / 429 → { ok: false, error: { kind: 'api_error', status } }
|
|
11
|
+
* 4. #7397 — the spoke marker `payload.site` from AILK_LEAD_SITE, spread last
|
|
12
|
+
* 5. #8073 — forwards the trusted caller address + AILK_VISITOR_FORWARD_KEY
|
|
11
13
|
* as the two visitor-forwarding headers only when both are present
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
LEAD_SITE_MARKER_KEY,
|
|
18
|
+
leadSubmitBodySchema,
|
|
19
|
+
} from "@working-theory/validation";
|
|
15
20
|
import { ailkCaptureLead } from "../src/tools/capture_lead.js";
|
|
16
21
|
import { trustedCallerAddress } from "../src/request-context.js";
|
|
17
22
|
|
|
@@ -30,222 +35,141 @@ jest.mock("@working-theory/api-client", () => {
|
|
|
30
35
|
};
|
|
31
36
|
});
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
|
|
34
39
|
const { apiClient } = require("@working-theory/api-client") as {
|
|
35
40
|
apiClient: { leads: { submit: jest.Mock } };
|
|
36
41
|
};
|
|
37
42
|
|
|
43
|
+
/** The route body, as an agent sends it. */
|
|
38
44
|
const VALID_INPUT = {
|
|
39
|
-
|
|
45
|
+
source: "mcp",
|
|
40
46
|
email: "jane@example.com",
|
|
41
|
-
message: "I am interested in your services.",
|
|
47
|
+
payload: { name: "Jane Smith", message: "I am interested in your services." },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** The route's 201 body, as the api-client hands it back. */
|
|
51
|
+
const WIRE = {
|
|
52
|
+
id: "lead_00000000-0000-7000-8000-0000000000d1",
|
|
53
|
+
persisted: "db",
|
|
54
|
+
email: { ok: true, messageId: "msg-1" },
|
|
42
55
|
};
|
|
43
56
|
|
|
44
57
|
beforeEach(() => {
|
|
45
58
|
jest.clearAllMocks();
|
|
46
59
|
// The exact-body assertions below assume no spoke marker (#7397).
|
|
47
60
|
delete process.env.AILK_LEAD_SITE;
|
|
61
|
+
delete process.env.AILK_SITE_ID;
|
|
48
62
|
});
|
|
49
63
|
|
|
50
64
|
describe("ailkCaptureLead — input validation", () => {
|
|
51
|
-
it("rejects malformed input (
|
|
65
|
+
it("rejects malformed input (no source) without calling api-client", async () => {
|
|
52
66
|
const result = await ailkCaptureLead({ email: "not-an-email" } as never);
|
|
53
67
|
|
|
54
|
-
expect(result
|
|
55
|
-
if (!result.ok) {
|
|
56
|
-
expect(result.error.
|
|
57
|
-
if (result.error.kind === "invalid_input") {
|
|
58
|
-
expect(Array.isArray(result.error.issues)).toBe(true);
|
|
59
|
-
expect(result.error.issues.length).toBeGreaterThan(0);
|
|
60
|
-
}
|
|
68
|
+
expect(result).toMatchObject({ ok: false, error: { kind: "invalid_input" } });
|
|
69
|
+
if (!result.ok && result.error.kind === "invalid_input") {
|
|
70
|
+
expect(result.error.issues.length).toBeGreaterThan(0);
|
|
61
71
|
}
|
|
62
72
|
expect(apiClient.leads.submit).not.toHaveBeenCalled();
|
|
63
73
|
});
|
|
64
|
-
});
|
|
65
74
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
ok: true,
|
|
70
|
-
body: { id: "lead_00000000-0000-7000-8000-0000000000d1", persisted: "db" },
|
|
71
|
-
});
|
|
75
|
+
it("admits the StepFlow envelope — email inside payload, none at the top level — as the route does", async () => {
|
|
76
|
+
apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
|
|
77
|
+
const envelope = { source: "step-flow", payload: { email: "env@example.com" } };
|
|
72
78
|
|
|
73
|
-
|
|
79
|
+
await ailkCaptureLead(envelope);
|
|
74
80
|
|
|
75
|
-
expect(apiClient.leads.submit).toHaveBeenCalledWith(
|
|
76
|
-
siteId: "default",
|
|
77
|
-
source: "mcp",
|
|
78
|
-
email: "jane@example.com",
|
|
79
|
-
payload: { name: "Jane Smith", message: "I am interested in your services." },
|
|
80
|
-
});
|
|
81
|
-
expect(result.ok).toBe(true);
|
|
82
|
-
if (result.ok) {
|
|
83
|
-
expect(result.id).toBe("lead_00000000-0000-7000-8000-0000000000d1");
|
|
84
|
-
expect(result.persisted).toBe("db");
|
|
85
|
-
}
|
|
81
|
+
expect(apiClient.leads.submit).toHaveBeenCalledWith(envelope);
|
|
86
82
|
});
|
|
87
83
|
});
|
|
88
84
|
|
|
89
|
-
describe("ailkCaptureLead —
|
|
90
|
-
it("
|
|
91
|
-
apiClient.leads.submit.mockResolvedValueOnce({
|
|
92
|
-
ok: false,
|
|
93
|
-
status: 400,
|
|
94
|
-
body: { error: "bad_request" },
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
const result = await ailkCaptureLead(VALID_INPUT);
|
|
98
|
-
|
|
99
|
-
expect(result.ok).toBe(false);
|
|
100
|
-
if (!result.ok) {
|
|
101
|
-
expect(result.error.kind).toBe("api_error");
|
|
102
|
-
if (result.error.kind === "api_error") {
|
|
103
|
-
expect(result.error.status).toBe(400);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("maps API 503 to { ok: false, error: { kind: 'api_error', status: 503 } }", async () => {
|
|
109
|
-
apiClient.leads.submit.mockResolvedValueOnce({
|
|
110
|
-
ok: false,
|
|
111
|
-
status: 503,
|
|
112
|
-
body: { error: "service_unavailable" },
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
const result = await ailkCaptureLead(VALID_INPUT);
|
|
116
|
-
|
|
117
|
-
expect(result.ok).toBe(false);
|
|
118
|
-
if (!result.ok) {
|
|
119
|
-
expect(result.error.kind).toBe("api_error");
|
|
120
|
-
if (result.error.kind === "api_error") {
|
|
121
|
-
expect(result.error.status).toBe(503);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("maps API 429 to { ok: false, error: { kind: 'api_error', status: 429 } }", async () => {
|
|
127
|
-
apiClient.leads.submit.mockResolvedValueOnce({
|
|
128
|
-
ok: false,
|
|
129
|
-
status: 429,
|
|
130
|
-
body: { error: "rate_limited" },
|
|
131
|
-
});
|
|
85
|
+
describe("ailkCaptureLead — happy path", () => {
|
|
86
|
+
it("posts exactly the parsed body and returns the route's body unchanged, email included", async () => {
|
|
87
|
+
apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
|
|
132
88
|
|
|
133
89
|
const result = await ailkCaptureLead(VALID_INPUT);
|
|
134
90
|
|
|
135
|
-
expect(
|
|
136
|
-
|
|
137
|
-
expect(result.error.kind).toBe("api_error");
|
|
138
|
-
if (result.error.kind === "api_error") {
|
|
139
|
-
expect(result.error.status).toBe(429);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
91
|
+
expect(apiClient.leads.submit).toHaveBeenCalledWith(VALID_INPUT);
|
|
92
|
+
expect(result).toEqual(WIRE);
|
|
142
93
|
});
|
|
143
|
-
});
|
|
144
94
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
apiClient.leads.submit.mockResolvedValueOnce({
|
|
149
|
-
ok: true,
|
|
150
|
-
body: { id: "lead_00000000-0000-7000-8000-0000000000d2", persisted: "db" },
|
|
151
|
-
});
|
|
95
|
+
it("never sends a siteId, whatever AILK_SITE_ID says, and strips one the agent sent", async () => {
|
|
96
|
+
process.env.AILK_SITE_ID = "site-abc";
|
|
97
|
+
apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
|
|
152
98
|
|
|
153
|
-
await ailkCaptureLead(VALID_INPUT);
|
|
99
|
+
await ailkCaptureLead({ ...VALID_INPUT, siteId: "site-xyz" } as never);
|
|
154
100
|
|
|
155
|
-
|
|
156
|
-
expect(call.siteId).toBe("default");
|
|
101
|
+
expect(apiClient.leads.submit.mock.calls[0][0]).toEqual(VALID_INPUT);
|
|
157
102
|
});
|
|
158
103
|
});
|
|
159
104
|
|
|
160
|
-
describe("ailkCaptureLead —
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
expect(call.siteId).toBe("site-abc");
|
|
176
|
-
});
|
|
105
|
+
describe("ailkCaptureLead — API error mapping", () => {
|
|
106
|
+
it.each([400, 503, 429])(
|
|
107
|
+
"maps API %i to { ok: false, error: { kind: 'api_error', status } }",
|
|
108
|
+
async (status) => {
|
|
109
|
+
apiClient.leads.submit.mockResolvedValueOnce({
|
|
110
|
+
ok: false,
|
|
111
|
+
status,
|
|
112
|
+
body: { error: "refused" },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const result = await ailkCaptureLead(VALID_INPUT);
|
|
116
|
+
|
|
117
|
+
expect(result).toEqual({ ok: false, error: { kind: "api_error", status } });
|
|
118
|
+
},
|
|
119
|
+
);
|
|
177
120
|
});
|
|
178
121
|
|
|
179
122
|
// #7397 — a spoke of a hub-and-spokes deployment is marked in the lead
|
|
180
|
-
// PAYLOAD (`payload.site`)
|
|
181
|
-
//
|
|
182
|
-
//
|
|
123
|
+
// PAYLOAD (`payload.site`). The marker comes from the MCP server's own env,
|
|
124
|
+
// so an MCP-origin lead reaches the hub inbox attributed; it is the one key
|
|
125
|
+
// the tool adds to the body the agent sent.
|
|
183
126
|
describe("ailkCaptureLead — payload site marker (#7397)", () => {
|
|
184
|
-
afterEach(() => {
|
|
185
|
-
delete process.env.AILK_LEAD_SITE;
|
|
186
|
-
});
|
|
187
|
-
|
|
188
127
|
it("names the marker key 'site', the key the issue's hub-and-spokes design names", () => {
|
|
189
128
|
expect(LEAD_SITE_MARKER_KEY).toBe("site");
|
|
190
129
|
});
|
|
191
130
|
|
|
192
|
-
it("
|
|
131
|
+
it("spreads payload.site from AILK_LEAD_SITE LAST, over an agent's own value", async () => {
|
|
193
132
|
process.env.AILK_LEAD_SITE = "spoke-a.example";
|
|
194
|
-
apiClient.leads.submit.mockResolvedValueOnce({
|
|
195
|
-
ok: true,
|
|
196
|
-
body: { id: "lead_00000000-0000-7000-8000-0000000000d4", persisted: "db" },
|
|
197
|
-
});
|
|
133
|
+
apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
|
|
198
134
|
|
|
199
|
-
await ailkCaptureLead(
|
|
135
|
+
await ailkCaptureLead({
|
|
136
|
+
...VALID_INPUT,
|
|
137
|
+
payload: { ...VALID_INPUT.payload, site: "agent-said" },
|
|
138
|
+
});
|
|
200
139
|
|
|
201
140
|
expect(apiClient.leads.submit).toHaveBeenCalledWith({
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
email: "jane@example.com",
|
|
205
|
-
payload: {
|
|
206
|
-
name: "Jane Smith",
|
|
207
|
-
message: "I am interested in your services.",
|
|
208
|
-
site: "spoke-a.example",
|
|
209
|
-
},
|
|
141
|
+
...VALID_INPUT,
|
|
142
|
+
payload: { ...VALID_INPUT.payload, site: "spoke-a.example" },
|
|
210
143
|
});
|
|
211
144
|
});
|
|
212
145
|
|
|
213
|
-
it("
|
|
146
|
+
it("marks a body the agent sent without a payload", async () => {
|
|
214
147
|
process.env.AILK_LEAD_SITE = "spoke-a.example";
|
|
215
|
-
apiClient.leads.submit.mockResolvedValueOnce({
|
|
216
|
-
ok: true,
|
|
217
|
-
body: { id: "lead_00000000-0000-7000-8000-0000000000d5", persisted: "db" },
|
|
218
|
-
});
|
|
148
|
+
apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
|
|
219
149
|
|
|
220
|
-
await ailkCaptureLead(
|
|
150
|
+
await ailkCaptureLead({ source: "mcp", email: "jane@example.com" });
|
|
221
151
|
|
|
222
152
|
const body = apiClient.leads.submit.mock.calls[0][0] as Record<string, unknown>;
|
|
223
|
-
expect(
|
|
153
|
+
expect(body["payload"]).toEqual({ site: "spoke-a.example" });
|
|
154
|
+
expect(leadSubmitBodySchema.safeParse(body).success).toBe(true);
|
|
224
155
|
});
|
|
225
156
|
|
|
226
157
|
it.each([
|
|
227
158
|
["unset", undefined],
|
|
228
159
|
["empty", ""],
|
|
229
160
|
["whitespace only", " "],
|
|
230
|
-
])("
|
|
161
|
+
])("sends the payload as it came when AILK_LEAD_SITE is %s", async (_label, value) => {
|
|
231
162
|
if (value !== undefined) process.env.AILK_LEAD_SITE = value;
|
|
232
|
-
apiClient.leads.submit.mockResolvedValueOnce({
|
|
233
|
-
ok: true,
|
|
234
|
-
body: { id: "lead_00000000-0000-7000-8000-0000000000d6", persisted: "db" },
|
|
235
|
-
});
|
|
163
|
+
apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
|
|
236
164
|
|
|
237
165
|
await ailkCaptureLead(VALID_INPUT);
|
|
238
166
|
|
|
239
|
-
|
|
240
|
-
expect(call.payload).not.toHaveProperty("site");
|
|
167
|
+
expect(apiClient.leads.submit.mock.calls[0][0]).toEqual(VALID_INPUT);
|
|
241
168
|
});
|
|
242
169
|
|
|
243
170
|
it("trims surrounding whitespace from AILK_LEAD_SITE", async () => {
|
|
244
171
|
process.env.AILK_LEAD_SITE = " spoke-a.example\n";
|
|
245
|
-
apiClient.leads.submit.mockResolvedValueOnce({
|
|
246
|
-
ok: true,
|
|
247
|
-
body: { id: "lead_00000000-0000-7000-8000-0000000000d7", persisted: "db" },
|
|
248
|
-
});
|
|
172
|
+
apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
|
|
249
173
|
|
|
250
174
|
await ailkCaptureLead(VALID_INPUT);
|
|
251
175
|
|
|
@@ -260,10 +184,7 @@ describe("ailkCaptureLead — visitor-forwarding headers (#8073)", () => {
|
|
|
260
184
|
});
|
|
261
185
|
|
|
262
186
|
it("calls api-client with exactly one argument when there is no trusted address", async () => {
|
|
263
|
-
apiClient.leads.submit.mockResolvedValueOnce({
|
|
264
|
-
ok: true,
|
|
265
|
-
body: { id: "lead_00000000-0000-7000-8000-0000000000e1", persisted: "db" },
|
|
266
|
-
});
|
|
187
|
+
apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
|
|
267
188
|
process.env.AILK_VISITOR_FORWARD_KEY = "spoke-key";
|
|
268
189
|
|
|
269
190
|
await ailkCaptureLead(VALID_INPUT);
|
|
@@ -272,10 +193,7 @@ describe("ailkCaptureLead — visitor-forwarding headers (#8073)", () => {
|
|
|
272
193
|
});
|
|
273
194
|
|
|
274
195
|
it("forwards the trusted address and the forward key as the two visitor headers", async () => {
|
|
275
|
-
apiClient.leads.submit.mockResolvedValueOnce({
|
|
276
|
-
ok: true,
|
|
277
|
-
body: { id: "lead_00000000-0000-7000-8000-0000000000e2", persisted: "db" },
|
|
278
|
-
});
|
|
196
|
+
apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
|
|
279
197
|
process.env.AILK_VISITOR_FORWARD_KEY = "spoke-key";
|
|
280
198
|
|
|
281
199
|
await trustedCallerAddress.run("203.0.113.9", () =>
|
|
@@ -292,10 +210,7 @@ describe("ailkCaptureLead — visitor-forwarding headers (#8073)", () => {
|
|
|
292
210
|
});
|
|
293
211
|
|
|
294
212
|
it("sends no headers when there is a trusted address but no forward key", async () => {
|
|
295
|
-
apiClient.leads.submit.mockResolvedValueOnce({
|
|
296
|
-
ok: true,
|
|
297
|
-
body: { id: "lead_00000000-0000-7000-8000-0000000000e3", persisted: "db" },
|
|
298
|
-
});
|
|
213
|
+
apiClient.leads.submit.mockResolvedValueOnce({ ok: true, body: WIRE });
|
|
299
214
|
delete process.env.AILK_VISITOR_FORWARD_KEY;
|
|
300
215
|
|
|
301
216
|
await trustedCallerAddress.run("203.0.113.9", () =>
|