create-ailk 0.27.0 → 0.28.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 +84 -7
- package/dist/add-module-core.js +2 -1
- package/dist/add-module.d.ts +2 -1
- package/dist/add-module.js +9 -4
- package/dist/add-route.js +46 -19
- package/dist/body-heading.d.ts +56 -0
- package/dist/body-heading.js +394 -0
- package/dist/cli.js +28 -1
- package/dist/content-payloads.js +2 -2
- package/dist/derive-architecture.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/lib/auth-secret-file.d.ts +6 -2
- package/dist/lib/auth-secret-file.js +46 -20
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +23 -14
- package/dist/paid-sections.d.ts +17 -0
- package/dist/paid-sections.js +39 -0
- package/dist/programmatic.d.ts +16 -0
- package/dist/programmatic.js +73 -4
- package/dist/remove-module.d.ts +98 -0
- package/dist/remove-module.js +620 -0
- package/dist/seed-flows.d.ts +2 -2
- package/dist/seed-flows.js +16 -9
- package/dist/site-roster.js +4 -1
- package/dist/starter-grid.d.ts +26 -0
- package/dist/starter-grid.js +34 -0
- package/dist/surfaces.d.ts +26 -0
- package/dist/surfaces.js +105 -9
- package/dist/sync-routes.d.ts +7 -0
- package/dist/sync-routes.js +53 -6
- package/dist/template-pages.d.ts +36 -0
- package/dist/template-pages.js +129 -0
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +3 -1
- package/dist/update.js +38 -4
- package/package.json +3 -3
- package/release-scope.json +2 -1
- package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
- package/templates/.env.example +5 -2
- package/templates/apps/api/.env.example +9 -5
- package/templates/apps/api/CLAUDE.md +14 -9
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
- package/templates/apps/api/src/vercel-handler.ts +8 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +25 -11
- package/templates/apps/web/CLAUDE.md +45 -4
- package/templates/apps/web/__tests__/setup.ts +7 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
- package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
- package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
- package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/layout.tsx +157 -139
- package/templates/apps/web/app/[locale]/page.tsx +51 -3
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
- package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
- package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
- package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
- package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
- package/templates/apps/web/app/llms.txt/route.ts +41 -3
- package/templates/apps/web/app/robots.txt/route.ts +5 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
- package/templates/apps/web/auth.ts +102 -50
- package/templates/apps/web/components/signed-in-nav.tsx +8 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
- package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
- package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
- package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
- package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
- package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
- package/templates/apps/web/lib/auth-secret.ts +57 -0
- package/templates/apps/web/lib/bot-check.ts +138 -0
- package/templates/apps/web/lib/consent-visitor.ts +81 -7
- package/templates/apps/web/lib/deck-page.ts +6 -3
- package/templates/apps/web/lib/docs-index.ts +100 -0
- package/templates/apps/web/lib/docs-path.ts +94 -0
- package/templates/apps/web/lib/hub-children.tsx +318 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/nav-label-check.ts +61 -0
- package/templates/apps/web/lib/nav-label-keys.ts +78 -0
- package/templates/apps/web/lib/newsletter.actions.ts +31 -6
- package/templates/apps/web/lib/offerings-pricing.ts +135 -6
- package/templates/apps/web/lib/page-chrome.ts +367 -0
- package/templates/apps/web/lib/posting-title.ts +26 -4
- package/templates/apps/web/lib/pricing-page.ts +430 -0
- package/templates/apps/web/lib/relay.ts +31 -106
- package/templates/apps/web/lib/route-family.ts +69 -0
- package/templates/apps/web/lib/service-listing.ts +89 -0
- package/templates/apps/web/lib/services-path.ts +97 -0
- package/templates/apps/web/lib/site-header.ts +94 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
- package/templates/apps/web/lib/template-listing.ts +169 -0
- package/templates/apps/web/messages/en.json +86 -30
- package/templates/apps/web/middleware.ts +171 -11
- package/templates/apps/web/next.config.mjs +211 -2
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
- package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
- package/templates/apps/web/sentry.server.config.ts +30 -2
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
- package/templates/apps/web/src/lib/rate-limit.ts +68 -27
- package/templates/apps/web/src/lib/routes.ts +15 -1
- package/versions.json +48 -48
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for "Email me this plan" — `saveRecommendationAction` (#4842 D7),
|
|
3
|
+
* split out of `checkout.actions.ts` with its cases by #6786 so the save half
|
|
4
|
+
* ships with the results page while checkout stays commerce's.
|
|
5
|
+
*
|
|
6
|
+
* AC-4 (the action half) — calls `flows.save` once with
|
|
7
|
+
* `{ answers, email, locale }` and returns the lead id.
|
|
8
|
+
* D7 — the shared status → kind map (`email-moment.ts`), never a throw;
|
|
9
|
+
* an invalid body → `invalid_input` without calling the api-client.
|
|
10
|
+
*
|
|
11
|
+
* Mock shape: `checkout.actions.test.ts`'s (`@working-theory/api-client` +
|
|
12
|
+
* `next/headers`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { saveRecommendationAction } from "../save.actions.js";
|
|
16
|
+
|
|
17
|
+
// #9255 — the write bot check's verdict. `checkBotId` reads Vercel's
|
|
18
|
+
// request context, which jest has none of, so every case decides it here.
|
|
19
|
+
const checkBotIdMock = jest.fn();
|
|
20
|
+
jest.mock("botid/server", () => ({
|
|
21
|
+
checkBotId: (...args: unknown[]) => checkBotIdMock(...args),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
25
|
+
flows: {
|
|
26
|
+
save: jest.fn(),
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
jest.mock("next/headers", () => ({
|
|
31
|
+
__esModule: true,
|
|
32
|
+
headers: jest.fn().mockResolvedValue({ get: () => null }),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
import { flows } from "@working-theory/api-client";
|
|
36
|
+
import { headers } from "next/headers";
|
|
37
|
+
|
|
38
|
+
const mockSave = flows.save as jest.Mock;
|
|
39
|
+
const mockHeaders = headers as jest.Mock;
|
|
40
|
+
|
|
41
|
+
const SLUG = "ailk-recommender";
|
|
42
|
+
const ANSWERS = { team_size: "agency", wants_seo: "yes" };
|
|
43
|
+
const SAVE_BODY = { answers: ANSWERS, email: "jane@example.com", locale: "en" };
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
jest.spyOn(console, "error").mockImplementation(() => undefined);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
jest.clearAllMocks();
|
|
51
|
+
jest.restoreAllMocks();
|
|
52
|
+
mockHeaders.mockResolvedValue({ get: () => null });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("saveRecommendationAction — AC-4 (the action half)", () => {
|
|
56
|
+
it("calls flows.save once with { answers, email, locale } and returns the leadId", async () => {
|
|
57
|
+
mockSave.mockResolvedValueOnce({
|
|
58
|
+
ok: true,
|
|
59
|
+
body: { leadId: "lead_1", sessionId: "fs_1" },
|
|
60
|
+
});
|
|
61
|
+
await expect(saveRecommendationAction(SLUG, SAVE_BODY)).resolves.toEqual({
|
|
62
|
+
ok: true,
|
|
63
|
+
leadId: "lead_1",
|
|
64
|
+
});
|
|
65
|
+
expect(mockSave).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(mockSave).toHaveBeenCalledWith(SLUG, SAVE_BODY);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("maps failures exactly as checkout does and never throws", async () => {
|
|
70
|
+
mockSave.mockResolvedValueOnce({
|
|
71
|
+
ok: false,
|
|
72
|
+
status: 429,
|
|
73
|
+
body: { retryAfterSeconds: 9 },
|
|
74
|
+
});
|
|
75
|
+
await expect(saveRecommendationAction(SLUG, SAVE_BODY)).resolves.toEqual({
|
|
76
|
+
ok: false,
|
|
77
|
+
kind: "rate_limited",
|
|
78
|
+
retryAfterSeconds: 9,
|
|
79
|
+
});
|
|
80
|
+
mockSave.mockRejectedValueOnce(new Error("boom"));
|
|
81
|
+
await expect(saveRecommendationAction(SLUG, SAVE_BODY)).resolves.toEqual({
|
|
82
|
+
ok: false,
|
|
83
|
+
kind: "network",
|
|
84
|
+
});
|
|
85
|
+
await expect(
|
|
86
|
+
saveRecommendationAction(SLUG, { ...SAVE_BODY, email: "nope" }),
|
|
87
|
+
).resolves.toEqual({ ok: false, kind: "invalid_input" });
|
|
88
|
+
expect(mockSave).toHaveBeenCalledTimes(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("forwards the visitor's x-forwarded-for on the outbound call (#4730)", async () => {
|
|
92
|
+
mockHeaders.mockResolvedValueOnce({
|
|
93
|
+
get: (name: string) =>
|
|
94
|
+
name === "x-forwarded-for" ? "203.0.113.7" : null,
|
|
95
|
+
});
|
|
96
|
+
mockSave.mockResolvedValueOnce({
|
|
97
|
+
ok: true,
|
|
98
|
+
body: { leadId: "lead_1", sessionId: "fs_1" },
|
|
99
|
+
});
|
|
100
|
+
await saveRecommendationAction(SLUG, SAVE_BODY);
|
|
101
|
+
expect(mockSave).toHaveBeenCalledWith(SLUG, SAVE_BODY, {
|
|
102
|
+
headers: { "x-forwarded-for": "203.0.113.7" },
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// #9255 D4 — the write bot check runs after the slug and body checks,
|
|
108
|
+
// immediately before the write (spec
|
|
109
|
+
// docs/specs/9255-an-edge-bot-challenge-in-spec.md §5.1 AC-2).
|
|
110
|
+
describe("saveRecommendationAction — the write bot check (#9255)", () => {
|
|
111
|
+
const BOT = {
|
|
112
|
+
isHuman: false,
|
|
113
|
+
isBot: true,
|
|
114
|
+
isVerifiedBot: false,
|
|
115
|
+
bypassed: false,
|
|
116
|
+
};
|
|
117
|
+
const HUMAN = { ...BOT, isHuman: true, isBot: false };
|
|
118
|
+
const BOT_ENV_KEYS = [
|
|
119
|
+
"VERCEL",
|
|
120
|
+
"AILK_BOT_CHECK",
|
|
121
|
+
"AILK_BOOKING_BOT_CHECK",
|
|
122
|
+
] as const;
|
|
123
|
+
const priorBotEnv = new Map(
|
|
124
|
+
BOT_ENV_KEYS.map((name) => [name, process.env[name]]),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
let botLogSpies: jest.SpyInstance[] = [];
|
|
128
|
+
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
checkBotIdMock.mockReset();
|
|
131
|
+
for (const name of BOT_ENV_KEYS) delete process.env[name];
|
|
132
|
+
process.env.VERCEL = "1";
|
|
133
|
+
botLogSpies = [
|
|
134
|
+
jest.spyOn(console, "warn").mockImplementation(() => undefined),
|
|
135
|
+
jest.spyOn(console, "error").mockImplementation(() => undefined),
|
|
136
|
+
];
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
afterEach(() => {
|
|
140
|
+
for (const [name, value] of priorBotEnv) {
|
|
141
|
+
if (value === undefined) delete process.env[name];
|
|
142
|
+
else process.env[name] = value;
|
|
143
|
+
}
|
|
144
|
+
for (const spy of botLogSpies) spy.mockRestore();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it.each([
|
|
148
|
+
["a bot", () => checkBotIdMock.mockResolvedValue(BOT)],
|
|
149
|
+
[
|
|
150
|
+
"a check that fails",
|
|
151
|
+
() => checkBotIdMock.mockRejectedValue(new Error("boom")),
|
|
152
|
+
],
|
|
153
|
+
])(
|
|
154
|
+
"%s gets kind unavailable, and nothing is sent",
|
|
155
|
+
async (_label, decide) => {
|
|
156
|
+
decide();
|
|
157
|
+
expect(await saveRecommendationAction(SLUG, SAVE_BODY)).toStrictEqual({
|
|
158
|
+
ok: false,
|
|
159
|
+
kind: "unavailable",
|
|
160
|
+
});
|
|
161
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
162
|
+
expect(mockSave).not.toHaveBeenCalled();
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
it("a human sends exactly what the check off sends", async () => {
|
|
167
|
+
checkBotIdMock.mockResolvedValue(HUMAN);
|
|
168
|
+
mockSave.mockResolvedValue({
|
|
169
|
+
ok: true,
|
|
170
|
+
status: 201,
|
|
171
|
+
body: { leadId: "lead_1" },
|
|
172
|
+
});
|
|
173
|
+
delete process.env.VERCEL;
|
|
174
|
+
await saveRecommendationAction(SLUG, SAVE_BODY);
|
|
175
|
+
process.env.VERCEL = "1";
|
|
176
|
+
expect(await saveRecommendationAction(SLUG, SAVE_BODY)).toEqual({
|
|
177
|
+
ok: true,
|
|
178
|
+
leadId: "lead_1",
|
|
179
|
+
});
|
|
180
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
181
|
+
expect(mockSave).toHaveBeenCalledTimes(2);
|
|
182
|
+
expect(mockSave.mock.calls[1]).toEqual(mockSave.mock.calls[0]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("an invalid slug answers invalid_input with no check", async () => {
|
|
186
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
187
|
+
expect(await saveRecommendationAction("Not A Slug", SAVE_BODY)).toEqual({
|
|
188
|
+
ok: false,
|
|
189
|
+
kind: "invalid_input",
|
|
190
|
+
});
|
|
191
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* email-moment.ts — the helpers the recommender's two email-moment Server
|
|
4
|
+
* Actions share (#4842 D7): `saveRecommendationAction` (`save.actions.ts`,
|
|
5
|
+
* shipped with the results page) and `startCheckoutAction`
|
|
6
|
+
* (`checkout.actions.ts`, commerce's). Split out of `checkout.actions.ts` by
|
|
7
|
+
* #6786 so the save half ships without the checkout half; a `"use server"`
|
|
8
|
+
* file may export only async functions, so the shared sync helpers live here.
|
|
9
|
+
*
|
|
10
|
+
* Both actions mirror `submitFlowStepAction`'s idiom exactly: the visitor-IP
|
|
11
|
+
* forwarding helper (duplicated here — the `flow.actions.ts` original is
|
|
12
|
+
* module-private and that file is this unit's regression floor), a status →
|
|
13
|
+
* kind map, and NEVER a throw to the client. Logging is PII-safe: slug + kind
|
|
14
|
+
* only — the email is never logged.
|
|
15
|
+
*/
|
|
16
|
+
import type { FlowSaveInput } from "@working-theory/validation";
|
|
17
|
+
import { headers } from "next/headers";
|
|
18
|
+
|
|
19
|
+
export type EmailMomentFailureKind =
|
|
20
|
+
| "invalid_input"
|
|
21
|
+
| "not_configured"
|
|
22
|
+
| "rate_limited"
|
|
23
|
+
| "unavailable"
|
|
24
|
+
| "network";
|
|
25
|
+
|
|
26
|
+
export interface EmailMomentFailure {
|
|
27
|
+
ok: false;
|
|
28
|
+
kind: EmailMomentFailureKind;
|
|
29
|
+
retryAfterSeconds?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Reads the visitor's real IP from THIS action's own incoming request —
|
|
34
|
+
* never from the browser-supplied payload — preferring `x-forwarded-for`
|
|
35
|
+
* and falling back to `x-real-ip`. Returns `undefined` rather than throwing
|
|
36
|
+
* on any failure (a local copy of `flow.actions.ts`'s helper, #4730).
|
|
37
|
+
*/
|
|
38
|
+
export async function resolveVisitorIpInit(): Promise<
|
|
39
|
+
{ headers: Record<string, string> } | undefined
|
|
40
|
+
> {
|
|
41
|
+
try {
|
|
42
|
+
const incoming = await headers();
|
|
43
|
+
const visitorIp =
|
|
44
|
+
incoming.get("x-forwarded-for") ?? incoming.get("x-real-ip");
|
|
45
|
+
return visitorIp
|
|
46
|
+
? { headers: { "x-forwarded-for": visitorIp } }
|
|
47
|
+
: undefined;
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** PII-safe error identity for logs — the `recommender.ts` idiom (`err.name` only). */
|
|
54
|
+
export function errorName(err: unknown): string {
|
|
55
|
+
return err instanceof Error ? err.name : "unknown";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The shared `status → kind` map (D7). Never throws. */
|
|
59
|
+
export function failureFor(res: {
|
|
60
|
+
status: number;
|
|
61
|
+
body: unknown;
|
|
62
|
+
}): EmailMomentFailure {
|
|
63
|
+
if (res.status === 400) return { ok: false, kind: "invalid_input" };
|
|
64
|
+
if (res.status === 409) return { ok: false, kind: "not_configured" };
|
|
65
|
+
if (res.status === 429) {
|
|
66
|
+
const body = res.body as { retryAfterSeconds?: number } | null;
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
kind: "rate_limited",
|
|
70
|
+
...(typeof body?.retryAfterSeconds === "number"
|
|
71
|
+
? { retryAfterSeconds: body.retryAfterSeconds }
|
|
72
|
+
: {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { ok: false, kind: "unavailable" };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* exactOptionalPropertyTypes-safe rebuild of the parsed body: Zod's inferred
|
|
80
|
+
* optional fields type as `T | undefined`, which does not structurally match
|
|
81
|
+
* the api-client's `sessionId?: string` / `locale?: string` — so an absent
|
|
82
|
+
* optional key is OMITTED rather than sent as an explicit `undefined`.
|
|
83
|
+
*/
|
|
84
|
+
export function emailMomentFields(parsed: FlowSaveInput): FlowSaveInput {
|
|
85
|
+
const { answers, email, locale, sessionId } = parsed;
|
|
86
|
+
return {
|
|
87
|
+
answers,
|
|
88
|
+
email,
|
|
89
|
+
...(locale !== undefined ? { locale } : {}),
|
|
90
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from "@working-theory/validation";
|
|
38
38
|
import { cookies, headers } from "next/headers";
|
|
39
39
|
|
|
40
|
+
import { runBotCheck } from "../../../../lib/bot-check";
|
|
40
41
|
import { consentVisitorHeader, readConsentVisitor } from "../../../../lib/consent-visitor";
|
|
41
42
|
|
|
42
43
|
/**
|
|
@@ -106,6 +107,15 @@ export async function submitFlowStepAction(
|
|
|
106
107
|
};
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
// #9255 D4 — the spoke edge's bot check, after the input checks and before
|
|
111
|
+
// the write. A refusal is the walk's existing "try again" variant.
|
|
112
|
+
const botCheck = await runBotCheck({
|
|
113
|
+
source: "bot-check",
|
|
114
|
+
target: "submitFlowStepAction",
|
|
115
|
+
scope: "write",
|
|
116
|
+
});
|
|
117
|
+
if (botCheck !== "pass") return { ok: false, kind: "network" };
|
|
118
|
+
|
|
109
119
|
try {
|
|
110
120
|
// exactOptionalPropertyTypes-safe: submitFlowStepInputSchema's Zod-inferred
|
|
111
121
|
// optional fields type as `T | undefined`, which does not structurally
|