create-ailk 0.20.0 → 0.22.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 +21 -1
- package/component-catalog.json +220 -8
- package/component-catalog.md +21 -9
- package/dist/add-module.js +9 -5
- package/dist/apply-design.d.ts +9 -0
- package/dist/apply-design.js +78 -31
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-home.d.ts +54 -11
- package/dist/design-home.js +160 -22
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.d.ts +6 -1
- package/dist/lib/design-checks.js +23 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +63 -25
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/site-roster.d.ts +4 -2
- package/dist/site-roster.js +7 -5
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/starter-grid.js +16 -0
- package/dist/surfaces.js +30 -14
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.js +124 -1
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.d.ts +6 -1
- package/dist/update.js +125 -18
- package/package.json +3 -3
- package/site-templates/_base/ailk-template.json +2 -2
- package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
- package/templates/README.md +26 -25
- package/templates/apps/api/CLAUDE.md +20 -7
- package/templates/apps/api/jest.config.cjs +1 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/openapi/spec.ts +30 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/account.ts +266 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +161 -7
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/lead-routing.ts +337 -8
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/person.ts +40 -8
- package/templates/apps/api/src/services/plan-offerings.ts +59 -0
- package/templates/apps/api/src/services/project-listings.ts +6 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
- package/templates/apps/mcp/jest.config.cjs +1 -0
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +139 -3
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
- package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/jest.config.cjs +1 -0
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/checkout-config.ts +27 -0
- package/templates/apps/web/lib/checkout.ts +93 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +192 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +40 -12
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/database/CHANGELOG.md +47 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/seed.test.ts +72 -2
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/jest.config.cjs +1 -0
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +60 -0
- package/templates/tools/jest/pin-timezone.cjs +34 -0
- package/versions.json +34 -34
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The job page's apply action (#8746 D4, D5; AC-5): a submission writes one
|
|
3
|
+
* lead through the site's lead writer with source `flow:job-application`,
|
|
4
|
+
* `name` = first + last, and the posting's slug as the hidden `job` value.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
__resetRateLimit,
|
|
9
|
+
checkRateLimit,
|
|
10
|
+
} from "../../../../src/lib/rate-limit";
|
|
11
|
+
import { captureLeadAction } from "../[slug]/apply.actions";
|
|
12
|
+
|
|
13
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
14
|
+
leads: { submit: jest.fn() },
|
|
15
|
+
visitorForwardHeaders: jest.requireActual("@working-theory/api-client")
|
|
16
|
+
.visitorForwardHeaders,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
jest.mock("next/headers", () => ({
|
|
20
|
+
__esModule: true,
|
|
21
|
+
headers: jest.fn().mockResolvedValue({ get: () => null }),
|
|
22
|
+
cookies: jest.fn().mockResolvedValue({ get: () => undefined }),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const mockGetLocale = jest.fn();
|
|
26
|
+
const mockGetTranslations = jest.fn();
|
|
27
|
+
jest.mock("next-intl/server", () => ({
|
|
28
|
+
getLocale: (...args: unknown[]) => mockGetLocale(...args),
|
|
29
|
+
getTranslations: (...args: unknown[]) => mockGetTranslations(...args),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const mockInviteHref = jest.fn();
|
|
33
|
+
// eslint-disable-next-line no-restricted-syntax -- accountInviteHref reads the generated topology (build output); this file asserts the action's own decision
|
|
34
|
+
jest.mock("../../../../lib/account-invite", () => ({
|
|
35
|
+
accountInviteHref: (...args: unknown[]) => mockInviteHref(...args),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
import { leads } from "@working-theory/api-client";
|
|
39
|
+
import { headers } from "next/headers";
|
|
40
|
+
const mockSubmit = leads.submit as jest.Mock;
|
|
41
|
+
const mockHeaders = headers as jest.Mock;
|
|
42
|
+
|
|
43
|
+
const JOB = "content-strategist";
|
|
44
|
+
|
|
45
|
+
function form(fields: Record<string, string>): FormData {
|
|
46
|
+
const fd = new FormData();
|
|
47
|
+
for (const [key, value] of Object.entries(fields)) fd.append(key, value);
|
|
48
|
+
return fd;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const VALID = {
|
|
52
|
+
first_name: "Ada",
|
|
53
|
+
last_name: "Lovelace",
|
|
54
|
+
email: "ada@example.com",
|
|
55
|
+
consent: "true",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type SubmitBody = {
|
|
59
|
+
source: string;
|
|
60
|
+
email: string;
|
|
61
|
+
payload: Record<string, unknown>;
|
|
62
|
+
};
|
|
63
|
+
const submitted = (): SubmitBody => mockSubmit.mock.calls[0][0] as SubmitBody;
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
mockSubmit.mockResolvedValue({ ok: true, status: 201, body: { ok: true } });
|
|
67
|
+
mockGetLocale.mockResolvedValue("en");
|
|
68
|
+
mockGetTranslations.mockResolvedValue((key: string) =>
|
|
69
|
+
key === "trackInAccount" ? "Track this in your account" : key,
|
|
70
|
+
);
|
|
71
|
+
mockInviteHref.mockReturnValue("/sign-in?next=%2Faccount&email=ada%40example.com");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
jest.clearAllMocks();
|
|
76
|
+
mockHeaders.mockResolvedValue({ get: () => null });
|
|
77
|
+
__resetRateLimit();
|
|
78
|
+
delete process.env.AILK_LEAD_SITE;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("captureLeadAction on a job page (#8746 AC-5)", () => {
|
|
82
|
+
it("writes one flow:job-application lead with name = first + last and the hidden job slug", async () => {
|
|
83
|
+
const result = await captureLeadAction(JOB, null, form(VALID));
|
|
84
|
+
|
|
85
|
+
expect(result).toEqual({ kind: "success" });
|
|
86
|
+
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
87
|
+
const body = submitted();
|
|
88
|
+
expect(body.source).toBe("flow:job-application");
|
|
89
|
+
expect(body.email).toBe("ada@example.com");
|
|
90
|
+
expect(body.payload).toEqual({
|
|
91
|
+
name: "Ada Lovelace",
|
|
92
|
+
first_name: "Ada",
|
|
93
|
+
last_name: "Lovelace",
|
|
94
|
+
job: JOB,
|
|
95
|
+
page: "careers",
|
|
96
|
+
});
|
|
97
|
+
expect(body).not.toHaveProperty("siteId");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("carries phone and a LinkedIn URL, a bare domain gaining https://", async () => {
|
|
101
|
+
await captureLeadAction(
|
|
102
|
+
JOB,
|
|
103
|
+
null,
|
|
104
|
+
form({
|
|
105
|
+
...VALID,
|
|
106
|
+
phone: "+1 555 0100",
|
|
107
|
+
linkedin_url: "linkedin.com/in/ada",
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
expect(submitted().payload).toMatchObject({
|
|
111
|
+
phone: "+1 555 0100",
|
|
112
|
+
linkedin_url: "https://linkedin.com/in/ada",
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("keeps an extra field the owner added to the magnet, and no field overrides job, name or source", async () => {
|
|
117
|
+
await captureLeadAction(
|
|
118
|
+
JOB,
|
|
119
|
+
null,
|
|
120
|
+
form({
|
|
121
|
+
...VALID,
|
|
122
|
+
cover_letter: "Hello",
|
|
123
|
+
job: "other-job",
|
|
124
|
+
name: "Mallory",
|
|
125
|
+
source: "web",
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
const body = submitted();
|
|
129
|
+
expect(body.source).toBe("flow:job-application");
|
|
130
|
+
expect(body.payload).toMatchObject({
|
|
131
|
+
cover_letter: "Hello",
|
|
132
|
+
job: JOB,
|
|
133
|
+
name: "Ada Lovelace",
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it.each([
|
|
138
|
+
["first_name", { ...VALID, first_name: " " }],
|
|
139
|
+
["last_name", { ...VALID, last_name: "" }],
|
|
140
|
+
["email", { ...VALID, email: "not-an-email" }],
|
|
141
|
+
["linkedin_url", { ...VALID, linkedin_url: "not a url" }],
|
|
142
|
+
["linkedin_url", { ...VALID, linkedin_url: "javascript:alert(1)" }],
|
|
143
|
+
[
|
|
144
|
+
"linkedin_url",
|
|
145
|
+
{ ...VALID, linkedin_url: "https://linkedin.com@evil.example/in/ada" },
|
|
146
|
+
],
|
|
147
|
+
["consent", { ...VALID, consent: "" }],
|
|
148
|
+
])(
|
|
149
|
+
"refuses a bad %s with a field error and sends nothing",
|
|
150
|
+
async (field, fields) => {
|
|
151
|
+
const result = await captureLeadAction(JOB, null, form(fields));
|
|
152
|
+
expect(result).toMatchObject({
|
|
153
|
+
kind: "error",
|
|
154
|
+
fieldErrors: { [field]: expect.any(String) },
|
|
155
|
+
});
|
|
156
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
it("refuses a job value that is not a slug, sending nothing", async () => {
|
|
161
|
+
const result = await captureLeadAction("../../etc", null, form(VALID));
|
|
162
|
+
expect(result).toMatchObject({ kind: "error" });
|
|
163
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it.each([[["eng-lead"]], [123]])(
|
|
167
|
+
"refuses a bound job of the wrong type (%p), sending nothing",
|
|
168
|
+
async (job) => {
|
|
169
|
+
// eslint-disable-next-line no-restricted-syntax -- a tampered bound argument is exactly a value of the wrong type
|
|
170
|
+
const tampered = job as unknown as string;
|
|
171
|
+
const result = await captureLeadAction(tampered, null, form(VALID));
|
|
172
|
+
expect(result).toMatchObject({ kind: "error" });
|
|
173
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
it("reads host:port without a scheme as a bare domain", async () => {
|
|
178
|
+
await captureLeadAction(
|
|
179
|
+
JOB,
|
|
180
|
+
null,
|
|
181
|
+
form({ ...VALID, linkedin_url: "www.linkedin.com:443/in/ada" }),
|
|
182
|
+
);
|
|
183
|
+
expect(submitted().payload).toMatchObject({
|
|
184
|
+
linkedin_url: "https://www.linkedin.com:443/in/ada",
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("shares /contact's per-visitor bucket", async () => {
|
|
189
|
+
mockHeaders.mockResolvedValue({
|
|
190
|
+
get: (name: string) =>
|
|
191
|
+
name === "x-forwarded-for" ? "203.0.113.7" : null,
|
|
192
|
+
});
|
|
193
|
+
for (let i = 0; i < 30; i += 1)
|
|
194
|
+
checkRateLimit("contact:203.0.113.7", 30, 10 * 60 * 1000);
|
|
195
|
+
expect(await captureLeadAction(JOB, null, form(VALID))).toMatchObject({
|
|
196
|
+
kind: "error",
|
|
197
|
+
});
|
|
198
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("answers a filled honeypot as success and sends nothing", async () => {
|
|
202
|
+
const result = await captureLeadAction(
|
|
203
|
+
JOB,
|
|
204
|
+
null,
|
|
205
|
+
form({ ...VALID, ailk_hp: "bot" }),
|
|
206
|
+
);
|
|
207
|
+
expect(result).toEqual({ kind: "success" });
|
|
208
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("maps lead_capture_not_configured and an unreachable API to unavailable", async () => {
|
|
212
|
+
mockSubmit.mockResolvedValueOnce({
|
|
213
|
+
ok: false,
|
|
214
|
+
status: 503,
|
|
215
|
+
body: { error: "lead_capture_not_configured" },
|
|
216
|
+
});
|
|
217
|
+
expect(await captureLeadAction(JOB, null, form(VALID))).toEqual({
|
|
218
|
+
kind: "unavailable",
|
|
219
|
+
});
|
|
220
|
+
mockSubmit.mockRejectedValueOnce(new Error("fetch failed"));
|
|
221
|
+
expect(await captureLeadAction(JOB, null, form(VALID))).toEqual({
|
|
222
|
+
kind: "unavailable",
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("answers any other refusal with the generic error", async () => {
|
|
227
|
+
mockSubmit.mockResolvedValueOnce({
|
|
228
|
+
ok: false,
|
|
229
|
+
status: 400,
|
|
230
|
+
body: { error: "invalid" },
|
|
231
|
+
});
|
|
232
|
+
expect(await captureLeadAction(JOB, null, form(VALID))).toMatchObject({
|
|
233
|
+
kind: "error",
|
|
234
|
+
message: expect.any(String),
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("limits one visitor to 30 applications per 10 minutes", async () => {
|
|
239
|
+
mockHeaders.mockResolvedValue({
|
|
240
|
+
get: (name: string) =>
|
|
241
|
+
name === "x-forwarded-for" ? "203.0.113.9" : null,
|
|
242
|
+
});
|
|
243
|
+
for (let i = 0; i < 30; i += 1) {
|
|
244
|
+
expect(await captureLeadAction(JOB, null, form(VALID))).toEqual({
|
|
245
|
+
kind: "success",
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
expect(await captureLeadAction(JOB, null, form(VALID))).toMatchObject({
|
|
249
|
+
kind: "error",
|
|
250
|
+
});
|
|
251
|
+
expect(mockSubmit).toHaveBeenCalledTimes(30);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("the thank-you's account link (#8771 AC-6)", () => {
|
|
256
|
+
const invite = () =>
|
|
257
|
+
mockSubmit.mockResolvedValue({
|
|
258
|
+
ok: true,
|
|
259
|
+
status: 201,
|
|
260
|
+
body: { ok: true, next: { completion: "invite" } },
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("an invite answer returns the prefilled link and label", async () => {
|
|
264
|
+
invite();
|
|
265
|
+
|
|
266
|
+
const result = await captureLeadAction(JOB, null, form(VALID));
|
|
267
|
+
|
|
268
|
+
expect(result).toEqual({
|
|
269
|
+
kind: "success",
|
|
270
|
+
next: {
|
|
271
|
+
href: "/sign-in?next=%2Faccount&email=ada%40example.com",
|
|
272
|
+
label: "Track this in your account",
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
expect(mockInviteHref).toHaveBeenCalledWith("en", "ada@example.com");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it.each([
|
|
279
|
+
["no next", { ok: true }],
|
|
280
|
+
["a checkout next", { ok: true, next: { completion: "checkout", checkoutOffering: "retainer" } }],
|
|
281
|
+
])("%s: exactly { kind: 'success' }", async (_name, body) => {
|
|
282
|
+
mockSubmit.mockResolvedValue({ ok: true, status: 201, body });
|
|
283
|
+
|
|
284
|
+
await expect(captureLeadAction(JOB, null, form(VALID))).resolves.toEqual({ kind: "success" });
|
|
285
|
+
expect(mockInviteHref).not.toHaveBeenCalled();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("a site with no sign-in (href null) is exactly { kind: 'success' }", async () => {
|
|
289
|
+
invite();
|
|
290
|
+
mockInviteHref.mockReturnValue(null);
|
|
291
|
+
await expect(captureLeadAction(JOB, null, form(VALID))).resolves.toEqual({ kind: "success" });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it.each([
|
|
295
|
+
["getLocale", () => mockGetLocale.mockRejectedValue(new Error("x"))],
|
|
296
|
+
["accountInviteHref", () => mockInviteHref.mockImplementation(() => { throw new Error("x"); })],
|
|
297
|
+
["getTranslations", () => mockGetTranslations.mockRejectedValue(new Error("x"))],
|
|
298
|
+
])("a throw from %s after the write is still a success", async (_name, arm) => {
|
|
299
|
+
invite();
|
|
300
|
+
arm();
|
|
301
|
+
await expect(captureLeadAction(JOB, null, form(VALID))).resolves.toEqual({ kind: "success" });
|
|
302
|
+
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("a filled honeypot is exactly { kind: 'success' } and writes nothing", async () => {
|
|
306
|
+
const result = await captureLeadAction(JOB, null, form({ ...VALID, ailk_hp: "http://x" }));
|
|
307
|
+
expect(result).toEqual({ kind: "success" });
|
|
308
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jobApplicationFormFromConfig (#8746 D4, D5; AC-4): the Job application
|
|
3
|
+
* magnet's field step mapped onto the shared form block, and the built-in
|
|
4
|
+
* form that stands in for an absent or switched-off magnet.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
jobApplicationFlowConfig,
|
|
8
|
+
type FlowConfig,
|
|
9
|
+
} from "@working-theory/validation";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
jobApplicationFormFallback,
|
|
13
|
+
jobApplicationFormFromConfig,
|
|
14
|
+
} from "../[slug]/job-application-form";
|
|
15
|
+
|
|
16
|
+
// jsdom has no `structuredClone`; the config is plain JSON.
|
|
17
|
+
const clone = (): FlowConfig =>
|
|
18
|
+
JSON.parse(JSON.stringify(jobApplicationFlowConfig)) as FlowConfig;
|
|
19
|
+
|
|
20
|
+
describe("jobApplicationFormFromConfig (#8746 D5, AC-4)", () => {
|
|
21
|
+
it("maps the seeded config to First, Last, Email, Phone and LinkedIn URL, with the step's title and submit label", () => {
|
|
22
|
+
const view = jobApplicationFormFromConfig(jobApplicationFlowConfig, "en");
|
|
23
|
+
expect(view?.title).toBe("Job application");
|
|
24
|
+
expect(view?.fields).toEqual([
|
|
25
|
+
{
|
|
26
|
+
name: "first_name",
|
|
27
|
+
type: "text",
|
|
28
|
+
label: "First name",
|
|
29
|
+
required: true,
|
|
30
|
+
colSpan: 1,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "last_name",
|
|
34
|
+
type: "text",
|
|
35
|
+
label: "Last name",
|
|
36
|
+
required: true,
|
|
37
|
+
colSpan: 1,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "email",
|
|
41
|
+
type: "email",
|
|
42
|
+
label: "Email",
|
|
43
|
+
required: true,
|
|
44
|
+
colSpan: 1,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "phone",
|
|
48
|
+
type: "tel",
|
|
49
|
+
label: "Phone",
|
|
50
|
+
required: false,
|
|
51
|
+
colSpan: 1,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "linkedin_url",
|
|
55
|
+
type: "url",
|
|
56
|
+
label: "LinkedIn URL",
|
|
57
|
+
required: false,
|
|
58
|
+
colSpan: 2,
|
|
59
|
+
},
|
|
60
|
+
]);
|
|
61
|
+
expect(view?.submitLabel).toBe("Submit application");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("keeps an owner's edited label and an added full-width field", () => {
|
|
65
|
+
const config = clone();
|
|
66
|
+
const step = config.steps[0] as Extract<
|
|
67
|
+
FlowConfig["steps"][number],
|
|
68
|
+
{ type: "field" }
|
|
69
|
+
>;
|
|
70
|
+
step.fields[4]!.copy = { label: { en: "Portfolio" } };
|
|
71
|
+
step.fields.push({
|
|
72
|
+
name: "cover_letter",
|
|
73
|
+
inputType: "textarea",
|
|
74
|
+
copy: { label: { en: "Cover letter" } },
|
|
75
|
+
});
|
|
76
|
+
const view = jobApplicationFormFromConfig(config, "en");
|
|
77
|
+
expect(view?.fields.map((f) => [f.name, f.label, f.colSpan])).toEqual([
|
|
78
|
+
["first_name", "First name", 1],
|
|
79
|
+
["last_name", "Last name", 1],
|
|
80
|
+
["email", "Email", 1],
|
|
81
|
+
["phone", "Phone", 1],
|
|
82
|
+
["linkedin_url", "Portfolio", 2],
|
|
83
|
+
["cover_letter", "Cover letter", 2],
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it.each(["first_name", "last_name", "email"])(
|
|
88
|
+
"answers null when the step has no %s field",
|
|
89
|
+
(name) => {
|
|
90
|
+
const config = clone();
|
|
91
|
+
const step = config.steps[0] as Extract<
|
|
92
|
+
FlowConfig["steps"][number],
|
|
93
|
+
{ type: "field" }
|
|
94
|
+
>;
|
|
95
|
+
step.fields = step.fields.filter((f) => f.name !== name);
|
|
96
|
+
expect(jobApplicationFormFromConfig(config, "en")).toBeNull();
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
it("answers null when the first step is not a field step", () => {
|
|
101
|
+
const config = clone();
|
|
102
|
+
(config.steps[0] as { type: string }).type = "segmentation";
|
|
103
|
+
expect(jobApplicationFormFromConfig(config, "en")).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("a consent field on the magnet (#8746 D5)", () => {
|
|
108
|
+
it("labels the terms box with the step's first consent field, which is not drawn as a field", () => {
|
|
109
|
+
const config = clone();
|
|
110
|
+
const step = config.steps[0] as Extract<
|
|
111
|
+
FlowConfig["steps"][number],
|
|
112
|
+
{ type: "field" }
|
|
113
|
+
>;
|
|
114
|
+
step.fields.push({
|
|
115
|
+
name: "retention",
|
|
116
|
+
inputType: "consent",
|
|
117
|
+
required: true,
|
|
118
|
+
purposes: ["service_terms"],
|
|
119
|
+
copy: {
|
|
120
|
+
label: {
|
|
121
|
+
en: "I agree my application is kept for 12 months under the [Privacy Policy](privacy).",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const view = jobApplicationFormFromConfig(config, "en");
|
|
126
|
+
expect(view?.consentStatement).toBe(
|
|
127
|
+
"I agree my application is kept for 12 months under the [Privacy Policy](privacy).",
|
|
128
|
+
);
|
|
129
|
+
expect(view?.fields.map((f) => f.name)).not.toContain("retention");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("the seeded config carries none, so the page keeps the catalog's statement", () => {
|
|
133
|
+
expect(jobApplicationFormFallback("en").consentStatement).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("jobApplicationFormFallback (#8746 D5, AC-4)", () => {
|
|
138
|
+
it("is the seeded config's own form", () => {
|
|
139
|
+
expect(jobApplicationFormFallback("en")).toEqual(
|
|
140
|
+
jobApplicationFormFromConfig(jobApplicationFlowConfig, "en"),
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
});
|