create-ailk 0.26.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 +86 -9
- 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 +25 -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 +112 -11
- 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 +90 -6
- 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 +12 -2
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +16 -5
- package/templates/apps/api/CLAUDE.md +32 -11
- 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/api/vercel.json +7 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +27 -0
- package/templates/apps/web/CLAUDE.md +56 -8
- 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]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -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 +161 -128
- 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 +322 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
- 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 +484 -4
- 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/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
- 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 +44 -4
- 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/sign-up-agreements.ts +17 -6
- 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 +144 -3
- package/templates/apps/web/middleware.ts +179 -12
- package/templates/apps/web/next.config.mjs +220 -3
- package/templates/apps/web/package.json +3 -2
- 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
|
@@ -1,37 +1,49 @@
|
|
|
1
1
|
// Case Studies list page. Reads CaseStudiesList MDX via the content adapter and emits
|
|
2
2
|
// a JSON-LD CollectionPage via the dispatcher.
|
|
3
|
-
|
|
3
|
+
// It lists every CaseStudy page through one generated card section (#10121,
|
|
4
|
+
// apps/web/lib/hub-children.tsx — the /alternatives and /templates shape).
|
|
5
|
+
import type {
|
|
6
|
+
getMdxSlugs as defaultGetMdxSlugs,
|
|
7
|
+
getMdxSource as defaultGetMdxSource,
|
|
8
|
+
getPageSections as defaultGetPageSections,
|
|
9
|
+
} from "@working-theory/page-renderer";
|
|
4
10
|
import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
|
|
5
11
|
import type { Metadata } from "next";
|
|
6
12
|
import type { ReactElement } from "react";
|
|
7
13
|
|
|
14
|
+
import { type HubConfig, hubPageInput } from "../../../lib/hub-children";
|
|
15
|
+
|
|
8
16
|
interface Props {
|
|
9
17
|
params: Promise<{ locale: string }>;
|
|
10
18
|
getMdxSource?: typeof defaultGetMdxSource;
|
|
19
|
+
getPageSections?: typeof defaultGetPageSections;
|
|
20
|
+
getMdxSlugs?: typeof defaultGetMdxSlugs;
|
|
11
21
|
}
|
|
12
22
|
|
|
23
|
+
const HUB: HubConfig = {
|
|
24
|
+
listType: "CaseStudiesList",
|
|
25
|
+
slug: "case-studies",
|
|
26
|
+
childType: "CaseStudy",
|
|
27
|
+
linkLabel: "Read the case study",
|
|
28
|
+
sectionId: "case-study-cards",
|
|
29
|
+
body: "mdx-body",
|
|
30
|
+
};
|
|
31
|
+
|
|
13
32
|
export async function generateMetadata({
|
|
14
33
|
params,
|
|
15
34
|
}: Pick<Props, "params">): Promise<Metadata> {
|
|
16
35
|
const { locale } = await params;
|
|
17
|
-
return renderPageMetadata({
|
|
18
|
-
type: "CaseStudiesList",
|
|
19
|
-
slug: "case-studies",
|
|
20
|
-
locale,
|
|
21
|
-
});
|
|
36
|
+
return renderPageMetadata({ type: "CaseStudiesList", slug: "case-studies", locale });
|
|
22
37
|
}
|
|
23
38
|
|
|
24
39
|
export default async function Page({
|
|
25
40
|
params,
|
|
26
|
-
|
|
41
|
+
...loaders
|
|
27
42
|
}: Props): Promise<ReactElement> {
|
|
28
43
|
const { locale } = await params;
|
|
29
44
|
return renderPage({
|
|
30
45
|
type: "CaseStudiesList",
|
|
31
46
|
slug: "case-studies",
|
|
32
|
-
locale,
|
|
33
|
-
translationsNamespace: "case-studies",
|
|
34
|
-
bodyMode: "mdx-body",
|
|
35
|
-
...(getMdxSource !== undefined && { getMdxSource }),
|
|
47
|
+
...(await hubPageInput(HUB, locale, loaders)),
|
|
36
48
|
});
|
|
37
49
|
}
|
|
@@ -36,7 +36,9 @@ const mockVisitor = { ip: "203.0.113.7" };
|
|
|
36
36
|
jest.mock("next/headers", () => ({
|
|
37
37
|
__esModule: true,
|
|
38
38
|
headers: jest.fn(async () => ({
|
|
39
|
-
|
|
39
|
+
// #9967 D1 — the limiter reads only the edge-set address, on Vercel.
|
|
40
|
+
get: (name: string) =>
|
|
41
|
+
name === "x-vercel-forwarded-for" ? mockVisitor.ip : null,
|
|
40
42
|
})),
|
|
41
43
|
}));
|
|
42
44
|
|
|
@@ -73,10 +75,18 @@ async function linkHref(
|
|
|
73
75
|
return href;
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
const priorVercel = process.env.VERCEL;
|
|
79
|
+
|
|
76
80
|
beforeEach(() => {
|
|
77
81
|
mockRead.mockReset();
|
|
78
82
|
__resetRateLimit();
|
|
79
83
|
mockVisitor.ip = "203.0.113.7";
|
|
84
|
+
process.env.VERCEL = "1";
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterAll(() => {
|
|
88
|
+
if (priorVercel === undefined) delete process.env.VERCEL;
|
|
89
|
+
else process.env.VERCEL = priorVercel;
|
|
80
90
|
});
|
|
81
91
|
|
|
82
92
|
describe("the thank-you page's account link (#8757 D13, AC-9)", () => {
|
|
@@ -79,7 +79,7 @@ describe("ClientsPage — snapshot regression", () => {
|
|
|
79
79
|
});
|
|
80
80
|
const { container } = render(element);
|
|
81
81
|
expect(container.innerHTML).toMatchInlineSnapshot(
|
|
82
|
-
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Our Clients","description":"Companies that trust us.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Organization"}]}}</script><main id="main"><div><
|
|
82
|
+
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Our Clients","description":"Companies that trust us.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Organization"}]}}</script><main id="main"><div><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div data-testid="mdx-remote" data-source=""></div></article></div></div></div></main>"`,
|
|
83
83
|
);
|
|
84
84
|
});
|
|
85
85
|
});
|
|
@@ -13,7 +13,17 @@
|
|
|
13
13
|
* Neither ever throws to the client — Banner's `onAccept`/`onReject` are
|
|
14
14
|
* plain `() => void`, and the banner island already hides itself locally;
|
|
15
15
|
* a failed write here is logged and nothing more. Neither writes
|
|
16
|
-
* `ailk-consent` — the island owns that cookie, unchanged (C3).
|
|
16
|
+
* `ailk-consent` — the island owns that cookie, unchanged (C3). The visitor
|
|
17
|
+
* id is signed with a secret the site already holds (#8556; the order is in
|
|
18
|
+
* `lib/consent-visitor.ts`), so a production site with none of
|
|
19
|
+
* `BETTER_AUTH_SECRET`, `AILK_HUB_SESSION_SECRET` or `AILK_SPOKE_KEY` makes
|
|
20
|
+
* `ensureConsentVisitor` throw: the choice is logged as failed and not
|
|
21
|
+
* recorded, and `ailk-consent` still governs.
|
|
22
|
+
*
|
|
23
|
+
* Before the write each runs a per-visitor limit (#9967 D2): 20 choices per
|
|
24
|
+
* 10 minutes per visitor, the shared wider bucket for one whose address is
|
|
25
|
+
* unreadable. A refused choice makes no hub call and is logged; the visitor's
|
|
26
|
+
* own `ailk-consent` cookie still governs.
|
|
17
27
|
*/
|
|
18
28
|
|
|
19
29
|
import { consent, visitorForwardHeaders } from "@working-theory/api-client";
|
|
@@ -21,12 +31,38 @@ import { cookies, headers } from "next/headers";
|
|
|
21
31
|
import { getLocale } from "next-intl/server";
|
|
22
32
|
|
|
23
33
|
import { ensureConsentVisitor } from "../../lib/consent-visitor";
|
|
34
|
+
import { takeVisitorLimit } from "../../src/lib/rate-limit";
|
|
35
|
+
|
|
36
|
+
const CHOICE_LIMIT = 20;
|
|
37
|
+
const CHOICE_WINDOW_MS = 10 * 60_000;
|
|
38
|
+
/** Outside a request scope there is no address: the shared bucket. */
|
|
39
|
+
const NO_HEADERS = { get: () => null };
|
|
24
40
|
|
|
25
41
|
async function recordChoice(choice: "accepted" | "declined"): Promise<void> {
|
|
26
42
|
try {
|
|
27
43
|
const cookieStore = await cookies();
|
|
28
44
|
const visitorId = ensureConsentVisitor(cookieStore);
|
|
29
45
|
|
|
46
|
+
let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
|
|
47
|
+
try {
|
|
48
|
+
requestHeaders = await headers();
|
|
49
|
+
} catch {
|
|
50
|
+
// No request scope to read — the shared bucket, no forwarding headers.
|
|
51
|
+
}
|
|
52
|
+
if (
|
|
53
|
+
!takeVisitorLimit(
|
|
54
|
+
"consent",
|
|
55
|
+
requestHeaders ?? NO_HEADERS,
|
|
56
|
+
CHOICE_LIMIT,
|
|
57
|
+
CHOICE_WINDOW_MS,
|
|
58
|
+
).allowed
|
|
59
|
+
) {
|
|
60
|
+
console.error("[consent.actions] record cookie choice refused", {
|
|
61
|
+
outcome: "rate_limited",
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
30
66
|
let locale = "en";
|
|
31
67
|
try {
|
|
32
68
|
locale = await getLocale();
|
|
@@ -36,13 +72,15 @@ async function recordChoice(choice: "accepted" | "declined"): Promise<void> {
|
|
|
36
72
|
|
|
37
73
|
let init: { headers: Record<string, string> } | undefined;
|
|
38
74
|
try {
|
|
39
|
-
const forwarded =
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
75
|
+
const forwarded = requestHeaders
|
|
76
|
+
? visitorForwardHeaders(
|
|
77
|
+
requestHeaders,
|
|
78
|
+
process.env.AILK_VISITOR_FORWARD_KEY,
|
|
79
|
+
)
|
|
80
|
+
: undefined;
|
|
43
81
|
if (forwarded) init = { headers: forwarded };
|
|
44
82
|
} catch {
|
|
45
|
-
//
|
|
83
|
+
// A forwarding read that throws — record with no forwarding headers.
|
|
46
84
|
}
|
|
47
85
|
|
|
48
86
|
const body = { visitorId, choice, locale };
|
package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* per turn with an increasing `turnIndex`, plus one terminal call tagging
|
|
18
18
|
* the outcome.
|
|
19
19
|
*/
|
|
20
|
-
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
20
|
+
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
21
21
|
|
|
22
22
|
jest.mock("next-intl", () => ({
|
|
23
23
|
useTranslations: () => (key: string) => key,
|
|
@@ -106,7 +106,7 @@ function answer(text: string): void {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
describe("ConversationalIntakeWrapper — completion (AC1)", () => {
|
|
109
|
-
it("submits through the existing lead-capture endpoint's api-client surface", () => {
|
|
109
|
+
it("submits through the existing lead-capture endpoint's api-client surface", async () => {
|
|
110
110
|
mockSubmit.mockResolvedValue({
|
|
111
111
|
ok: true,
|
|
112
112
|
body: { id: "lead_abc123", persisted: "db" },
|
|
@@ -125,8 +125,8 @@ describe("ConversationalIntakeWrapper — completion (AC1)", () => {
|
|
|
125
125
|
advanceThinking(); // turn 4 (terms) revealed
|
|
126
126
|
acceptTerms();
|
|
127
127
|
advanceThinking(); // completion revealed, onComplete fires
|
|
128
|
-
|
|
129
|
-
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
128
|
+
// The action awaits its bot check (#9255) before the write.
|
|
129
|
+
await waitFor(() => expect(mockSubmit).toHaveBeenCalledTimes(1));
|
|
130
130
|
const body = mockSubmit.mock.calls[0][0] as {
|
|
131
131
|
source: string;
|
|
132
132
|
email: string;
|
|
@@ -18,10 +18,21 @@ import { captureLeadAction } from "../contact.actions.js";
|
|
|
18
18
|
|
|
19
19
|
// Mock @working-theory/api-client. `visitorForwardHeaders` stays real (#7394)
|
|
20
20
|
// so AC-9 compares the action's `init` with the helper's own output.
|
|
21
|
+
// #9255 — the write bot check's verdict. `checkBotId` reads Vercel's
|
|
22
|
+
// request context, which jest has none of, so every case decides it here.
|
|
23
|
+
const checkBotIdMock = jest.fn();
|
|
24
|
+
jest.mock("botid/server", () => ({
|
|
25
|
+
checkBotId: (...args: unknown[]) => checkBotIdMock(...args),
|
|
26
|
+
}));
|
|
27
|
+
|
|
21
28
|
jest.mock("@working-theory/api-client", () => ({
|
|
22
29
|
leads: {
|
|
23
30
|
submit: jest.fn(),
|
|
24
31
|
},
|
|
32
|
+
// #9171 — a `checkout` magnet's lookup key comes from the shown set.
|
|
33
|
+
offerings: {
|
|
34
|
+
shown: jest.fn(),
|
|
35
|
+
},
|
|
25
36
|
visitorForwardHeaders: jest.requireActual("@working-theory/api-client")
|
|
26
37
|
.visitorForwardHeaders,
|
|
27
38
|
}));
|
|
@@ -53,9 +64,9 @@ jest.mock("next-intl/server", () => {
|
|
|
53
64
|
};
|
|
54
65
|
});
|
|
55
66
|
|
|
56
|
-
// #
|
|
57
|
-
//
|
|
58
|
-
//
|
|
67
|
+
// #9171 — a `checkout` magnet never calls Next's `redirect()`: a Server
|
|
68
|
+
// Action redirect is fetched as RSC, which the buy route refuses. The mock
|
|
69
|
+
// throws NEXT_REDIRECT as Next does, so a stray call fails its case.
|
|
59
70
|
const mockRedirect = jest.fn((url: string): never => {
|
|
60
71
|
throw new Error(`NEXT_REDIRECT:${url}`);
|
|
61
72
|
});
|
|
@@ -65,21 +76,19 @@ jest.mock("next/navigation", () => ({
|
|
|
65
76
|
}));
|
|
66
77
|
|
|
67
78
|
let mockSignInProvider: "site" | "hub" | "none" = "site";
|
|
68
|
-
|
|
69
|
-
// eslint-disable-next-line no-restricted-syntax -- #507: SIGN_IN_PROVIDER and TOPOLOGY are generated per-build constants (#8757 D8, D11), not runtime inputs; no env or HTTP surface selects them.
|
|
79
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: SIGN_IN_PROVIDER is a generated per-build constant (#8757 D8), not a runtime input; no env or HTTP surface selects it.
|
|
70
80
|
jest.mock("../../../../lib/topology.generated", () => ({
|
|
71
81
|
__esModule: true,
|
|
72
82
|
get SIGN_IN_PROVIDER() {
|
|
73
83
|
return mockSignInProvider;
|
|
74
84
|
},
|
|
75
|
-
get TOPOLOGY() {
|
|
76
|
-
return mockTopology;
|
|
77
|
-
},
|
|
78
85
|
}));
|
|
79
86
|
|
|
80
|
-
import { leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
87
|
+
import { leads, offerings, visitorForwardHeaders } from "@working-theory/api-client";
|
|
88
|
+
import { workspaceShownSetSchema } from "@working-theory/validation";
|
|
81
89
|
import { headers } from "next/headers";
|
|
82
90
|
const mockSubmit = leads.submit as jest.Mock;
|
|
91
|
+
const mockShown = offerings.shown as jest.Mock;
|
|
83
92
|
const mockHeaders = headers as jest.Mock;
|
|
84
93
|
|
|
85
94
|
afterEach(() => {
|
|
@@ -612,7 +621,22 @@ describe("captureLeadAction — the built-in form's source (#8747)", () => {
|
|
|
612
621
|
|
|
613
622
|
describe("captureLeadAction — the per-visitor limit (#8747)", () => {
|
|
614
623
|
const visitor = (ip: string) => ({
|
|
615
|
-
get: (name: string) => (name === "x-forwarded-for" ? ip : null),
|
|
624
|
+
get: (name: string) => (name === "x-vercel-forwarded-for" ? ip : null),
|
|
625
|
+
});
|
|
626
|
+
// On Vercel, where the edge-set address is read; the bot check has its own suite.
|
|
627
|
+
const ENV_KEYS = ["VERCEL", "AILK_BOT_CHECK"] as const;
|
|
628
|
+
const priorEnv = new Map(ENV_KEYS.map((name) => [name, process.env[name]]));
|
|
629
|
+
|
|
630
|
+
beforeEach(() => {
|
|
631
|
+
process.env.VERCEL = "1";
|
|
632
|
+
process.env.AILK_BOT_CHECK = "off";
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
afterEach(() => {
|
|
636
|
+
for (const [name, value] of priorEnv) {
|
|
637
|
+
if (value === undefined) delete process.env[name];
|
|
638
|
+
else process.env[name] = value;
|
|
639
|
+
}
|
|
616
640
|
});
|
|
617
641
|
|
|
618
642
|
it("admits 30 posts from one visitor in ten minutes, and refuses the 31st without calling the API", async () => {
|
|
@@ -633,17 +657,27 @@ describe("captureLeadAction — the per-visitor limit (#8747)", () => {
|
|
|
633
657
|
expect(mockSubmit).toHaveBeenCalledTimes(30);
|
|
634
658
|
});
|
|
635
659
|
|
|
636
|
-
it("with no readable address, the limit
|
|
637
|
-
|
|
660
|
+
it("with no readable address, every post shares one bucket at ten times the limit, never skipped (#9967)", async () => {
|
|
661
|
+
// A client-sent x-forwarded-for is no address: it changes on every post.
|
|
662
|
+
let n = 0;
|
|
663
|
+
mockHeaders.mockImplementation(async () => {
|
|
664
|
+
n += 1;
|
|
665
|
+
return { get: (name: string) => (name === "x-forwarded-for" ? `10.0.${n >> 8}.${n & 255}` : null) };
|
|
666
|
+
});
|
|
638
667
|
mockSubmit.mockResolvedValue({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
639
668
|
|
|
640
|
-
for (let i = 0; i <
|
|
669
|
+
for (let i = 0; i < 300; i += 1) {
|
|
641
670
|
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toEqual({
|
|
642
671
|
kind: "success",
|
|
643
672
|
name: expect.any(String),
|
|
644
673
|
});
|
|
645
674
|
}
|
|
646
|
-
expect(mockSubmit).toHaveBeenCalledTimes(
|
|
675
|
+
expect(mockSubmit).toHaveBeenCalledTimes(300);
|
|
676
|
+
|
|
677
|
+
const refused = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
678
|
+
|
|
679
|
+
expect(refused).toMatchObject({ kind: "error", message: expect.any(String) });
|
|
680
|
+
expect(mockSubmit).toHaveBeenCalledTimes(300);
|
|
647
681
|
});
|
|
648
682
|
|
|
649
683
|
it("another visitor keeps its own budget", async () => {
|
|
@@ -786,7 +820,16 @@ describe("captureLeadAction — the account link (#8757 D8)", () => {
|
|
|
786
820
|
});
|
|
787
821
|
});
|
|
788
822
|
|
|
789
|
-
describe("captureLeadAction — a checkout magnet
|
|
823
|
+
describe("captureLeadAction — a checkout magnet answers the Offerings buy route after the write (#8757 D11, #9171)", () => {
|
|
824
|
+
// The shown-set read is shared for 15 seconds per instance, so each case
|
|
825
|
+
// starts a minute past the last one's window.
|
|
826
|
+
let clock = Date.now();
|
|
827
|
+
let now: jest.SpyInstance<number, []>;
|
|
828
|
+
beforeEach(() => {
|
|
829
|
+
clock += 60_000;
|
|
830
|
+
now = jest.spyOn(Date, "now").mockReturnValue(clock);
|
|
831
|
+
});
|
|
832
|
+
|
|
790
833
|
function answers(next: unknown): void {
|
|
791
834
|
mockSubmit.mockResolvedValueOnce({
|
|
792
835
|
ok: true,
|
|
@@ -794,18 +837,135 @@ describe("captureLeadAction — a checkout magnet redirects after the write (#87
|
|
|
794
837
|
});
|
|
795
838
|
}
|
|
796
839
|
|
|
797
|
-
|
|
840
|
+
/** A shown set in `state`: one `plan` carrying a Price per `period`. */
|
|
841
|
+
function shownSet(
|
|
842
|
+
periods: readonly ("monthly" | "yearly" | "once")[],
|
|
843
|
+
plan = "retainer",
|
|
844
|
+
state = "current",
|
|
845
|
+
): unknown {
|
|
846
|
+
return workspaceShownSetSchema.partial({ workspaceId: true, organizationId: true }).parse({
|
|
847
|
+
productIds: [`prod_${plan}`],
|
|
848
|
+
recommended: { plan: null, addon: null },
|
|
849
|
+
offerings: [
|
|
850
|
+
{
|
|
851
|
+
productId: `prod_${plan}`,
|
|
852
|
+
plan,
|
|
853
|
+
name: plan,
|
|
854
|
+
description: null,
|
|
855
|
+
marketingFeatures: [],
|
|
856
|
+
unitLabel: null,
|
|
857
|
+
note: null,
|
|
858
|
+
termsNote: null,
|
|
859
|
+
ailkGroup: "plan",
|
|
860
|
+
ailkOrder: null,
|
|
861
|
+
prices: periods.map((period) => ({
|
|
862
|
+
priceId: `price_${plan}_${period}`,
|
|
863
|
+
lookupKey: `${plan}_${period}`,
|
|
864
|
+
plan,
|
|
865
|
+
period,
|
|
866
|
+
interval: { monthly: "month", yearly: "year", once: null }[period],
|
|
867
|
+
amount: 4900,
|
|
868
|
+
currency: "usd",
|
|
869
|
+
})),
|
|
870
|
+
recommended: false,
|
|
871
|
+
},
|
|
872
|
+
],
|
|
873
|
+
state,
|
|
874
|
+
snapshotAt: "2026-10-26T00:00:00.000Z",
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
afterEach(() => {
|
|
879
|
+
mockShown.mockReset();
|
|
880
|
+
now.mockRestore();
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
/** The shown-set read answers `body` (the api-client's unwrapped 2xx). */
|
|
884
|
+
function shows(body: unknown): void {
|
|
885
|
+
mockShown.mockResolvedValueOnce({ ok: true, status: 200, body });
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
it("next: checkout, retainer → the success state carries /api/offerings/checkout/retainer_monthly, after exactly one write", async () => {
|
|
798
889
|
answers({ completion: "checkout", checkoutOffering: "retainer" });
|
|
890
|
+
shows({ status: "shown", shownSet: shownSet(["monthly", "yearly"]) });
|
|
799
891
|
|
|
800
|
-
await
|
|
801
|
-
"
|
|
802
|
-
|
|
892
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
|
|
893
|
+
kind: "success",
|
|
894
|
+
name: "Jane Smith",
|
|
895
|
+
redirect: "/api/offerings/checkout/retainer_monthly",
|
|
896
|
+
});
|
|
803
897
|
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
804
|
-
expect(mockRedirect).toHaveBeenCalledTimes(1);
|
|
805
|
-
expect(mockRedirect).toHaveBeenCalledWith("/api/checkout/retainer");
|
|
806
898
|
expect(mockSubmit.mock.invocationCallOrder[0]!).toBeLessThan(
|
|
807
|
-
|
|
899
|
+
mockShown.mock.invocationCallOrder[0]!,
|
|
808
900
|
);
|
|
901
|
+
// The form navigates; never a Server Action redirect, and never the retired route.
|
|
902
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it("one shown-set read serves every submit within 15 seconds", async () => {
|
|
906
|
+
const checkout = { completion: "checkout", checkoutOffering: "retainer" };
|
|
907
|
+
answers(checkout);
|
|
908
|
+
answers(checkout);
|
|
909
|
+
answers(checkout);
|
|
910
|
+
shows({ status: "shown", shownSet: shownSet(["monthly"]) });
|
|
911
|
+
shows({ status: "shown", shownSet: shownSet(["yearly"]) });
|
|
912
|
+
const submit = () => captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
913
|
+
|
|
914
|
+
expect(await submit()).toMatchObject({ redirect: "/api/offerings/checkout/retainer_monthly" });
|
|
915
|
+
now.mockReturnValue(clock + 14_999);
|
|
916
|
+
expect(await submit()).toMatchObject({ redirect: "/api/offerings/checkout/retainer_monthly" });
|
|
917
|
+
expect(mockShown).toHaveBeenCalledTimes(1);
|
|
918
|
+
now.mockReturnValue(clock + 15_000);
|
|
919
|
+
expect(await submit()).toMatchObject({ redirect: "/api/offerings/checkout/retainer_yearly" });
|
|
920
|
+
expect(mockShown).toHaveBeenCalledTimes(2);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it.each([
|
|
924
|
+
[["yearly", "once"], "retainer_yearly"],
|
|
925
|
+
[["once"], "retainer_once"],
|
|
926
|
+
] as const)(
|
|
927
|
+
"the key /pricing's card links first: shown %j → %s",
|
|
928
|
+
async (periods, lookupKey) => {
|
|
929
|
+
answers({ completion: "checkout", checkoutOffering: "retainer" });
|
|
930
|
+
shows({ status: "shown", shownSet: shownSet(periods) });
|
|
931
|
+
|
|
932
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toMatchObject({
|
|
933
|
+
redirect: `/api/offerings/checkout/${lookupKey}`,
|
|
934
|
+
});
|
|
935
|
+
},
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
it.each([
|
|
939
|
+
["a plan the shown set does not show", () => shows({ status: "shown", shownSet: shownSet(["monthly"], "growth") })],
|
|
940
|
+
["an unconnected site", () => shows({ status: "unconnected" })],
|
|
941
|
+
["a shown set that is not current", () => shows({ status: "shown", shownSet: shownSet(["monthly"], "retainer", "expired") })],
|
|
942
|
+
["a non-2xx read", () => mockShown.mockResolvedValueOnce({ ok: false, status: 503, body: null })],
|
|
943
|
+
["a thrown read", () => mockShown.mockRejectedValueOnce(new Error("network"))],
|
|
944
|
+
] as const)("%s → exactly { kind: success } and no buy route", async (_label, read) => {
|
|
945
|
+
answers({ completion: "checkout", checkoutOffering: "retainer" });
|
|
946
|
+
read();
|
|
947
|
+
|
|
948
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
|
|
949
|
+
kind: "success",
|
|
950
|
+
name: expect.any(String),
|
|
951
|
+
});
|
|
952
|
+
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
953
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it("a read with no answer within 5 seconds → exactly { kind: success } and no buy route", async () => {
|
|
957
|
+
jest.useFakeTimers({ doNotFake: ["nextTick", "setImmediate", "queueMicrotask", "Date"] });
|
|
958
|
+
try {
|
|
959
|
+
answers({ completion: "checkout", checkoutOffering: "retainer" });
|
|
960
|
+
mockShown.mockReturnValueOnce(new Promise(() => {}));
|
|
961
|
+
|
|
962
|
+
const result = captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
963
|
+
await jest.advanceTimersByTimeAsync(5_000);
|
|
964
|
+
expect(await result).toStrictEqual({ kind: "success", name: expect.any(String) });
|
|
965
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
966
|
+
} finally {
|
|
967
|
+
jest.useRealTimers();
|
|
968
|
+
}
|
|
809
969
|
});
|
|
810
970
|
|
|
811
971
|
it.each([
|
|
@@ -816,24 +976,26 @@ describe("captureLeadAction — a checkout magnet redirects after the write (#87
|
|
|
816
976
|
{ ok: false, status: 503, body: { error: "lead_capture_not_configured" } },
|
|
817
977
|
"unavailable",
|
|
818
978
|
],
|
|
819
|
-
] as const)("a failed write (%s) → { kind: %s } and no
|
|
979
|
+
] as const)("a failed write (%s) → { kind: %s } and no buy route", async (_label, response, kind) => {
|
|
820
980
|
mockSubmit.mockResolvedValueOnce(response);
|
|
821
981
|
|
|
822
982
|
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
823
983
|
expect(result).toMatchObject({ kind });
|
|
984
|
+
expect(mockShown).not.toHaveBeenCalled();
|
|
824
985
|
expect(mockRedirect).not.toHaveBeenCalled();
|
|
825
986
|
});
|
|
826
987
|
|
|
827
|
-
it("a thrown write → { kind: unavailable } and no
|
|
988
|
+
it("a thrown write → { kind: unavailable } and no buy route", async () => {
|
|
828
989
|
mockSubmit.mockRejectedValueOnce(new Error("network"));
|
|
829
990
|
|
|
830
991
|
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
831
992
|
expect(result).toMatchObject({ kind: "unavailable" });
|
|
993
|
+
expect(mockShown).not.toHaveBeenCalled();
|
|
832
994
|
expect(mockRedirect).not.toHaveBeenCalled();
|
|
833
995
|
});
|
|
834
996
|
|
|
835
997
|
it.each(["../x", "/evil", "Retainer", "has_underscore", "a".repeat(41), ""])(
|
|
836
|
-
"checkoutOffering %j → exactly { kind: success } and no
|
|
998
|
+
"checkoutOffering %j → exactly { kind: success }, no shown-set read and no buy route",
|
|
837
999
|
async (checkoutOffering) => {
|
|
838
1000
|
answers({ completion: "checkout", checkoutOffering });
|
|
839
1001
|
|
|
@@ -841,27 +1003,12 @@ describe("captureLeadAction — a checkout magnet redirects after the write (#87
|
|
|
841
1003
|
kind: "success",
|
|
842
1004
|
name: expect.any(String),
|
|
843
1005
|
});
|
|
1006
|
+
expect(mockShown).not.toHaveBeenCalled();
|
|
844
1007
|
expect(mockRedirect).not.toHaveBeenCalled();
|
|
845
1008
|
},
|
|
846
1009
|
);
|
|
847
1010
|
|
|
848
|
-
it("
|
|
849
|
-
mockTopology = "hub";
|
|
850
|
-
try {
|
|
851
|
-
answers({ completion: "checkout", checkoutOffering: "retainer" });
|
|
852
|
-
|
|
853
|
-
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
|
|
854
|
-
kind: "success",
|
|
855
|
-
name: expect.any(String),
|
|
856
|
-
});
|
|
857
|
-
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
858
|
-
expect(mockRedirect).not.toHaveBeenCalled();
|
|
859
|
-
} finally {
|
|
860
|
-
mockTopology = "spoke";
|
|
861
|
-
}
|
|
862
|
-
});
|
|
863
|
-
|
|
864
|
-
it("an invite still answers the account link and never redirects", async () => {
|
|
1011
|
+
it("an invite still answers the account link and no buy route", async () => {
|
|
865
1012
|
answers({ completion: "invite" });
|
|
866
1013
|
|
|
867
1014
|
const result = await captureLeadAction(
|
|
@@ -869,6 +1016,86 @@ describe("captureLeadAction — a checkout magnet redirects after the write (#87
|
|
|
869
1016
|
makeFormData({ ...VALID_FIELDS, email: "a@b.example" }),
|
|
870
1017
|
);
|
|
871
1018
|
expect(result).toMatchObject({ kind: "success", next: { label: "Track this in your account" } });
|
|
1019
|
+
expect(mockShown).not.toHaveBeenCalled();
|
|
872
1020
|
expect(mockRedirect).not.toHaveBeenCalled();
|
|
873
1021
|
});
|
|
874
1022
|
});
|
|
1023
|
+
|
|
1024
|
+
// #9255 D4 — the write bot check runs after the honeypot, the consent and
|
|
1025
|
+
// the schema, immediately before the write (spec
|
|
1026
|
+
// docs/specs/9255-an-edge-bot-challenge-in-spec.md §5.1 AC-2).
|
|
1027
|
+
describe("captureLeadAction — the write bot check (#9255)", () => {
|
|
1028
|
+
const BOT = { isHuman: false, isBot: true, isVerifiedBot: false, bypassed: false };
|
|
1029
|
+
const HUMAN = { ...BOT, isHuman: true, isBot: false };
|
|
1030
|
+
const BOT_ENV_KEYS = ["VERCEL", "AILK_BOT_CHECK", "AILK_BOOKING_BOT_CHECK"] as const;
|
|
1031
|
+
const priorBotEnv = new Map(BOT_ENV_KEYS.map((name) => [name, process.env[name]]));
|
|
1032
|
+
|
|
1033
|
+
let botLogSpies: jest.SpyInstance[] = [];
|
|
1034
|
+
|
|
1035
|
+
beforeEach(() => {
|
|
1036
|
+
checkBotIdMock.mockReset();
|
|
1037
|
+
for (const name of BOT_ENV_KEYS) delete process.env[name];
|
|
1038
|
+
process.env.VERCEL = "1";
|
|
1039
|
+
botLogSpies = [
|
|
1040
|
+
jest.spyOn(console, "warn").mockImplementation(() => undefined),
|
|
1041
|
+
jest.spyOn(console, "error").mockImplementation(() => undefined),
|
|
1042
|
+
];
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
afterEach(() => {
|
|
1046
|
+
for (const [name, value] of priorBotEnv) {
|
|
1047
|
+
if (value === undefined) delete process.env[name];
|
|
1048
|
+
else process.env[name] = value;
|
|
1049
|
+
}
|
|
1050
|
+
for (const spy of botLogSpies) spy.mockRestore();
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
const OK = { ok: true, body: { id: "lead_abc123", persisted: "db" } };
|
|
1054
|
+
|
|
1055
|
+
it("a bot gets the generic error, and nothing is sent", async () => {
|
|
1056
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
1057
|
+
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
1058
|
+
expect(result).toStrictEqual({ kind: "error", message: "Something went wrong, please try again." });
|
|
1059
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
1060
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it("a check that fails is unavailable, and nothing is sent", async () => {
|
|
1064
|
+
checkBotIdMock.mockRejectedValue(new Error("boom"));
|
|
1065
|
+
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
1066
|
+
expect(result).toStrictEqual({ kind: "unavailable" });
|
|
1067
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it("a human sends exactly what the check off sends", async () => {
|
|
1071
|
+
checkBotIdMock.mockResolvedValue(HUMAN);
|
|
1072
|
+
mockSubmit.mockResolvedValue(OK);
|
|
1073
|
+
delete process.env.VERCEL;
|
|
1074
|
+
await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
1075
|
+
process.env.VERCEL = "1";
|
|
1076
|
+
expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toMatchObject({
|
|
1077
|
+
kind: "success",
|
|
1078
|
+
});
|
|
1079
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
1080
|
+
expect(mockSubmit).toHaveBeenCalledTimes(2);
|
|
1081
|
+
expect(mockSubmit.mock.calls[1]).toEqual(mockSubmit.mock.calls[0]);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it("a filled honeypot answers success with no check", async () => {
|
|
1085
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
1086
|
+
const result = await captureLeadAction(
|
|
1087
|
+
INITIAL_STATE,
|
|
1088
|
+
makeFormData({ ...VALID_FIELDS, ailk_hp: "spam" }),
|
|
1089
|
+
);
|
|
1090
|
+
expect(result).toEqual({ kind: "success" });
|
|
1091
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it("a post without consent answers the consent error with no check", async () => {
|
|
1095
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
1096
|
+
const { consent: _consent, ...unticked } = VALID_FIELDS;
|
|
1097
|
+
const result = await captureLeadAction(INITIAL_STATE, makeFormData(unticked));
|
|
1098
|
+
expect(result).toEqual({ kind: "error", fieldErrors: { consent: expect.any(String) } });
|
|
1099
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
1100
|
+
});
|
|
1101
|
+
});
|