create-ailk 0.19.0 → 0.20.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 +64 -7
- package/dist/add-module.js +7 -2
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +64 -5
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +40 -3
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +98 -3
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/topology.js +29 -1
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
- package/templates/apps/api/src/middleware/auth.ts +16 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
- package/templates/apps/api/src/server.ts +125 -18
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/lead-routing.ts +105 -12
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +121 -18
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +55 -0
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +114 -22
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -19,9 +19,16 @@
|
|
|
19
19
|
* tab to the page (PhoneTabProvider).
|
|
20
20
|
* 5. The For-you items are the aside; none → no aside.
|
|
21
21
|
* 6. No site chrome (Nav, Footer, ThemeToggle) inside the frame.
|
|
22
|
+
* 7. The four Settings pages render alone, inside the shell marker only —
|
|
23
|
+
* no rail, no Inbox, no AppShell frame (#8430): the page draws its own
|
|
24
|
+
* standalone settings frame, as the Admin's does. Membership is an
|
|
25
|
+
* exact, locale-aware match on settings-frame.tsx's `SETTINGS_ROUTES`
|
|
26
|
+
* (#8441): a sub-route of a settings path stays inside the shell.
|
|
27
|
+
* 8. One main landmark (#8441): every settings route and the Inbox, each
|
|
28
|
+
* rendered through this layout with its real page, draw exactly one.
|
|
22
29
|
*/
|
|
23
30
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
24
|
-
import type { ReactElement } from "react";
|
|
31
|
+
import type { ReactElement, ReactNode } from "react";
|
|
25
32
|
|
|
26
33
|
jest.mock("next-intl/server", () => {
|
|
27
34
|
const msgs: unknown = require("../../../../../messages/en.json");
|
|
@@ -65,13 +72,20 @@ const mockMemberNotFound = jest.fn();
|
|
|
65
72
|
jest.mock("@working-theory/api-client", () => ({
|
|
66
73
|
__esModule: true,
|
|
67
74
|
waitlistSignups: { me: jest.fn() },
|
|
68
|
-
|
|
75
|
+
flowCheckouts: { me: jest.fn() },
|
|
76
|
+
subscription: { me: (...args: unknown[]) => mockSubscriptionMe(...args), portalSession: jest.fn() },
|
|
77
|
+
leads: { submit: jest.fn() },
|
|
69
78
|
member: {
|
|
70
79
|
pieces: (...args: unknown[]) => mockMemberNotFound(...args),
|
|
71
80
|
messages: (...args: unknown[]) => mockMemberNotFound(...args),
|
|
72
81
|
purchases: (...args: unknown[]) => mockMemberNotFound(...args),
|
|
73
82
|
appointments: (...args: unknown[]) => mockMemberNotFound(...args),
|
|
83
|
+
// #8441 — the Inbox page's New-menu availability reads, for § one main landmark.
|
|
84
|
+
meetingTypes: (...args: unknown[]) => mockMemberNotFound(...args),
|
|
85
|
+
requestForm: (...args: unknown[]) => mockMemberNotFound(...args),
|
|
74
86
|
},
|
|
87
|
+
hubSignIn: { me: jest.fn() },
|
|
88
|
+
visitorForwardHeaders: () => null,
|
|
75
89
|
}));
|
|
76
90
|
|
|
77
91
|
const mockSignOut = jest.fn();
|
|
@@ -86,7 +100,8 @@ let mockPathname = "/account";
|
|
|
86
100
|
let mockSearch = "";
|
|
87
101
|
jest.mock("next/navigation", () => ({
|
|
88
102
|
__esModule: true,
|
|
89
|
-
useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
|
|
103
|
+
useRouter: () => ({ push: mockPush, refresh: mockRefresh, replace: jest.fn() }),
|
|
104
|
+
redirect: jest.fn(),
|
|
90
105
|
usePathname: () => mockPathname,
|
|
91
106
|
useSearchParams: () => new URLSearchParams(mockSearch),
|
|
92
107
|
}));
|
|
@@ -94,6 +109,8 @@ jest.mock("next/navigation", () => ({
|
|
|
94
109
|
import { usePhoneTab } from "@working-theory/templates/account";
|
|
95
110
|
|
|
96
111
|
import AccountLayout from "../layout";
|
|
112
|
+
import InboxPage from "../page";
|
|
113
|
+
import { SETTINGS_ROUTES, type SettingsTabKey } from "../settings-frame";
|
|
97
114
|
|
|
98
115
|
/** A page that shows the phone tab the frame hands it. */
|
|
99
116
|
function PhoneTabProbe(): ReactElement {
|
|
@@ -128,16 +145,16 @@ function openByPointer(trigger: HTMLElement): void {
|
|
|
128
145
|
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: "mouse" });
|
|
129
146
|
}
|
|
130
147
|
|
|
131
|
-
async function renderLayout(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
});
|
|
148
|
+
async function renderLayout(
|
|
149
|
+
locale = "en",
|
|
150
|
+
children: ReactNode = (
|
|
151
|
+
<>
|
|
152
|
+
<p data-testid="account-child">page</p>
|
|
153
|
+
<PhoneTabProbe />
|
|
154
|
+
</>
|
|
155
|
+
),
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
const element = await AccountLayout({ children, params: Promise.resolve({ locale }) });
|
|
141
158
|
render(element);
|
|
142
159
|
}
|
|
143
160
|
|
|
@@ -217,12 +234,11 @@ describe("the account rail (D13, AC-17)", () => {
|
|
|
217
234
|
|
|
218
235
|
it.each([
|
|
219
236
|
"/account/support",
|
|
220
|
-
// #8334 — the Settings
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
"/account/billing",
|
|
237
|
+
// #8334 — the old Settings path (it redirects to General) selects no
|
|
238
|
+
// row: Settings has no rail row to select. The four Settings pages render
|
|
239
|
+
// no rail at all (#8430, § the settings routes below).
|
|
224
240
|
"/account/settings",
|
|
225
|
-
"/fr/account/
|
|
241
|
+
"/fr/account/support",
|
|
226
242
|
])("no row is selected on %s (#8231 D1, #8334)", async (path) => {
|
|
227
243
|
mockPathname = path;
|
|
228
244
|
await renderLayout(path.startsWith("/fr/") ? "fr" : "en");
|
|
@@ -451,6 +467,96 @@ describe("no session", () => {
|
|
|
451
467
|
});
|
|
452
468
|
});
|
|
453
469
|
|
|
470
|
+
describe("the settings routes take the screen (#8430)", () => {
|
|
471
|
+
it.each([
|
|
472
|
+
["/account/general", "en"],
|
|
473
|
+
["/account/security", "en"],
|
|
474
|
+
["/account/notifications", "en"],
|
|
475
|
+
["/account/privacy", "en"],
|
|
476
|
+
["/account/billing", "en"],
|
|
477
|
+
["/fr/account/billing", "fr"],
|
|
478
|
+
])("on %s the page renders alone — no rail, no Inbox, no AppShell frame", async (path, locale) => {
|
|
479
|
+
mockPathname = path;
|
|
480
|
+
await renderLayout(locale);
|
|
481
|
+
expect(screen.getByTestId("account-child")).toBeInTheDocument();
|
|
482
|
+
expect(screen.queryByTestId("dashboard-shell")).not.toBeInTheDocument();
|
|
483
|
+
expect(screen.queryByTestId("sidebar-desktop")).not.toBeInTheDocument();
|
|
484
|
+
expect(screen.queryByTestId("app-shell-aside")).not.toBeInTheDocument();
|
|
485
|
+
expect(screen.queryByRole("link", { name: "Inbox" })).not.toBeInTheDocument();
|
|
486
|
+
expect(screen.queryByRole("link", { name: "Artifacts" })).not.toBeInTheDocument();
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("keeps the shell marker around the page, so the Admin's tokens still apply (#8337 D4)", async () => {
|
|
490
|
+
mockPathname = "/account/general";
|
|
491
|
+
await renderLayout();
|
|
492
|
+
const markers = document.querySelectorAll("[data-ailk-shell]");
|
|
493
|
+
expect(markers).toHaveLength(1);
|
|
494
|
+
const marker = markers[0] as HTMLElement;
|
|
495
|
+
expect(marker.getAttribute("data-ailk-shell")).toBe("account");
|
|
496
|
+
expect(Array.from(marker.classList)).toEqual(["contents"]);
|
|
497
|
+
expect(within(marker).getByTestId("account-child")).toBeInTheDocument();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it.each([
|
|
501
|
+
"/account",
|
|
502
|
+
"/account/artifacts",
|
|
503
|
+
"/account/support",
|
|
504
|
+
"/account/inbox/purchase/fc_1",
|
|
505
|
+
// #8441 — the match is exact: a sub-route of a settings path is not listed,
|
|
506
|
+
// so it stays inside the shell.
|
|
507
|
+
"/account/billing/history",
|
|
508
|
+
"/account/general/",
|
|
509
|
+
"/fr/account/security/sessions",
|
|
510
|
+
])(
|
|
511
|
+
"%s still renders inside the frame",
|
|
512
|
+
async (path) => {
|
|
513
|
+
mockPathname = path;
|
|
514
|
+
await renderLayout(path.startsWith("/fr/") ? "fr" : "en");
|
|
515
|
+
expect(screen.getByTestId("dashboard-shell")).toBeInTheDocument();
|
|
516
|
+
expect(within(screen.getByTestId("sidebar-desktop")).getByRole("link", { name: "Inbox" })).toBeInTheDocument();
|
|
517
|
+
},
|
|
518
|
+
);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
describe("one main landmark (#8441)", () => {
|
|
522
|
+
type Page = (props: { params: Promise<{ locale: string }> }) => Promise<ReactElement>;
|
|
523
|
+
// Keyed by the list's own type, so a tab added to `SETTINGS_ROUTES` without
|
|
524
|
+
// its page here fails to compile. Billing loads with `import()`:
|
|
525
|
+
// architecture.yaml bans a static import of a relative billing/ path
|
|
526
|
+
// (billing.test.tsx).
|
|
527
|
+
const PAGES: Record<SettingsTabKey, () => Promise<Page>> = {
|
|
528
|
+
general: async () => (await import("../general/page")).default,
|
|
529
|
+
security: async () => (await import("../security/page")).default,
|
|
530
|
+
notifications: async () => (await import("../notifications/page")).default,
|
|
531
|
+
privacy: async () => (await import("../privacy/page")).default,
|
|
532
|
+
billing: async () => (await import("../billing/page")).default,
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
async function renderRoute(path: string, locale: string, page: Page): Promise<void> {
|
|
536
|
+
mockPathname = path;
|
|
537
|
+
await renderLayout(locale, await page({ params: Promise.resolve({ locale }) }));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
it.each(SETTINGS_ROUTES.flatMap(({ key, path }) => [
|
|
541
|
+
[path, "en", key] as const,
|
|
542
|
+
[`/fr${path}`, "fr", key] as const,
|
|
543
|
+
]))("%s draws exactly one <main>", async (path, locale, key) => {
|
|
544
|
+
await renderRoute(path, locale, await PAGES[key]());
|
|
545
|
+
expect(screen.getAllByRole("main")).toHaveLength(1);
|
|
546
|
+
expect(document.querySelectorAll("main")).toHaveLength(1);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it.each([
|
|
550
|
+
["/account", "en"],
|
|
551
|
+
["/fr/account", "fr"],
|
|
552
|
+
])("the Inbox, %s, draws exactly one <main>", async (path, locale) => {
|
|
553
|
+
await renderRoute(path, locale, InboxPage);
|
|
554
|
+
expect(screen.getByTestId("dashboard-shell")).toBeInTheDocument();
|
|
555
|
+
expect(screen.getAllByRole("main")).toHaveLength(1);
|
|
556
|
+
expect(document.querySelectorAll("main")).toHaveLength(1);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
454
560
|
describe("the shell marker (#8337 D4, AC-6)", () => {
|
|
455
561
|
it("with a session, exactly one [data-ailk-shell=\"account\"] wraps the shell", async () => {
|
|
456
562
|
await renderLayout();
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for /[locale]/account/notifications — User settings › Notifications
|
|
3
|
-
* (#8339 D8,
|
|
4
|
-
*
|
|
3
|
+
* (#8339 D8; #8438 D15, AC-8). Always renders (decision A): one
|
|
4
|
+
* `ConsentSwitchList` of the registry's `area: "communications"` purposes,
|
|
5
|
+
* or one empty line with none declared. The cookie-settings link moved to
|
|
6
|
+
* Privacy (#8438 D14) — this tab no longer renders it.
|
|
5
7
|
*/
|
|
6
8
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
9
|
import { tmpdir } from "node:os";
|
|
8
10
|
import { join } from "node:path";
|
|
9
11
|
|
|
10
|
-
import { render, screen } from "@testing-library/react";
|
|
12
|
+
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
11
13
|
|
|
12
14
|
jest.mock("next-intl/server", () => {
|
|
13
15
|
const msgs: unknown = require("../../../../../messages/en.json");
|
|
@@ -29,13 +31,14 @@ jest.mock("next-intl/server", () => {
|
|
|
29
31
|
);
|
|
30
32
|
};
|
|
31
33
|
},
|
|
34
|
+
getLocale: jest.fn().mockResolvedValue("en"),
|
|
32
35
|
};
|
|
33
36
|
});
|
|
34
37
|
|
|
35
38
|
jest.mock("next/headers", () => ({
|
|
36
39
|
__esModule: true,
|
|
37
40
|
headers: jest.fn().mockResolvedValue(new Headers()),
|
|
38
|
-
cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
|
|
41
|
+
cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc", get: () => undefined }),
|
|
39
42
|
}));
|
|
40
43
|
|
|
41
44
|
const mockGetSession = jest.fn();
|
|
@@ -45,13 +48,19 @@ jest.mock("../../../../../auth", () => ({
|
|
|
45
48
|
auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
|
|
46
49
|
}));
|
|
47
50
|
|
|
51
|
+
const mockConsents = jest.fn();
|
|
52
|
+
const mockSetConsent = jest.fn();
|
|
48
53
|
jest.mock("@working-theory/api-client", () => ({
|
|
49
54
|
__esModule: true,
|
|
50
55
|
waitlistSignups: { me: jest.fn() },
|
|
51
56
|
flowCheckouts: { me: jest.fn() },
|
|
52
57
|
subscription: { me: jest.fn(), portalSession: jest.fn() },
|
|
53
58
|
leads: { submit: jest.fn() },
|
|
54
|
-
member: {
|
|
59
|
+
member: {
|
|
60
|
+
pieces: jest.fn().mockResolvedValue({ ok: true, body: [] }),
|
|
61
|
+
consents: (...args: unknown[]) => mockConsents(...args),
|
|
62
|
+
setConsent: (...args: unknown[]) => mockSetConsent(...args),
|
|
63
|
+
},
|
|
55
64
|
hubSignIn: { me: jest.fn() },
|
|
56
65
|
visitorForwardHeaders: () => null,
|
|
57
66
|
}));
|
|
@@ -75,6 +84,31 @@ const SESSION = {
|
|
|
75
84
|
},
|
|
76
85
|
};
|
|
77
86
|
|
|
87
|
+
const ok = <T,>(body: T) => ({ ok: true, body });
|
|
88
|
+
|
|
89
|
+
function communicationsPurpose(
|
|
90
|
+
overrides: Partial<{
|
|
91
|
+
id: string;
|
|
92
|
+
kind: "consent" | "preference";
|
|
93
|
+
effective: boolean;
|
|
94
|
+
state: { granted: boolean; at: string; source: string; policyVersion: number } | null;
|
|
95
|
+
}> = {},
|
|
96
|
+
) {
|
|
97
|
+
return {
|
|
98
|
+
id: overrides.id ?? "marketing_email",
|
|
99
|
+
kind: overrides.kind ?? "consent",
|
|
100
|
+
area: "communications" as const,
|
|
101
|
+
required: false,
|
|
102
|
+
signUp: false,
|
|
103
|
+
description: "Product news and offers.",
|
|
104
|
+
statement: "Receive product news and offers by email.",
|
|
105
|
+
policyHref: "/privacy",
|
|
106
|
+
policyVersion: 1,
|
|
107
|
+
state: overrides.state === undefined ? null : overrides.state,
|
|
108
|
+
effective: overrides.effective ?? false,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
78
112
|
let contentRoot: string;
|
|
79
113
|
|
|
80
114
|
beforeEach(() => {
|
|
@@ -82,6 +116,8 @@ beforeEach(() => {
|
|
|
82
116
|
process.env.AILK_CONTENT_ROOT = contentRoot;
|
|
83
117
|
writeFileSync(join(contentRoot, "_site.mdx"), '---\nsiteName: "Test"\n---\n');
|
|
84
118
|
mockGetSession.mockReset().mockResolvedValue(SESSION);
|
|
119
|
+
mockConsents.mockReset().mockResolvedValue(ok({ purposes: [] }));
|
|
120
|
+
mockSetConsent.mockReset();
|
|
85
121
|
});
|
|
86
122
|
|
|
87
123
|
afterEach(() => {
|
|
@@ -98,29 +134,91 @@ describe("the frame (D7, D8)", () => {
|
|
|
98
134
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Notifications");
|
|
99
135
|
expect(screen.getByTestId("account-settings-back")).toHaveAttribute("href", "/account");
|
|
100
136
|
expect(screen.getByTestId("account-settings-rail-notifications")).toHaveAttribute("aria-current", "page");
|
|
101
|
-
expect(screen.getByTestId("account-settings-rail-
|
|
137
|
+
expect(screen.getByTestId("account-settings-rail-privacy")).not.toHaveAttribute("aria-current");
|
|
102
138
|
});
|
|
103
139
|
});
|
|
104
140
|
|
|
105
|
-
describe("
|
|
106
|
-
it("
|
|
141
|
+
describe("the standalone shape (#8430)", () => {
|
|
142
|
+
it("takes the screen as the Admin's settings do: the rail column and the pane are the page's own landmarks", async () => {
|
|
107
143
|
await renderPage();
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
144
|
+
const settings = screen.getByTestId("account-settings");
|
|
145
|
+
expect(settings).toHaveClass("min-h-screen");
|
|
146
|
+
const column = within(settings).getByRole("complementary");
|
|
147
|
+
expect(column.firstElementChild).toBe(screen.getByTestId("account-settings-back"));
|
|
148
|
+
expect(column).toContainElement(screen.getByTestId("account-settings-rail"));
|
|
149
|
+
expect(within(settings).getByRole("main")).toContainElement(
|
|
150
|
+
screen.getByRole("heading", { level: 1 }),
|
|
111
151
|
);
|
|
112
152
|
});
|
|
153
|
+
});
|
|
113
154
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
"
|
|
155
|
+
describe("AC-8 — always present, one empty line with no communications purposes declared", () => {
|
|
156
|
+
it("renders exactly one account-notifications-empty line and no cookie link", async () => {
|
|
157
|
+
await renderPage();
|
|
158
|
+
expect(screen.getByTestId("account-notifications-empty")).toHaveTextContent(
|
|
159
|
+
"There's nothing to set here yet.",
|
|
119
160
|
);
|
|
161
|
+
expect(screen.queryByTestId("account-cookie-settings")).not.toBeInTheDocument();
|
|
162
|
+
expect(screen.queryByTestId("account-notifications")).not.toBeInTheDocument();
|
|
120
163
|
});
|
|
121
164
|
|
|
122
|
-
it("
|
|
165
|
+
it("the tab link is present in the frame regardless", async () => {
|
|
166
|
+
await renderPage();
|
|
167
|
+
expect(screen.getByTestId("account-settings-rail-notifications")).toBeInTheDocument();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("AC-8 — a declared communications preference defaults on with no row", () => {
|
|
172
|
+
it("renders one switch, on", async () => {
|
|
173
|
+
mockConsents.mockResolvedValue(
|
|
174
|
+
ok({ purposes: [communicationsPurpose({ kind: "preference", effective: true, state: null })] }),
|
|
175
|
+
);
|
|
176
|
+
await renderPage();
|
|
177
|
+
expect(screen.queryByTestId("account-notifications-empty")).not.toBeInTheDocument();
|
|
178
|
+
expect(screen.getByTestId("account-notifications-marketing_email-switch")).toBeChecked();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("AC-8 — a declared communications consent defaults off with no row", () => {
|
|
183
|
+
it("renders one switch, off", async () => {
|
|
184
|
+
mockConsents.mockResolvedValue(
|
|
185
|
+
ok({ purposes: [communicationsPurpose({ kind: "consent", effective: false, state: null })] }),
|
|
186
|
+
);
|
|
187
|
+
await renderPage();
|
|
188
|
+
expect(screen.getByTestId("account-notifications-marketing_email-switch")).not.toBeChecked();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("toggling calls setConsentAction through member.setConsent", async () => {
|
|
192
|
+
mockConsents.mockResolvedValue(
|
|
193
|
+
ok({ purposes: [communicationsPurpose({ kind: "consent", effective: false, state: null })] }),
|
|
194
|
+
);
|
|
195
|
+
mockSetConsent.mockResolvedValue(
|
|
196
|
+
ok({
|
|
197
|
+
purpose: communicationsPurpose({
|
|
198
|
+
kind: "consent",
|
|
199
|
+
effective: true,
|
|
200
|
+
state: { granted: true, at: "2026-10-12T12:00:00.000Z", source: "account_settings", policyVersion: 1 },
|
|
201
|
+
}),
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
await renderPage();
|
|
205
|
+
fireEvent.click(screen.getByTestId("account-notifications-marketing_email-switch"));
|
|
206
|
+
await waitFor(() => expect(mockSetConsent).toHaveBeenCalledTimes(1));
|
|
207
|
+
expect(mockSetConsent).toHaveBeenCalledWith(
|
|
208
|
+
{ purposeId: "marketing_email", granted: true, locale: "en" },
|
|
209
|
+
expect.objectContaining({ headers: expect.objectContaining({ cookie: "session=abc" }) }),
|
|
210
|
+
);
|
|
211
|
+
expect(await screen.findByTestId("account-notifications-marketing_email-date")).toHaveTextContent(
|
|
212
|
+
"Given Oct 12, 2026",
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("a failed consents read", () => {
|
|
218
|
+
it("shows the unavailable line, not a broken switch list", async () => {
|
|
219
|
+
mockConsents.mockResolvedValue({ ok: false, status: 503, body: null });
|
|
123
220
|
await renderPage();
|
|
124
|
-
expect(screen.
|
|
221
|
+
expect(screen.getByTestId("account-notifications-unavailable")).toBeInTheDocument();
|
|
222
|
+
expect(screen.queryByTestId("account-notifications-empty")).not.toBeInTheDocument();
|
|
125
223
|
});
|
|
126
224
|
});
|