create-ailk 0.21.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/component-catalog.json +37 -3
- package/component-catalog.md +6 -5
- package/dist/add-module.js +9 -5
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-package.js +7 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +19 -5
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/surfaces.js +19 -14
- package/dist/sync-routes.js +3 -2
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.js +5 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/templates/apps/api/CLAUDE.md +15 -6
- 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/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/public-site-resolver.ts +8 -1
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +120 -9
- 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]/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 +17 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +41 -2
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +438 -31
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +168 -57
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +49 -17
- 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]/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/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/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 +5 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +34 -17
- 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 +6 -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 +24 -7
- 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 +38 -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 +34 -1
- package/templates/database/inbox/schema.prisma +17 -2
- 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 +34 -3
- package/versions.json +34 -34
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx
CHANGED
|
@@ -1,281 +1,301 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Tests for AgreementsInterstitial (#8765 D7, AC-2, AC-4, AC-5) — the
|
|
3
|
+
* full-page sign-up-agreement ask the account layout renders in place of the
|
|
4
|
+
* account, and its Server Action `acceptAccountAgreementsAction`.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import { consentStatementText } from "@working-theory/ui/blocks";
|
|
7
|
+
import { CONTACT_FORM_TERMS_STATEMENT } from "@working-theory/validation";
|
|
8
|
+
import {
|
|
9
|
+
fireEvent,
|
|
10
|
+
render,
|
|
11
|
+
screen,
|
|
12
|
+
waitFor,
|
|
13
|
+
within,
|
|
14
|
+
} from "@testing-library/react";
|
|
15
|
+
|
|
16
|
+
const mockPush = jest.fn();
|
|
8
17
|
const mockRefresh = jest.fn();
|
|
9
18
|
jest.mock("next/navigation", () => ({
|
|
10
19
|
__esModule: true,
|
|
11
|
-
useRouter: () => ({ refresh: mockRefresh }),
|
|
20
|
+
useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
|
|
21
|
+
usePathname: () => "/account",
|
|
22
|
+
useSearchParams: () => new URLSearchParams(""),
|
|
12
23
|
}));
|
|
13
24
|
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
jest.mock("../agreements-actions", () => ({
|
|
25
|
+
const mockSignOut = jest.fn();
|
|
26
|
+
jest.mock("better-auth/react", () => ({
|
|
17
27
|
__esModule: true,
|
|
18
|
-
|
|
28
|
+
createAuthClient: () => ({
|
|
29
|
+
signOut: (...args: unknown[]) => mockSignOut(...args),
|
|
30
|
+
}),
|
|
19
31
|
}));
|
|
20
32
|
|
|
21
|
-
const
|
|
33
|
+
const mockAcceptAction = jest.fn();
|
|
22
34
|
// eslint-disable-next-line no-restricted-syntax -- test-seam mock: the Server Action module is a "use server" boundary; the component only passes it through.
|
|
23
|
-
jest.mock("../
|
|
35
|
+
jest.mock("../agreements-actions", () => ({
|
|
24
36
|
__esModule: true,
|
|
25
|
-
|
|
37
|
+
acceptAccountAgreementsAction: (...args: unknown[]) =>
|
|
38
|
+
mockAcceptAction(...args),
|
|
26
39
|
}));
|
|
27
40
|
|
|
28
|
-
import {
|
|
41
|
+
import { consentPoliciesFor } from "../../../../../lib/consent-policies";
|
|
42
|
+
import {
|
|
43
|
+
AgreementsInterstitial,
|
|
44
|
+
type AgreementsInterstitialAgreement,
|
|
45
|
+
} from "../agreements-prompt";
|
|
29
46
|
|
|
30
47
|
const LABELS = {
|
|
31
48
|
titleNew: "Please accept our terms",
|
|
32
49
|
titleUpdated: "We've updated our terms",
|
|
50
|
+
lead: "To keep using your account, accept the agreements below.",
|
|
33
51
|
accept: "Accept",
|
|
52
|
+
signOut: "Sign out",
|
|
34
53
|
error: "We couldn't save that. Try again.",
|
|
35
|
-
|
|
54
|
+
signOutFailed: "We couldn't sign you out. Try again.",
|
|
36
55
|
};
|
|
37
56
|
|
|
38
|
-
const AGREEMENT:
|
|
57
|
+
const AGREEMENT: AgreementsInterstitialAgreement = {
|
|
39
58
|
id: "account_terms",
|
|
40
|
-
statement:
|
|
41
|
-
|
|
42
|
-
version: 1,
|
|
43
|
-
hadPriorGrant: false,
|
|
59
|
+
statement: CONTACT_FORM_TERMS_STATEMENT,
|
|
60
|
+
acceptedVersion: null,
|
|
44
61
|
};
|
|
45
62
|
|
|
63
|
+
function renderInterstitial(
|
|
64
|
+
agreements: AgreementsInterstitialAgreement[] = [AGREEMENT],
|
|
65
|
+
locale = "en",
|
|
66
|
+
extra: { signOutEndpoint?: string; signInHref?: string } = {},
|
|
67
|
+
): void {
|
|
68
|
+
render(
|
|
69
|
+
<AgreementsInterstitial
|
|
70
|
+
locale={locale}
|
|
71
|
+
agreements={agreements}
|
|
72
|
+
labels={LABELS}
|
|
73
|
+
{...extra}
|
|
74
|
+
/>,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const fetchMock = jest.fn();
|
|
79
|
+
const ORIGINAL_FETCH = globalThis.fetch;
|
|
80
|
+
|
|
46
81
|
beforeEach(() => {
|
|
82
|
+
mockPush.mockReset();
|
|
47
83
|
mockRefresh.mockReset();
|
|
48
|
-
|
|
49
|
-
|
|
84
|
+
mockSignOut.mockReset().mockResolvedValue({ data: {}, error: null });
|
|
85
|
+
mockAcceptAction.mockReset();
|
|
86
|
+
fetchMock.mockReset();
|
|
87
|
+
globalThis.fetch = fetchMock;
|
|
50
88
|
});
|
|
51
89
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
render(<AgreementsPrompt missing={[]} labels={LABELS} />);
|
|
55
|
-
expect(screen.queryByTestId("account-agreements-prompt")).not.toBeInTheDocument();
|
|
56
|
-
expect(mockAcceptCarried).not.toHaveBeenCalled();
|
|
57
|
-
});
|
|
90
|
+
afterAll(() => {
|
|
91
|
+
globalThis.fetch = ORIGINAL_FETCH;
|
|
58
92
|
});
|
|
59
93
|
|
|
60
|
-
describe("
|
|
61
|
-
it("
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
expect(
|
|
94
|
+
describe("AgreementsInterstitial — the page (D7)", () => {
|
|
95
|
+
it("renders one h1 — 'Please accept our terms' when nothing was accepted before — and the lead", () => {
|
|
96
|
+
renderInterstitial();
|
|
97
|
+
const page = screen.getByTestId("account-agreements-interstitial");
|
|
98
|
+
const headings = within(page).getAllByRole("heading", { level: 1 });
|
|
99
|
+
expect(headings).toHaveLength(1);
|
|
100
|
+
expect(headings[0]).toHaveTextContent("Please accept our terms");
|
|
101
|
+
expect(page).toHaveTextContent(
|
|
102
|
+
"To keep using your account, accept the agreements below.",
|
|
103
|
+
);
|
|
66
104
|
});
|
|
67
105
|
|
|
68
|
-
it("
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
106
|
+
it("reads 'We've updated our terms' when any entry has an accepted version (AC-4)", () => {
|
|
107
|
+
renderInterstitial([{ ...AGREEMENT, acceptedVersion: 1 }]);
|
|
108
|
+
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
|
109
|
+
"We've updated our terms",
|
|
110
|
+
);
|
|
73
111
|
});
|
|
74
112
|
|
|
75
|
-
it("
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
expect(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
113
|
+
it("draws no element of the retired Inbox card", () => {
|
|
114
|
+
renderInterstitial();
|
|
115
|
+
expect(
|
|
116
|
+
screen.queryByTestId("account-agreements-prompt"),
|
|
117
|
+
).not.toBeInTheDocument();
|
|
118
|
+
expect(
|
|
119
|
+
document.querySelector(
|
|
120
|
+
'[data-testid^="account-agreements-prompt-"][data-testid$="-policy"]',
|
|
121
|
+
),
|
|
122
|
+
).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("AgreementsInterstitial — the statement renders with its links (AC-5, #8761)", () => {
|
|
127
|
+
it.each(["en", "fr"])(
|
|
128
|
+
"%s: the text is the Contact sentence with no markdown, and three labelled links to consentPoliciesFor(locale)",
|
|
129
|
+
(locale) => {
|
|
130
|
+
renderInterstitial([AGREEMENT], locale);
|
|
131
|
+
const statement = screen.getByTestId(
|
|
132
|
+
"account-agreements-statement-account_terms",
|
|
133
|
+
);
|
|
134
|
+
expect(statement.textContent).toBe(
|
|
135
|
+
consentStatementText(CONTACT_FORM_TERMS_STATEMENT),
|
|
136
|
+
);
|
|
137
|
+
expect(statement.textContent).not.toContain("[");
|
|
138
|
+
expect(statement.textContent).not.toContain("](");
|
|
139
|
+
|
|
140
|
+
const policies = consentPoliciesFor(locale);
|
|
141
|
+
const links = (
|
|
142
|
+
[
|
|
143
|
+
["terms", "Terms"],
|
|
144
|
+
["privacy", "Privacy Policy"],
|
|
145
|
+
["working_agreement", "Working Agreement"],
|
|
146
|
+
] as const
|
|
147
|
+
).map(([key, label]) => {
|
|
148
|
+
const link = within(statement).getByTestId(
|
|
149
|
+
`consent-policy-link-${key}`,
|
|
150
|
+
);
|
|
151
|
+
return [
|
|
152
|
+
link.textContent,
|
|
153
|
+
link.getAttribute("href"),
|
|
154
|
+
label,
|
|
155
|
+
policies[key],
|
|
156
|
+
];
|
|
157
|
+
});
|
|
158
|
+
for (const [text, href, label, expected] of links) {
|
|
159
|
+
expect(text).toBe(label);
|
|
160
|
+
expect(href).toBe(expected);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("AgreementsInterstitial — Accept (AC-2)", () => {
|
|
167
|
+
it("is a 44px button that calls the action once with every id, then refreshes", async () => {
|
|
168
|
+
mockAcceptAction.mockResolvedValue({ ok: true });
|
|
169
|
+
renderInterstitial();
|
|
170
|
+
const accept = screen.getByTestId("account-agreements-accept");
|
|
171
|
+
expect(accept).toHaveTextContent("Accept");
|
|
172
|
+
expect(accept.className).toContain("min-h-(--control-height-big)");
|
|
173
|
+
fireEvent.click(accept);
|
|
174
|
+
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
|
|
175
|
+
expect(mockAcceptAction).toHaveBeenCalledTimes(1);
|
|
176
|
+
expect(mockAcceptAction).toHaveBeenCalledWith(["account_terms"]);
|
|
177
|
+
expect(mockAcceptAction.mock.invocationCallOrder[0]).toBeLessThan(
|
|
178
|
+
mockRefresh.mock.invocationCallOrder[0]!,
|
|
84
179
|
);
|
|
85
180
|
});
|
|
86
181
|
|
|
87
|
-
it("
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
182
|
+
it("a refused write shows the error line and does not refresh", async () => {
|
|
183
|
+
mockAcceptAction.mockResolvedValue({ ok: false });
|
|
184
|
+
renderInterstitial();
|
|
185
|
+
fireEvent.click(screen.getByTestId("account-agreements-accept"));
|
|
186
|
+
expect(
|
|
187
|
+
await screen.findByTestId("account-agreements-error"),
|
|
188
|
+
).toHaveTextContent("We couldn't save that. Try again.");
|
|
189
|
+
expect(mockRefresh).not.toHaveBeenCalled();
|
|
190
|
+
expect(screen.getByTestId("account-agreements-accept")).not.toBeDisabled();
|
|
91
191
|
});
|
|
92
192
|
|
|
93
|
-
it("a
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
193
|
+
it("a thrown write shows the error line too", async () => {
|
|
194
|
+
mockAcceptAction.mockRejectedValue(new Error("down"));
|
|
195
|
+
renderInterstitial();
|
|
196
|
+
fireEvent.click(screen.getByTestId("account-agreements-accept"));
|
|
197
|
+
expect(
|
|
198
|
+
await screen.findByTestId("account-agreements-error"),
|
|
199
|
+
).toBeInTheDocument();
|
|
200
|
+
expect(mockRefresh).not.toHaveBeenCalled();
|
|
97
201
|
});
|
|
98
202
|
});
|
|
99
203
|
|
|
100
|
-
describe("
|
|
101
|
-
async
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
204
|
+
describe("AgreementsInterstitial — Sign out (D7, the frame's own logic)", () => {
|
|
205
|
+
it("is a ghost button; without an endpoint it signs out through Better Auth and goes home", async () => {
|
|
206
|
+
renderInterstitial();
|
|
207
|
+
const signOut = screen.getByTestId("account-agreements-sign-out");
|
|
208
|
+
expect(signOut).toHaveTextContent("Sign out");
|
|
209
|
+
fireEvent.click(signOut);
|
|
210
|
+
await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/"));
|
|
211
|
+
expect(mockSignOut).toHaveBeenCalledTimes(1);
|
|
212
|
+
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
|
213
|
+
expect(mockAcceptAction).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
106
215
|
|
|
107
|
-
it("
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
216
|
+
it("with a hub endpoint it POSTs it and lands on the sign-in page", async () => {
|
|
217
|
+
fetchMock.mockResolvedValue({ ok: true });
|
|
218
|
+
renderInterstitial([AGREEMENT], "fr", {
|
|
219
|
+
signOutEndpoint: "/api/hub-session/sign-out",
|
|
220
|
+
signInHref: "/fr/account",
|
|
221
|
+
});
|
|
222
|
+
fireEvent.click(screen.getByTestId("account-agreements-sign-out"));
|
|
223
|
+
await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/fr/account"));
|
|
224
|
+
expect(fetchMock).toHaveBeenCalledWith("/api/hub-session/sign-out", {
|
|
225
|
+
method: "POST",
|
|
226
|
+
credentials: "same-origin",
|
|
227
|
+
});
|
|
228
|
+
expect(mockSignOut).not.toHaveBeenCalled();
|
|
113
229
|
});
|
|
114
230
|
|
|
115
|
-
it("a
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
231
|
+
it("a refused hub sign-out stays and says so", async () => {
|
|
232
|
+
fetchMock.mockResolvedValue({ ok: false, status: 403 });
|
|
233
|
+
renderInterstitial([AGREEMENT], "en", {
|
|
234
|
+
signOutEndpoint: "/api/hub-session/sign-out",
|
|
235
|
+
});
|
|
236
|
+
fireEvent.click(screen.getByTestId("account-agreements-sign-out"));
|
|
237
|
+
expect(
|
|
238
|
+
await screen.findByTestId("account-sign-out-error"),
|
|
239
|
+
).toHaveTextContent("We couldn't sign you out. Try again.");
|
|
240
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
124
241
|
});
|
|
125
242
|
});
|
|
126
243
|
|
|
127
|
-
// ──
|
|
128
|
-
//
|
|
129
|
-
// The real action, loaded past the mock above — exercising the HMAC, the
|
|
130
|
-
// one-hour window and the email-hash binding with real `node:crypto`.
|
|
244
|
+
// ── acceptAccountAgreementsAction — the real module ───────────────────────────
|
|
131
245
|
|
|
132
|
-
|
|
133
|
-
jest.mock("
|
|
246
|
+
const mockAcceptAccountAgreements = jest.fn();
|
|
247
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
134
248
|
__esModule: true,
|
|
135
|
-
|
|
249
|
+
consent: {
|
|
250
|
+
acceptAccountAgreements: (...args: unknown[]) =>
|
|
251
|
+
mockAcceptAccountAgreements(...args),
|
|
252
|
+
},
|
|
136
253
|
}));
|
|
137
254
|
|
|
138
|
-
|
|
139
|
-
jest.mock("@working-theory/api-client", () => ({
|
|
255
|
+
jest.mock("next-intl/server", () => ({
|
|
140
256
|
__esModule: true,
|
|
141
|
-
|
|
257
|
+
getLocale: jest.fn().mockResolvedValue("fr"),
|
|
142
258
|
}));
|
|
143
259
|
|
|
144
|
-
const mockCookieGet = jest.fn();
|
|
145
|
-
const mockCookieDelete = jest.fn();
|
|
146
260
|
jest.mock("next/headers", () => ({
|
|
147
261
|
__esModule: true,
|
|
148
|
-
cookies: jest.fn().mockResolvedValue({
|
|
149
|
-
get: (...args: unknown[]) => mockCookieGet(...args),
|
|
150
|
-
delete: (...args: unknown[]) => mockCookieDelete(...args),
|
|
151
|
-
toString: () => "",
|
|
152
|
-
}),
|
|
262
|
+
cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
|
|
153
263
|
headers: jest.fn().mockResolvedValue(new Headers()),
|
|
154
264
|
}));
|
|
155
265
|
|
|
156
|
-
describe("
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
async function remember(email: string, purposeIds: string[]): Promise<string | undefined> {
|
|
161
|
-
const { rememberSignUpAgreementsAction } = jest.requireActual(
|
|
162
|
-
"../../../(auth)/sign-up/sign-up.actions",
|
|
163
|
-
) as typeof import("../../../(auth)/sign-up/sign-up.actions");
|
|
164
|
-
await rememberSignUpAgreementsAction(email, purposeIds);
|
|
165
|
-
expect(mockCookieSet).toHaveBeenCalledTimes(1);
|
|
166
|
-
return mockCookieSet.mock.calls[0]?.[1] as string | undefined;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const mockCookieSet = jest.fn();
|
|
170
|
-
|
|
171
|
-
beforeEach(() => {
|
|
172
|
-
mockCookieGet.mockReset();
|
|
173
|
-
mockCookieDelete.mockReset();
|
|
174
|
-
mockCookieSet.mockReset();
|
|
175
|
-
mockMemberSetConsent.mockReset().mockResolvedValue({ ok: true, body: { purpose: {} } });
|
|
176
|
-
(jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockReset();
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
// Minting goes through `cookies().set`, which the outer `cookies()` mock
|
|
180
|
-
// does not define — reassign it per test so `remember()` can capture the
|
|
181
|
-
// value, without disturbing the delete-tracking mock the "real action"
|
|
182
|
-
// suite shares.
|
|
183
|
-
beforeEach(() => {
|
|
184
|
-
const headersModule = jest.requireMock("next/headers") as {
|
|
185
|
-
cookies: jest.Mock;
|
|
186
|
-
};
|
|
187
|
-
headersModule.cookies.mockResolvedValue({
|
|
188
|
-
get: (...args: unknown[]) => mockCookieGet(...args),
|
|
189
|
-
set: (...args: unknown[]) => mockCookieSet(...args),
|
|
190
|
-
delete: (...args: unknown[]) => mockCookieDelete(...args),
|
|
191
|
-
toString: () => "",
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
async function accept(missingPurposeIds: string[]) {
|
|
196
|
-
const { acceptCarriedAgreementsAction } = jest.requireActual(
|
|
266
|
+
describe("acceptAccountAgreementsAction (the real module, D7)", () => {
|
|
267
|
+
async function accept(purposeIds: string[]): Promise<{ ok: boolean }> {
|
|
268
|
+
const { acceptAccountAgreementsAction } = jest.requireActual(
|
|
197
269
|
"../agreements-actions",
|
|
198
270
|
) as typeof import("../agreements-actions");
|
|
199
|
-
return
|
|
271
|
+
return acceptAccountAgreementsAction(purposeIds);
|
|
200
272
|
}
|
|
201
273
|
|
|
202
|
-
|
|
203
|
-
const value = await remember(EMAIL, ["account_terms"]);
|
|
204
|
-
mockCookieGet.mockReturnValue({ value });
|
|
205
|
-
(jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockResolvedValue({
|
|
206
|
-
user: { email: EMAIL, emailVerified: true },
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
const result = await accept(["account_terms"]);
|
|
210
|
-
expect(result).toEqual({ accepted: true });
|
|
211
|
-
expect(mockMemberSetConsent).toHaveBeenCalledWith(
|
|
212
|
-
{ purposeId: "account_terms", granted: true, via: "sign_up" },
|
|
213
|
-
expect.anything(),
|
|
214
|
-
);
|
|
215
|
-
expect(mockCookieDelete).toHaveBeenCalledWith(COOKIE_NAME);
|
|
216
|
-
});
|
|
274
|
+
beforeEach(() => mockAcceptAccountAgreements.mockReset());
|
|
217
275
|
|
|
218
|
-
it("
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const result = await accept(["account_terms"]);
|
|
224
|
-
expect(result).toEqual({ accepted: false });
|
|
225
|
-
expect(mockMemberSetConsent).not.toHaveBeenCalled();
|
|
226
|
-
expect(mockCookieDelete).toHaveBeenCalledWith(COOKIE_NAME);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it("another email's hash is refused, even with a verified session", async () => {
|
|
230
|
-
const value = await remember("someone-else@example.com", ["account_terms"]);
|
|
231
|
-
mockCookieGet.mockReturnValue({ value });
|
|
232
|
-
(jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockResolvedValue({
|
|
233
|
-
user: { email: EMAIL, emailVerified: true },
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
const result = await accept(["account_terms"]);
|
|
237
|
-
expect(result).toEqual({ accepted: false });
|
|
238
|
-
expect(mockMemberSetConsent).not.toHaveBeenCalled();
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it("an unverified session email is refused even when the hash would match", async () => {
|
|
242
|
-
const value = await remember(EMAIL, ["account_terms"]);
|
|
243
|
-
mockCookieGet.mockReturnValue({ value });
|
|
244
|
-
(jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockResolvedValue({
|
|
245
|
-
user: { email: EMAIL, emailVerified: false },
|
|
276
|
+
it("posts the ids and the request's locale with the forwarded cookie, and answers ok", async () => {
|
|
277
|
+
mockAcceptAccountAgreements.mockResolvedValue({
|
|
278
|
+
ok: true,
|
|
279
|
+
body: { agreements: [], complete: true },
|
|
246
280
|
});
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
281
|
+
await expect(accept(["account_terms"])).resolves.toEqual({ ok: true });
|
|
282
|
+
expect(mockAcceptAccountAgreements).toHaveBeenCalledWith(
|
|
283
|
+
{ purposeIds: ["account_terms"], locale: "fr" },
|
|
284
|
+
{ headers: { cookie: "session=abc" } },
|
|
285
|
+
);
|
|
250
286
|
});
|
|
251
287
|
|
|
252
|
-
it("a
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
288
|
+
it("a refusal answers ok: false", async () => {
|
|
289
|
+
mockAcceptAccountAgreements.mockResolvedValue({
|
|
290
|
+
ok: false,
|
|
291
|
+
status: 404,
|
|
292
|
+
body: null,
|
|
257
293
|
});
|
|
258
|
-
|
|
259
|
-
const result = await accept(["account_terms", "another_agreement"]);
|
|
260
|
-
expect(result).toEqual({ accepted: false });
|
|
261
|
-
expect(mockMemberSetConsent).not.toHaveBeenCalled();
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
it("an issuedAt more than an hour old is refused", async () => {
|
|
265
|
-
jest.useFakeTimers().setSystemTime(new Date("2026-10-12T00:00:00.000Z"));
|
|
266
|
-
const value = await remember(EMAIL, ["account_terms"]);
|
|
267
|
-
jest.setSystemTime(new Date("2026-10-12T01:00:01.000Z"));
|
|
268
|
-
mockCookieGet.mockReturnValue({ value });
|
|
269
|
-
|
|
270
|
-
const result = await accept(["account_terms"]);
|
|
271
|
-
expect(result).toEqual({ accepted: false });
|
|
272
|
-
jest.useRealTimers();
|
|
294
|
+
await expect(accept(["account_terms"])).resolves.toEqual({ ok: false });
|
|
273
295
|
});
|
|
274
296
|
|
|
275
|
-
it("
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
expect(result).toEqual({ accepted: false });
|
|
279
|
-
expect(mockCookieDelete).not.toHaveBeenCalled();
|
|
297
|
+
it("a thrown post answers ok: false, never a throw", async () => {
|
|
298
|
+
mockAcceptAccountAgreements.mockRejectedValue(new Error("network"));
|
|
299
|
+
await expect(accept(["account_terms"])).resolves.toEqual({ ok: false });
|
|
280
300
|
});
|
|
281
301
|
});
|
|
@@ -36,7 +36,12 @@ jest.mock("../../../../../lib/topology.generated", () => ({
|
|
|
36
36
|
jest.mock("../account-data", () => ({
|
|
37
37
|
__esModule: true,
|
|
38
38
|
accountSession: async () => ({
|
|
39
|
-
ctx: {
|
|
39
|
+
ctx: {
|
|
40
|
+
locale: "en",
|
|
41
|
+
user: { name: "Ada", email: "ada@example.com" },
|
|
42
|
+
t: {},
|
|
43
|
+
init: { headers: {} },
|
|
44
|
+
},
|
|
40
45
|
}),
|
|
41
46
|
loadForYou: async () => ({ items: [], now: "2026-10-12T00:00:00.000Z" }),
|
|
42
47
|
}));
|
|
@@ -52,6 +57,17 @@ jest.mock("../../../../../lib/site-logo", () => ({
|
|
|
52
57
|
siteLogo: () => null,
|
|
53
58
|
}));
|
|
54
59
|
|
|
60
|
+
// #8765 D7 — the layout's agreements read: complete, so the frame renders.
|
|
61
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
62
|
+
__esModule: true,
|
|
63
|
+
consent: {
|
|
64
|
+
accountAgreements: async () => ({
|
|
65
|
+
ok: true,
|
|
66
|
+
body: { agreements: [], complete: true },
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
|
|
55
71
|
const mockFrame = jest.fn();
|
|
56
72
|
// eslint-disable-next-line no-restricted-syntax -- #507: test-seam mock; the layout under test composes these account modules and the suite asserts only the sign-out endpoint it forwards
|
|
57
73
|
jest.mock("../account-frame", () => ({
|