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,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared cases for the hub child-card tests (#10121).
|
|
3
|
+
*
|
|
4
|
+
* Each hub's test lives beside its route
|
|
5
|
+
* (`app/[locale]/<hub>/__tests__/hub-children.test.tsx`) so a scaffold that
|
|
6
|
+
* drops a gated hub drops its test with it (#10157). This module imports no
|
|
7
|
+
* route, so it ships to every scaffold and type-checks without any hub.
|
|
8
|
+
*
|
|
9
|
+
* `renderPage`/`renderPageMetadata` are mocked at the
|
|
10
|
+
* `@working-theory/page-renderer` boundary in each test file, the /templates
|
|
11
|
+
* test shape: each assertion inspects the sections a hub hands `renderPage`.
|
|
12
|
+
* The JSON-LD is built from those same sections with the real `buildJsonLd`,
|
|
13
|
+
* as `renderPage` does, so the cards and the ItemList are compared on one
|
|
14
|
+
* input per hub.
|
|
15
|
+
*/
|
|
16
|
+
import { buildJsonLd } from "@working-theory/schema";
|
|
17
|
+
import type { ReactElement } from "react";
|
|
18
|
+
|
|
19
|
+
export type Section = {
|
|
20
|
+
type: string;
|
|
21
|
+
id?: string;
|
|
22
|
+
data?: Record<string, unknown>;
|
|
23
|
+
};
|
|
24
|
+
export type Loaders = {
|
|
25
|
+
getMdxSource: jest.Mock;
|
|
26
|
+
getPageSections: jest.Mock;
|
|
27
|
+
getMdxSlugs: jest.Mock;
|
|
28
|
+
};
|
|
29
|
+
export type HubPage = (
|
|
30
|
+
props: {
|
|
31
|
+
params: Promise<{ locale: string }>;
|
|
32
|
+
} & Loaders,
|
|
33
|
+
) => Promise<ReactElement>;
|
|
34
|
+
export type RenderPageMock = jest.Mock<Promise<string>, unknown[]>;
|
|
35
|
+
|
|
36
|
+
/** [route, page, list type, child type, body rule] — one row per hub. */
|
|
37
|
+
export type HubRow = [
|
|
38
|
+
string,
|
|
39
|
+
HubPage,
|
|
40
|
+
string,
|
|
41
|
+
string,
|
|
42
|
+
"sections" | "mdx-body" | "composed",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export const HERO: Section = {
|
|
46
|
+
type: "hero",
|
|
47
|
+
id: "hero",
|
|
48
|
+
data: { headline: "Hub" },
|
|
49
|
+
};
|
|
50
|
+
export const CLOSING: Section = {
|
|
51
|
+
type: "cta",
|
|
52
|
+
id: "closing",
|
|
53
|
+
data: { headline: "Go" },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Loaders for one hub: its seed, its declared sections, and its children. */
|
|
57
|
+
export function loaders(
|
|
58
|
+
listType: string,
|
|
59
|
+
childType: string,
|
|
60
|
+
children: Record<string, { title?: string; description?: string } | null>,
|
|
61
|
+
declared: Section[],
|
|
62
|
+
hubSections = true,
|
|
63
|
+
): Loaders {
|
|
64
|
+
return {
|
|
65
|
+
getMdxSource: jest.fn(async (type: string, slug: string) => {
|
|
66
|
+
if (type === listType) {
|
|
67
|
+
return {
|
|
68
|
+
frontmatter: {
|
|
69
|
+
type,
|
|
70
|
+
slug,
|
|
71
|
+
title: "Hub",
|
|
72
|
+
description: "The hub.",
|
|
73
|
+
...(hubSections && { sections: declared }),
|
|
74
|
+
},
|
|
75
|
+
body: "",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (type !== childType) return null;
|
|
79
|
+
const child = children[slug];
|
|
80
|
+
return child === null || child === undefined
|
|
81
|
+
? null
|
|
82
|
+
: { frontmatter: { type, slug, ...child }, body: "" };
|
|
83
|
+
}),
|
|
84
|
+
getPageSections: jest.fn(async () => declared),
|
|
85
|
+
getMdxSlugs: jest.fn(async (type: string) =>
|
|
86
|
+
type === childType ? Object.keys(children) : [],
|
|
87
|
+
),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Renders a hub and returns the one input it handed `renderPage`. */
|
|
92
|
+
export async function renderInput(
|
|
93
|
+
mockRenderPage: RenderPageMock,
|
|
94
|
+
page: HubPage,
|
|
95
|
+
l: Loaders,
|
|
96
|
+
) {
|
|
97
|
+
mockRenderPage.mockClear();
|
|
98
|
+
await page({ params: Promise.resolve({ locale: "en" }), ...l });
|
|
99
|
+
expect(mockRenderPage).toHaveBeenCalledTimes(1);
|
|
100
|
+
return mockRenderPage.mock.calls[0]?.[0] as {
|
|
101
|
+
type: string;
|
|
102
|
+
slug: string;
|
|
103
|
+
bodyMode?: string;
|
|
104
|
+
trailing?: { props: { sections: Section[] } };
|
|
105
|
+
getPageSections: (fm: unknown) => Promise<Section[]>;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const titlesOf = (section: unknown) =>
|
|
110
|
+
(section as { data: { items: { title: string }[] } } | null)?.data.items.map(
|
|
111
|
+
(i) => i.title,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
/** The five cases every hub meets, registered as one `describe` per hub. */
|
|
115
|
+
export function describeHub(
|
|
116
|
+
[route, page, listType, childType, body]: HubRow,
|
|
117
|
+
mockRenderPage: RenderPageMock,
|
|
118
|
+
): void {
|
|
119
|
+
describe(`/${route} hub`, () => {
|
|
120
|
+
// Out of slug order on purpose, plus one slug that does not load.
|
|
121
|
+
const children = {
|
|
122
|
+
zeta: { title: "Zeta", description: "Zeta page." },
|
|
123
|
+
alpha: { title: "Alpha", description: "Alpha page." },
|
|
124
|
+
gone: null,
|
|
125
|
+
beta: { title: "Beta" },
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
it("generates one card per child page, sorted by slug, before the closing CTA", async () => {
|
|
129
|
+
const input = await renderInput(
|
|
130
|
+
mockRenderPage,
|
|
131
|
+
page,
|
|
132
|
+
loaders(listType, childType, children, [HERO, CLOSING]),
|
|
133
|
+
);
|
|
134
|
+
expect(input.type).toBe(listType);
|
|
135
|
+
expect(input.slug).toBe(route);
|
|
136
|
+
|
|
137
|
+
const sections = await input.getPageSections({});
|
|
138
|
+
expect(sections.map((s) => s.type)).toEqual(["hero", "items", "cta"]);
|
|
139
|
+
const items = (sections[1]?.data as { items: unknown[] }).items;
|
|
140
|
+
expect(items).toEqual([
|
|
141
|
+
expect.objectContaining({
|
|
142
|
+
title: "Alpha",
|
|
143
|
+
description: "Alpha page.",
|
|
144
|
+
link: expect.objectContaining({ href: `/${route}/alpha` }),
|
|
145
|
+
}),
|
|
146
|
+
expect.objectContaining({
|
|
147
|
+
title: "Beta",
|
|
148
|
+
description: "",
|
|
149
|
+
link: expect.objectContaining({ href: `/${route}/beta` }),
|
|
150
|
+
}),
|
|
151
|
+
expect.objectContaining({
|
|
152
|
+
title: "Zeta",
|
|
153
|
+
link: expect.objectContaining({ href: `/${route}/zeta` }),
|
|
154
|
+
}),
|
|
155
|
+
]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("lists the same pages in the CollectionPage ItemList as in the cards", async () => {
|
|
159
|
+
const input = await renderInput(
|
|
160
|
+
mockRenderPage,
|
|
161
|
+
page,
|
|
162
|
+
loaders(listType, childType, children, [HERO, CLOSING]),
|
|
163
|
+
);
|
|
164
|
+
const sections = await input.getPageSections({});
|
|
165
|
+
const jsonLd = buildJsonLd({
|
|
166
|
+
type: listType,
|
|
167
|
+
slug: route,
|
|
168
|
+
title: "Hub",
|
|
169
|
+
description: "The hub.",
|
|
170
|
+
locale: "en",
|
|
171
|
+
sections,
|
|
172
|
+
} as never) as unknown as {
|
|
173
|
+
mainEntity: { itemListElement: { name?: string }[] };
|
|
174
|
+
};
|
|
175
|
+
expect(jsonLd.mainEntity.itemListElement.map((e) => e.name)).toEqual([
|
|
176
|
+
"Alpha",
|
|
177
|
+
"Beta",
|
|
178
|
+
"Zeta",
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("gives an already-linked child no second card", async () => {
|
|
183
|
+
const authored: Section = {
|
|
184
|
+
type: "items",
|
|
185
|
+
id: "authored",
|
|
186
|
+
data: {
|
|
187
|
+
items: [
|
|
188
|
+
{
|
|
189
|
+
title: "Alpha (hand-written)",
|
|
190
|
+
link: { label: "Read", href: `/en/${route}/alpha/` },
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
title: "Zeta (hand-written)",
|
|
194
|
+
link: {
|
|
195
|
+
label: "Read",
|
|
196
|
+
href: `https://example.com/${route}/zeta?x=1`,
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
const input = await renderInput(
|
|
203
|
+
mockRenderPage,
|
|
204
|
+
page,
|
|
205
|
+
loaders(listType, childType, children, [HERO, authored, CLOSING]),
|
|
206
|
+
);
|
|
207
|
+
const sections = await input.getPageSections({});
|
|
208
|
+
const titles = (s: Section | undefined) =>
|
|
209
|
+
(s?.data as { items: { title: string }[] }).items.map((i) => i.title);
|
|
210
|
+
if (body === "mdx-body") {
|
|
211
|
+
// A prose hub never renders its declared sections, so links there do
|
|
212
|
+
// not count: every child gets a visible card after the body.
|
|
213
|
+
expect(sections.map((s) => s.type)).toEqual([
|
|
214
|
+
"hero",
|
|
215
|
+
"items",
|
|
216
|
+
"items",
|
|
217
|
+
"cta",
|
|
218
|
+
]);
|
|
219
|
+
expect(titles(sections[2])).toEqual(["Alpha", "Beta", "Zeta"]);
|
|
220
|
+
} else {
|
|
221
|
+
// The missing child continues the authored grid: one list, no duplicate.
|
|
222
|
+
expect(sections.map((s) => s.id)).toEqual([
|
|
223
|
+
"hero",
|
|
224
|
+
"authored",
|
|
225
|
+
"closing",
|
|
226
|
+
]);
|
|
227
|
+
expect(titles(sections[1])).toEqual([
|
|
228
|
+
"Alpha (hand-written)",
|
|
229
|
+
"Zeta (hand-written)",
|
|
230
|
+
"Beta",
|
|
231
|
+
]);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("renders its authored sections unchanged with zero child pages", async () => {
|
|
236
|
+
const input = await renderInput(
|
|
237
|
+
mockRenderPage,
|
|
238
|
+
page,
|
|
239
|
+
loaders(listType, childType, {}, [HERO, CLOSING]),
|
|
240
|
+
);
|
|
241
|
+
expect(await input.getPageSections({})).toEqual([HERO, CLOSING]);
|
|
242
|
+
expect(input.trailing).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("keeps the hub's body rule, rendering the cards after a prose body", async () => {
|
|
246
|
+
const prose = await renderInput(
|
|
247
|
+
mockRenderPage,
|
|
248
|
+
page,
|
|
249
|
+
loaders(listType, childType, children, [], false),
|
|
250
|
+
);
|
|
251
|
+
if (body === "sections") {
|
|
252
|
+
expect(prose.bodyMode).toBeUndefined();
|
|
253
|
+
expect(prose.trailing).toBeUndefined();
|
|
254
|
+
} else {
|
|
255
|
+
// A prose seed renders its MDX body, with the cards after it.
|
|
256
|
+
expect(prose.bodyMode).toBe("mdx-body");
|
|
257
|
+
expect(prose.trailing?.props.sections.map((s) => s.type)).toEqual([
|
|
258
|
+
"items",
|
|
259
|
+
]);
|
|
260
|
+
expect(await prose.getPageSections({})).toEqual(
|
|
261
|
+
prose.trailing?.props.sections,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const composed = await renderInput(
|
|
266
|
+
mockRenderPage,
|
|
267
|
+
page,
|
|
268
|
+
loaders(listType, childType, children, [HERO, CLOSING]),
|
|
269
|
+
);
|
|
270
|
+
if (body === "mdx-body") {
|
|
271
|
+
expect(composed.bodyMode).toBe("mdx-body");
|
|
272
|
+
} else {
|
|
273
|
+
expect(composed.bodyMode).toBeUndefined();
|
|
274
|
+
expect(composed.trailing).toBeUndefined();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
// #10045 — every nav/footer key resolves from the `nav` messages namespace by
|
|
5
|
+
// key (D1), and a key missing from any locale's messages file fails the build
|
|
6
|
+
// naming the key and the file (D2).
|
|
7
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
findMissingNavLabels,
|
|
13
|
+
formatMissingNavLabels,
|
|
14
|
+
} from "../nav-label-check";
|
|
15
|
+
import {
|
|
16
|
+
collectNavLabelKeys,
|
|
17
|
+
navMessageKeys,
|
|
18
|
+
usedNavLabelKeys,
|
|
19
|
+
} from "../nav-label-keys";
|
|
20
|
+
|
|
21
|
+
describe("collectNavLabelKeys", () => {
|
|
22
|
+
it("collects labelKey, headingKey and descKey at any depth", () => {
|
|
23
|
+
const keys = collectNavLabelKeys(
|
|
24
|
+
[
|
|
25
|
+
{
|
|
26
|
+
labelKey: "product",
|
|
27
|
+
path: "/p",
|
|
28
|
+
flyout: {
|
|
29
|
+
features: [
|
|
30
|
+
{ labelKey: "featureA", descKey: "featureADesc", path: "/a" },
|
|
31
|
+
],
|
|
32
|
+
groups: [
|
|
33
|
+
{
|
|
34
|
+
headingKey: "groupHeading",
|
|
35
|
+
items: [{ labelKey: "groupItem", path: "/g" }],
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
[
|
|
42
|
+
{
|
|
43
|
+
headingKey: "footerHeading",
|
|
44
|
+
items: [{ labelKey: "footerItem", path: "/f" }],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
);
|
|
48
|
+
expect([...keys].sort()).toEqual(
|
|
49
|
+
[
|
|
50
|
+
"featureA",
|
|
51
|
+
"featureADesc",
|
|
52
|
+
"footerHeading",
|
|
53
|
+
"footerItem",
|
|
54
|
+
"groupHeading",
|
|
55
|
+
"groupItem",
|
|
56
|
+
"product",
|
|
57
|
+
].sort(),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("ignores non-string key fields and other string properties", () => {
|
|
62
|
+
const keys = collectNavLabelKeys([
|
|
63
|
+
{ labelKey: 3, path: "/x", title: "nope" },
|
|
64
|
+
]);
|
|
65
|
+
expect([...keys]).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("navMessageKeys", () => {
|
|
70
|
+
it("returns every string key of the nav namespace", () => {
|
|
71
|
+
expect(
|
|
72
|
+
navMessageKeys({ nav: { a: "A", b: "B", nested: { c: "C" } } }),
|
|
73
|
+
).toEqual(["a", "b"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("is empty when there is no nav namespace", () => {
|
|
77
|
+
expect(navMessageKeys({})).toEqual([]);
|
|
78
|
+
expect(navMessageKeys(undefined)).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("findMissingNavLabels", () => {
|
|
83
|
+
function fixture(files: Record<string, unknown>): string {
|
|
84
|
+
const dir = mkdtempSync(join(tmpdir(), "nav-labels-"));
|
|
85
|
+
mkdirSync(join(dir, "site"));
|
|
86
|
+
for (const [name, body] of Object.entries(files)) {
|
|
87
|
+
writeFileSync(join(dir, name), JSON.stringify(body));
|
|
88
|
+
}
|
|
89
|
+
return dir;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
it("reports nothing when every key is present", () => {
|
|
93
|
+
const dir = fixture({
|
|
94
|
+
"en.json": { nav: { home: "Home" } },
|
|
95
|
+
"site/en.json": {},
|
|
96
|
+
});
|
|
97
|
+
expect(findMissingNavLabels(dir, ["home"])).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("names the missing key and the messages file", () => {
|
|
101
|
+
const dir = fixture({
|
|
102
|
+
"en.json": { nav: { home: "Home", websiteRebuild: "Website rebuild" } },
|
|
103
|
+
"fr.json": { nav: { home: "Accueil" } },
|
|
104
|
+
"site/en.json": {},
|
|
105
|
+
});
|
|
106
|
+
const missing = findMissingNavLabels(dir, ["home", "websiteRebuild"]);
|
|
107
|
+
expect(missing).toEqual([
|
|
108
|
+
{ key: "websiteRebuild", file: join(dir, "fr.json") },
|
|
109
|
+
]);
|
|
110
|
+
const message = formatMissingNavLabels(missing);
|
|
111
|
+
expect(message).toContain('"websiteRebuild"');
|
|
112
|
+
expect(message).toContain(join(dir, "fr.json"));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("accepts a key the site overlay adds (messages/site/<locale>.json)", () => {
|
|
116
|
+
const dir = fixture({
|
|
117
|
+
"en.json": { nav: { home: "Home" } },
|
|
118
|
+
"site/en.json": { nav: { aiGrowthPlatform: "AI growth platform" } },
|
|
119
|
+
});
|
|
120
|
+
expect(findMissingNavLabels(dir, ["aiGrowthPlatform"])).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("falls back to the English overlay for a locale with no overlay of its own", () => {
|
|
124
|
+
const dir = fixture({
|
|
125
|
+
"en.json": { nav: {} },
|
|
126
|
+
"fr.json": { nav: {} },
|
|
127
|
+
"site/en.json": { nav: { aiGrowthPlatform: "AI growth platform" } },
|
|
128
|
+
});
|
|
129
|
+
expect(findMissingNavLabels(dir, ["aiGrowthPlatform"])).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("every key nav-config.ts and the layout use exists in every shipped locale", () => {
|
|
133
|
+
const keys = usedNavLabelKeys();
|
|
134
|
+
expect(
|
|
135
|
+
findMissingNavLabels(join(__dirname, "..", "..", "messages"), keys),
|
|
136
|
+
).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -10,6 +10,13 @@ import { subscribeNewsletterAction } from "../newsletter.actions.js";
|
|
|
10
10
|
// `flows.get` answers 404 by default — a Free build with no flow route — so
|
|
11
11
|
// the #6730 cases below take `leads.subscribe`; the #8847 cases serve the
|
|
12
12
|
// newsletter magnet.
|
|
13
|
+
// #9255 — the write bot check's verdict. `checkBotId` reads Vercel's
|
|
14
|
+
// request context, which jest has none of, so every case decides it here.
|
|
15
|
+
const checkBotIdMock = jest.fn();
|
|
16
|
+
jest.mock("botid/server", () => ({
|
|
17
|
+
checkBotId: (...args: unknown[]) => checkBotIdMock(...args),
|
|
18
|
+
}));
|
|
19
|
+
|
|
13
20
|
jest.mock("@working-theory/api-client", () => ({
|
|
14
21
|
flows: {
|
|
15
22
|
get: jest.fn().mockResolvedValue({ ok: false, status: 404, body: {} }),
|
|
@@ -258,10 +265,25 @@ describe("subscribeNewsletterAction — through the newsletter magnet (#8847 D11
|
|
|
258
265
|
expect(mockSubscribe).not.toHaveBeenCalled();
|
|
259
266
|
});
|
|
260
267
|
|
|
268
|
+
// On Vercel, where the edge-set address is read (#9967 D1); the bot check
|
|
269
|
+
// has its own suite below.
|
|
270
|
+
const LIMIT_ENV_KEYS = ["VERCEL", "AILK_BOT_CHECK"] as const;
|
|
271
|
+
const priorLimitEnv = new Map(LIMIT_ENV_KEYS.map((name) => [name, process.env[name]]));
|
|
272
|
+
beforeEach(() => {
|
|
273
|
+
process.env.VERCEL = "1";
|
|
274
|
+
process.env.AILK_BOT_CHECK = "off";
|
|
275
|
+
});
|
|
276
|
+
afterEach(() => {
|
|
277
|
+
for (const [name, value] of priorLimitEnv) {
|
|
278
|
+
if (value === undefined) delete process.env[name];
|
|
279
|
+
else process.env[name] = value;
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
261
283
|
it("forwards the visitor's address to the step route, and refuses a visitor's 31st post in ten minutes (security review)", async () => {
|
|
262
284
|
servedMagnet();
|
|
263
285
|
mockHeaders.mockResolvedValue({
|
|
264
|
-
get: (name: string) => (name === "x-forwarded-for" ? "
|
|
286
|
+
get: (name: string) => (name === "x-vercel-forwarded-for" ? "198.51.100.7" : null),
|
|
265
287
|
});
|
|
266
288
|
mockSubmitStep.mockResolvedValue({
|
|
267
289
|
ok: true,
|
|
@@ -273,7 +295,7 @@ describe("subscribeNewsletterAction — through the newsletter magnet (#8847 D11
|
|
|
273
295
|
expect(await subscribeNewsletterAction(null, formWith(`a${i}@b.co`))).toEqual({ kind: "success" });
|
|
274
296
|
}
|
|
275
297
|
expect(mockSubmitStep.mock.calls[0]![2]).toEqual({
|
|
276
|
-
headers: expect.objectContaining({ "x-forwarded-for": "
|
|
298
|
+
headers: expect.objectContaining({ "x-forwarded-for": "198.51.100.7" }),
|
|
277
299
|
});
|
|
278
300
|
|
|
279
301
|
expect(await subscribeNewsletterAction(null, formWith("late@b.co"))).toMatchObject({
|
|
@@ -283,6 +305,46 @@ describe("subscribeNewsletterAction — through the newsletter magnet (#8847 D11
|
|
|
283
305
|
expect(mockSubmitStep).toHaveBeenCalledTimes(30);
|
|
284
306
|
});
|
|
285
307
|
|
|
308
|
+
it("off Vercel, never forwards a client-sent x-forwarded-for to the step route (#9967 D1)", async () => {
|
|
309
|
+
delete process.env.VERCEL;
|
|
310
|
+
servedMagnet();
|
|
311
|
+
mockHeaders.mockResolvedValue({
|
|
312
|
+
get: (name: string) => (name === "x-forwarded-for" ? "203.0.113.9" : null),
|
|
313
|
+
});
|
|
314
|
+
mockSubmitStep.mockResolvedValue({
|
|
315
|
+
ok: true,
|
|
316
|
+
status: 200,
|
|
317
|
+
body: { sessionId: "s1", nextStepKey: null, completed: true },
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toEqual({ kind: "success" });
|
|
321
|
+
const stepInit = mockSubmitStep.mock.calls[0]![2] as { headers?: Record<string, string> } | undefined;
|
|
322
|
+
expect(stepInit?.headers?.["x-forwarded-for"]).toBeUndefined();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("with no readable address, every post shares one bucket at ten times the limit, never skipped (#9967 D1)", async () => {
|
|
326
|
+
servedMagnet();
|
|
327
|
+
let n = 0;
|
|
328
|
+
mockHeaders.mockImplementation(async () => {
|
|
329
|
+
n += 1;
|
|
330
|
+
return { get: (name: string) => (name === "x-forwarded-for" ? `10.0.${n >> 8}.${n & 255}` : null) };
|
|
331
|
+
});
|
|
332
|
+
mockSubmitStep.mockResolvedValue({
|
|
333
|
+
ok: true,
|
|
334
|
+
status: 200,
|
|
335
|
+
body: { sessionId: "s1", nextStepKey: null, completed: true },
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
for (let i = 0; i < 300; i += 1) {
|
|
339
|
+
expect(await subscribeNewsletterAction(null, formWith(`a${i}@b.co`))).toEqual({ kind: "success" });
|
|
340
|
+
}
|
|
341
|
+
expect(await subscribeNewsletterAction(null, formWith("late@b.co"))).toMatchObject({
|
|
342
|
+
kind: "error",
|
|
343
|
+
message: expect.any(String),
|
|
344
|
+
});
|
|
345
|
+
expect(mockSubmitStep).toHaveBeenCalledTimes(300);
|
|
346
|
+
});
|
|
347
|
+
|
|
286
348
|
it("with flows.get 404 (a Free build) the same post calls leads.subscribe and not submitStep", async () => {
|
|
287
349
|
mockSubscribe.mockResolvedValueOnce({ ok: true, body: { id: "subscr_1", persisted: "db" } });
|
|
288
350
|
|
|
@@ -291,3 +353,117 @@ describe("subscribeNewsletterAction — through the newsletter magnet (#8847 D11
|
|
|
291
353
|
expect(mockSubmitStep).not.toHaveBeenCalled();
|
|
292
354
|
});
|
|
293
355
|
});
|
|
356
|
+
|
|
357
|
+
// #9255 D4 — the write bot check runs once per signup, on whichever write
|
|
358
|
+
// path the signup takes: the magnet's `flows.submitStep` or the Free path's
|
|
359
|
+
// `leads.subscribe` (spec docs/specs/9255-an-edge-bot-challenge-in-spec.md
|
|
360
|
+
// §5.1 AC-2).
|
|
361
|
+
describe("subscribeNewsletterAction — the write bot check (#9255)", () => {
|
|
362
|
+
const BOT = { isHuman: false, isBot: true, isVerifiedBot: false, bypassed: false };
|
|
363
|
+
const HUMAN = { ...BOT, isHuman: true, isBot: false };
|
|
364
|
+
const BOT_ENV_KEYS = ["VERCEL", "AILK_BOT_CHECK", "AILK_BOOKING_BOT_CHECK"] as const;
|
|
365
|
+
const priorBotEnv = new Map(BOT_ENV_KEYS.map((name) => [name, process.env[name]]));
|
|
366
|
+
|
|
367
|
+
let botLogSpies: jest.SpyInstance[] = [];
|
|
368
|
+
|
|
369
|
+
beforeEach(() => {
|
|
370
|
+
checkBotIdMock.mockReset();
|
|
371
|
+
for (const name of BOT_ENV_KEYS) delete process.env[name];
|
|
372
|
+
process.env.VERCEL = "1";
|
|
373
|
+
botLogSpies = [
|
|
374
|
+
jest.spyOn(console, "warn").mockImplementation(() => undefined),
|
|
375
|
+
jest.spyOn(console, "error").mockImplementation(() => undefined),
|
|
376
|
+
];
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
afterEach(() => {
|
|
380
|
+
for (const [name, value] of priorBotEnv) {
|
|
381
|
+
if (value === undefined) delete process.env[name];
|
|
382
|
+
else process.env[name] = value;
|
|
383
|
+
}
|
|
384
|
+
for (const spy of botLogSpies) spy.mockRestore();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
function servedMagnet(): void {
|
|
388
|
+
const { completionEmail: _omitted, ...body } = newsletterFlowConfig;
|
|
389
|
+
mockFlowsGet.mockResolvedValue({ ok: true, status: 200, body });
|
|
390
|
+
}
|
|
391
|
+
afterEach(() => {
|
|
392
|
+
mockFlowsGet.mockResolvedValue({ ok: false, status: 404, body: {} });
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const PATHS = [
|
|
396
|
+
{
|
|
397
|
+
path: "the magnet's flows.submitStep",
|
|
398
|
+
serve: servedMagnet,
|
|
399
|
+
write: () => mockSubmitStep,
|
|
400
|
+
answer: { ok: true, status: 200, body: { sessionId: "s1", nextStepKey: null, completed: true } },
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
path: "the Free path's leads.subscribe",
|
|
404
|
+
serve: () => undefined,
|
|
405
|
+
write: () => mockSubscribe,
|
|
406
|
+
answer: { ok: true, body: { id: "subscr_1", persisted: "db" } },
|
|
407
|
+
},
|
|
408
|
+
];
|
|
409
|
+
|
|
410
|
+
it.each(PATHS)("$path: a bot gets the generic error, and nothing is sent", async ({ serve }) => {
|
|
411
|
+
serve();
|
|
412
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
413
|
+
expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toStrictEqual({
|
|
414
|
+
kind: "error",
|
|
415
|
+
message: "Something went wrong, please try again.",
|
|
416
|
+
});
|
|
417
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
418
|
+
expect(mockSubmitStep).not.toHaveBeenCalled();
|
|
419
|
+
expect(mockSubscribe).not.toHaveBeenCalled();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it.each(PATHS)("$path: a check that fails is unavailable, and nothing is sent", async ({ serve }) => {
|
|
423
|
+
serve();
|
|
424
|
+
checkBotIdMock.mockRejectedValue(new Error("boom"));
|
|
425
|
+
expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toStrictEqual({
|
|
426
|
+
kind: "unavailable",
|
|
427
|
+
});
|
|
428
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
429
|
+
expect(mockSubmitStep).not.toHaveBeenCalled();
|
|
430
|
+
expect(mockSubscribe).not.toHaveBeenCalled();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it.each(PATHS)(
|
|
434
|
+
"$path: a human is checked once and sends exactly what the check off sends",
|
|
435
|
+
async ({ serve, write, answer }) => {
|
|
436
|
+
serve();
|
|
437
|
+
checkBotIdMock.mockResolvedValue(HUMAN);
|
|
438
|
+
write().mockResolvedValue(answer);
|
|
439
|
+
delete process.env.VERCEL;
|
|
440
|
+
await subscribeNewsletterAction(null, formWith("a@b.co"));
|
|
441
|
+
process.env.VERCEL = "1";
|
|
442
|
+
expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toEqual({ kind: "success" });
|
|
443
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
444
|
+
expect(write()).toHaveBeenCalledTimes(2);
|
|
445
|
+
expect(write().mock.calls[1]).toEqual(write().mock.calls[0]);
|
|
446
|
+
},
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
it.each(PATHS)("$path: a filled honeypot answers success with no check", async ({ serve }) => {
|
|
450
|
+
serve();
|
|
451
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
452
|
+
const fd = formWith("a@b.co");
|
|
453
|
+
fd.append("ailk_hp", "spam");
|
|
454
|
+
expect(await subscribeNewsletterAction(null, fd)).toEqual({ kind: "success" });
|
|
455
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it.each(PATHS)("$path: a post without consent answers the consent error with no check", async ({ serve }) => {
|
|
459
|
+
serve();
|
|
460
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
461
|
+
const fd = new FormData();
|
|
462
|
+
fd.append("email", "a@b.co");
|
|
463
|
+
expect(await subscribeNewsletterAction(null, fd)).toEqual({
|
|
464
|
+
kind: "error",
|
|
465
|
+
fieldErrors: { consent: "Please accept the terms to subscribe." },
|
|
466
|
+
});
|
|
467
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
468
|
+
});
|
|
469
|
+
});
|