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
|
@@ -25,11 +25,16 @@
|
|
|
25
25
|
* there is no subscription without reading `subscription.me`.
|
|
26
26
|
* - #8217 AC-1 (spec docs/specs/8217-spoke-billing-spoke-checkout-reads-spec.md
|
|
27
27
|
* D10–D12): Billing reads the seam's `loadSpokeBilling` — the plan, the
|
|
28
|
-
* card and Manage
|
|
28
|
+
* card and Manage billing for a subscription; the plain none line with
|
|
29
29
|
* no button for none or a spoke without the action (`null`); the
|
|
30
30
|
* unreadable line; the error row for a failed read — and never calls
|
|
31
31
|
* `subscription.me` or `member.billing` itself. `openBillingPortal`
|
|
32
32
|
* redirects to the loader's portal URL, and to nothing without one.
|
|
33
|
+
* - #8431 AC-4 (spec docs/specs/8431-account-security-tab-human-sign-spec.md
|
|
34
|
+
* D1–D3): Security says the hub manages sign-in and links to the hub's
|
|
35
|
+
* own Security page, offers no password control and never reads the
|
|
36
|
+
* seam's accounts; Log out POSTs the spoke's sign-out endpoint, never
|
|
37
|
+
* Better Auth, and lands on `/account` only when it succeeded.
|
|
33
38
|
*/
|
|
34
39
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
35
40
|
import { tmpdir } from "node:os";
|
|
@@ -73,6 +78,7 @@ jest.mock("next/headers", () => ({
|
|
|
73
78
|
|
|
74
79
|
const mockReadSession = jest.fn();
|
|
75
80
|
const mockLoadSpokeBilling = jest.fn();
|
|
81
|
+
const mockReadOwnAccounts = jest.fn();
|
|
76
82
|
// eslint-disable-next-line no-restricted-syntax -- #507: the seam is generated per build (#7894 D8); its `signIn: "hub"` render is the boundary under test, and no env or HTTP surface selects it.
|
|
77
83
|
jest.mock("../../../../../lib/topology.generated", () => ({
|
|
78
84
|
__esModule: true,
|
|
@@ -86,6 +92,8 @@ jest.mock("../../../../../lib/topology.generated", () => ({
|
|
|
86
92
|
registerSiteStores: () => Promise.resolve(),
|
|
87
93
|
// #8217 D7 — the billing render's loader; `null` is a spoke without the action.
|
|
88
94
|
loadSpokeBilling: (...args: unknown[]) => mockLoadSpokeBilling(...args),
|
|
95
|
+
// #8431 D2a — a spoke has no local accounts; Security must never ask.
|
|
96
|
+
readOwnAccounts: (...args: unknown[]) => mockReadOwnAccounts(...args),
|
|
89
97
|
}));
|
|
90
98
|
|
|
91
99
|
const mockLeadsSubmit = jest.fn();
|
|
@@ -126,18 +134,20 @@ jest.mock("@working-theory/api-client", () => ({
|
|
|
126
134
|
}));
|
|
127
135
|
|
|
128
136
|
const mockUpdateUser = jest.fn();
|
|
137
|
+
const mockSignOut = jest.fn();
|
|
129
138
|
jest.mock("better-auth/react", () => ({
|
|
130
139
|
__esModule: true,
|
|
131
140
|
createAuthClient: () => ({
|
|
132
|
-
signOut:
|
|
141
|
+
signOut: (...args: unknown[]) => mockSignOut(...args),
|
|
133
142
|
updateUser: (...args: unknown[]) => mockUpdateUser(...args),
|
|
134
143
|
}),
|
|
135
144
|
}));
|
|
136
145
|
|
|
137
146
|
const mockRedirect = jest.fn();
|
|
147
|
+
const mockPush = jest.fn();
|
|
138
148
|
jest.mock("next/navigation", () => ({
|
|
139
149
|
__esModule: true,
|
|
140
|
-
useRouter: () => ({ push:
|
|
150
|
+
useRouter: () => ({ push: mockPush, refresh: jest.fn(), replace: jest.fn() }),
|
|
141
151
|
usePathname: () => "/account/general",
|
|
142
152
|
redirect: (...args: unknown[]) => mockRedirect(...args),
|
|
143
153
|
}));
|
|
@@ -196,6 +206,9 @@ beforeEach(() => {
|
|
|
196
206
|
mockRedirect.mockReset();
|
|
197
207
|
mockCookieSet.mockReset();
|
|
198
208
|
mockUpdateUser.mockReset();
|
|
209
|
+
mockReadOwnAccounts.mockReset().mockResolvedValue(null);
|
|
210
|
+
mockSignOut.mockReset();
|
|
211
|
+
mockPush.mockReset();
|
|
199
212
|
});
|
|
200
213
|
|
|
201
214
|
afterEach(() => {
|
|
@@ -235,27 +248,17 @@ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
|
|
|
235
248
|
mockMemberRead.mockResolvedValue({ ok: true, body: [] });
|
|
236
249
|
});
|
|
237
250
|
|
|
238
|
-
it("renders the name and photo form
|
|
251
|
+
it("renders the name and photo form, with no link to the hub's settings", async () => {
|
|
239
252
|
await renderGeneral();
|
|
240
253
|
expect(screen.getByTestId("account-general-form")).toBeInTheDocument();
|
|
241
254
|
expect(screen.getByLabelText("Name")).toHaveValue("Ada Lovelace");
|
|
242
255
|
// No hub read carries the photo, so the field starts empty (C5).
|
|
243
256
|
expect(screen.getByLabelText("Photo")).toHaveValue("");
|
|
244
|
-
expect(screen.getByTestId("account-deletion-request")).toBeInTheDocument();
|
|
245
257
|
expect(screen.queryByTestId("account-settings-manage-on-hub")).not.toBeInTheDocument();
|
|
246
258
|
expect(screen.queryByTestId("account-privacy-manage-on-hub")).not.toBeInTheDocument();
|
|
247
259
|
expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
|
|
248
260
|
});
|
|
249
261
|
|
|
250
|
-
it("the Security tab's sign-in line reads the hub, by the same name read (D7, moved from General)", async () => {
|
|
251
|
-
const { default: SecurityPage } = await import("../security/page");
|
|
252
|
-
render(await SecurityPage({ params: Promise.resolve({ locale: "en" }) }));
|
|
253
|
-
expect(screen.getByTestId("account-signin-method")).toHaveTextContent(
|
|
254
|
-
"You sign in through hub.example.com.",
|
|
255
|
-
);
|
|
256
|
-
expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
|
|
257
|
-
});
|
|
258
|
-
|
|
259
262
|
it("saves the name through member.updateProfile with the Bearer credential, then re-seals the session", async () => {
|
|
260
263
|
mockUpdateProfile.mockResolvedValue({ ok: true, body: { name: "Ada King", image: null } });
|
|
261
264
|
mockHubMe.mockResolvedValue({ ok: true, body: { user: { ...USER, name: "Ada King" }, billingCustomerId: null } });
|
|
@@ -275,10 +278,32 @@ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
|
|
|
275
278
|
user: { name: "Ada King" },
|
|
276
279
|
});
|
|
277
280
|
});
|
|
281
|
+
});
|
|
278
282
|
|
|
279
|
-
|
|
280
|
-
|
|
283
|
+
describe("a hub that answers no member route (the fixture's 404)", () => {
|
|
284
|
+
it("shows the name read-only and offers no save", async () => {
|
|
281
285
|
await renderGeneral();
|
|
286
|
+
expect(screen.getByTestId("account-general-name-text")).toHaveTextContent("Ada Lovelace");
|
|
287
|
+
expect(screen.queryByTestId("account-general-form")).not.toBeInTheDocument();
|
|
288
|
+
expect(screen.queryByLabelText("Photo")).not.toBeInTheDocument();
|
|
289
|
+
expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
|
|
290
|
+
expect(mockUpdateUser).not.toHaveBeenCalled();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// #8438 D16 — Request account deletion moved to Privacy; the hub-mode
|
|
295
|
+
// deletion coverage above moves with it, expectations unchanged.
|
|
296
|
+
describe("Privacy's deletion block on a hub-mode spoke (moved from General, #8438 D16)", () => {
|
|
297
|
+
async function renderPrivacy(): Promise<void> {
|
|
298
|
+
const { default: PrivacyPage } = await import("../privacy/page");
|
|
299
|
+
render(await PrivacyPage({ params: Promise.resolve({ locale: "en" }) }));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
it("a hub that answers the member routes: the deletion request goes through the member API and names the hub operator", async () => {
|
|
303
|
+
mockMemberRead.mockResolvedValue({ ok: true, body: [] });
|
|
304
|
+
mockRequestDeletion.mockResolvedValue({ ok: true, body: { sentTo: "hub-operator" } });
|
|
305
|
+
await renderPrivacy();
|
|
306
|
+
expect(screen.getByTestId("account-deletion-request")).toBeInTheDocument();
|
|
282
307
|
await act(async () => {
|
|
283
308
|
fireEvent.click(screen.getByTestId("account-deletion-request"));
|
|
284
309
|
});
|
|
@@ -286,19 +311,12 @@ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
|
|
|
286
311
|
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Sent to the hub operator."));
|
|
287
312
|
expect(mockLeadsSubmit).not.toHaveBeenCalled();
|
|
288
313
|
});
|
|
289
|
-
});
|
|
290
314
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
await renderGeneral();
|
|
294
|
-
expect(screen.getByTestId("account-general-name-text")).toHaveTextContent("Ada Lovelace");
|
|
295
|
-
expect(screen.queryByTestId("account-general-form")).not.toBeInTheDocument();
|
|
296
|
-
expect(screen.queryByLabelText("Photo")).not.toBeInTheDocument();
|
|
315
|
+
it("a hub that answers no member route: offers no deletion path", async () => {
|
|
316
|
+
await renderPrivacy();
|
|
297
317
|
expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
|
|
298
318
|
expect(screen.queryByTestId("account-deletion-support")).not.toBeInTheDocument();
|
|
299
319
|
expect(screen.getByText("This site can't take deletion requests right now.")).toBeInTheDocument();
|
|
300
|
-
expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
|
|
301
|
-
expect(mockUpdateUser).not.toHaveBeenCalled();
|
|
302
320
|
});
|
|
303
321
|
});
|
|
304
322
|
|
|
@@ -326,6 +344,77 @@ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
|
|
|
326
344
|
});
|
|
327
345
|
});
|
|
328
346
|
|
|
347
|
+
describe("#8431 AC-4 — Security on a hub-mode spoke", () => {
|
|
348
|
+
const realFetch = Object.getOwnPropertyDescriptor(globalThis, "fetch");
|
|
349
|
+
const mockFetch = jest.fn();
|
|
350
|
+
|
|
351
|
+
beforeEach(() => {
|
|
352
|
+
mockFetch.mockReset();
|
|
353
|
+
Object.defineProperty(globalThis, "fetch", { value: mockFetch, writable: true, configurable: true });
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
afterEach(() => {
|
|
357
|
+
if (realFetch) Object.defineProperty(globalThis, "fetch", realFetch);
|
|
358
|
+
else Reflect.deleteProperty(globalThis, "fetch");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
async function renderSecurity(): Promise<void> {
|
|
362
|
+
const { default: SecurityPage } = await import("../security/page");
|
|
363
|
+
render(await SecurityPage({ params: Promise.resolve({ locale: "en" }) }));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
it("says the hub manages sign-in and links to the hub's Security page — no password control", async () => {
|
|
367
|
+
await renderSecurity();
|
|
368
|
+
expect(screen.getByTestId("account-security-hub").textContent).toBe(
|
|
369
|
+
"Your sign-in is managed at hub.example.com.",
|
|
370
|
+
);
|
|
371
|
+
const links = screen.getAllByTestId("account-security-hub-link");
|
|
372
|
+
expect(links).toHaveLength(1);
|
|
373
|
+
expect(links[0]).toHaveAttribute("href", `${HUB}/account/security`);
|
|
374
|
+
expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
|
|
375
|
+
expect(screen.queryByTestId("account-security-password")).not.toBeInTheDocument();
|
|
376
|
+
expect(screen.queryByTestId("account-security-sentence")).not.toBeInTheDocument();
|
|
377
|
+
expect(screen.queryByText(/other devices/i)).toBeNull();
|
|
378
|
+
expect(mockReadOwnAccounts).not.toHaveBeenCalled();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("with the hub config absent says 'our main site' and links nowhere", async () => {
|
|
382
|
+
delete process.env.AILK_HUB_CLIENT_SECRET;
|
|
383
|
+
await renderSecurity();
|
|
384
|
+
expect(screen.getByTestId("account-security-hub").textContent).toBe(
|
|
385
|
+
"Your sign-in is managed by our main site.",
|
|
386
|
+
);
|
|
387
|
+
expect(screen.queryByTestId("account-security-hub-link")).not.toBeInTheDocument();
|
|
388
|
+
expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("Log out POSTs the spoke's sign-out endpoint, never Better Auth, and on a 204 lands on /account", async () => {
|
|
392
|
+
mockFetch.mockResolvedValue({ ok: true, status: 204 });
|
|
393
|
+
await renderSecurity();
|
|
394
|
+
await act(async () => {
|
|
395
|
+
fireEvent.click(screen.getByTestId("account-security-log-out"));
|
|
396
|
+
});
|
|
397
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
398
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/hub-session/sign-out", {
|
|
399
|
+
method: "POST",
|
|
400
|
+
credentials: "same-origin",
|
|
401
|
+
});
|
|
402
|
+
expect(mockSignOut).not.toHaveBeenCalled();
|
|
403
|
+
expect(mockPush).toHaveBeenCalledWith("/account");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("a refused sign-out (403) navigates nowhere and shows the error", async () => {
|
|
407
|
+
mockFetch.mockResolvedValue({ ok: false, status: 403 });
|
|
408
|
+
await renderSecurity();
|
|
409
|
+
await act(async () => {
|
|
410
|
+
fireEvent.click(screen.getByTestId("account-security-log-out"));
|
|
411
|
+
});
|
|
412
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
413
|
+
expect(mockSignOut).not.toHaveBeenCalled();
|
|
414
|
+
expect(screen.getByTestId("account-security-log-out-error")).toBeInTheDocument();
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
329
418
|
describe("#8217 AC-1 — Billing on a hub-mode spoke reads the seam's spoke billing (D10–D12)", () => {
|
|
330
419
|
const VISA = { brand: "visa", last4: "4242", expMonth: 4, expYear: 2030 };
|
|
331
420
|
const PORTAL_URL = "https://billing.stripe.com/p/session/test_fake";
|
|
@@ -347,7 +436,7 @@ describe("#8217 AC-1 — Billing on a hub-mode spoke reads the seam's spoke bill
|
|
|
347
436
|
expect(mockMemberBilling).not.toHaveBeenCalled();
|
|
348
437
|
});
|
|
349
438
|
|
|
350
|
-
it("a subscription renders the plan, the card and Manage
|
|
439
|
+
it("a subscription renders the plan, the card and Manage billing", async () => {
|
|
351
440
|
mockLoadSpokeBilling.mockResolvedValue(
|
|
352
441
|
stub({ kind: "subscription", status: "active", planName: "Pro", paymentMethod: VISA, unreadable: false }),
|
|
353
442
|
);
|
|
@@ -356,7 +445,7 @@ describe("#8217 AC-1 — Billing on a hub-mode spoke reads the seam's spoke bill
|
|
|
356
445
|
expect(card).toHaveTextContent("Pro");
|
|
357
446
|
expect(card).toHaveTextContent("Active");
|
|
358
447
|
expect(card).toHaveTextContent("visa ending in 4242, expires 04/2030");
|
|
359
|
-
expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage
|
|
448
|
+
expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage billing");
|
|
360
449
|
expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
|
|
361
450
|
expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
|
|
362
451
|
});
|
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
* 2. An unknown kind, or an id the kind did not load, is a not-found —
|
|
10
10
|
* never an empty frame.
|
|
11
11
|
* 3. A failed read renders an error with Retry (D12's posture).
|
|
12
|
+
* 4. A message whose `action` is `onboarding` has an Open onboarding
|
|
13
|
+
* action to the locale's `/account/onboarding`; a message with no
|
|
14
|
+
* action has none (#8515 D6, AC-4).
|
|
12
15
|
*
|
|
13
|
-
* The `purchase` kind reads `member.purchases` (#8231 D5)
|
|
16
|
+
* The `purchase` kind reads `member.purchases` (#8231 D5); the `message`
|
|
17
|
+
* kind reads `member.messages`.
|
|
14
18
|
*/
|
|
15
19
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
16
20
|
|
|
@@ -52,11 +56,15 @@ jest.mock("../../../../../auth", () => ({
|
|
|
52
56
|
|
|
53
57
|
const mockSignupMe = jest.fn();
|
|
54
58
|
const mockMemberPurchases = jest.fn();
|
|
59
|
+
const mockMemberMessages = jest.fn();
|
|
55
60
|
jest.mock("@working-theory/api-client", () => ({
|
|
56
61
|
__esModule: true,
|
|
57
62
|
waitlistSignups: { me: (...args: unknown[]) => mockSignupMe(...args) },
|
|
58
63
|
subscription: { me: jest.fn() },
|
|
59
|
-
member: {
|
|
64
|
+
member: {
|
|
65
|
+
purchases: (...args: unknown[]) => mockMemberPurchases(...args),
|
|
66
|
+
messages: (...args: unknown[]) => mockMemberMessages(...args),
|
|
67
|
+
},
|
|
60
68
|
}));
|
|
61
69
|
|
|
62
70
|
const mockNotFound = jest.fn(() => {
|
|
@@ -85,9 +93,30 @@ const PURCHASE = {
|
|
|
85
93
|
purchasedAt: "2026-09-04T10:00:00.000Z",
|
|
86
94
|
};
|
|
87
95
|
|
|
96
|
+
const MESSAGE = {
|
|
97
|
+
id: "int_plain",
|
|
98
|
+
subject: "Hello",
|
|
99
|
+
body: "A note.",
|
|
100
|
+
file: null,
|
|
101
|
+
action: null,
|
|
102
|
+
createdAt: "2026-10-14T09:00:00.000Z",
|
|
103
|
+
readAt: null,
|
|
104
|
+
};
|
|
105
|
+
const MESSAGES = [
|
|
106
|
+
MESSAGE,
|
|
107
|
+
{
|
|
108
|
+
...MESSAGE,
|
|
109
|
+
id: "int_onboarding",
|
|
110
|
+
subject: "Your onboarding questions",
|
|
111
|
+
body: "Open this message in your account and choose Open onboarding.",
|
|
112
|
+
action: "onboarding",
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
|
|
88
116
|
beforeEach(() => {
|
|
89
117
|
mockGetSession.mockReset().mockResolvedValue(SESSION);
|
|
90
118
|
mockMemberPurchases.mockReset().mockResolvedValue({ ok: true, body: { items: [PURCHASE] } });
|
|
119
|
+
mockMemberMessages.mockReset().mockResolvedValue({ ok: true, body: { items: MESSAGES } });
|
|
91
120
|
mockSignupMe.mockReset();
|
|
92
121
|
mockNotFound.mockClear();
|
|
93
122
|
mockRefresh.mockReset();
|
|
@@ -139,3 +168,29 @@ describe("one Inbox item", () => {
|
|
|
139
168
|
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
|
140
169
|
});
|
|
141
170
|
});
|
|
171
|
+
|
|
172
|
+
describe("a message's onboarding action (#8515 D6, AC-4)", () => {
|
|
173
|
+
it("an onboarding message links to the locale's onboarding page", async () => {
|
|
174
|
+
await renderItem("message", "int_onboarding", "fr");
|
|
175
|
+
const links = screen
|
|
176
|
+
.getAllByRole("link", { name: "Open onboarding" })
|
|
177
|
+
.map((link) => link.getAttribute("href"));
|
|
178
|
+
expect(links.length).toBeGreaterThan(0);
|
|
179
|
+
expect(links.every((href) => href === "/fr/account/onboarding")).toBe(true);
|
|
180
|
+
expect(screen.getByTestId("account-item-action-onboarding-desk")).toHaveAttribute(
|
|
181
|
+
"href",
|
|
182
|
+
"/fr/account/onboarding",
|
|
183
|
+
);
|
|
184
|
+
// The body stays plain text.
|
|
185
|
+
expect(
|
|
186
|
+
screen.getByText("Open this message in your account and choose Open onboarding.").closest("a"),
|
|
187
|
+
).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("a message with no action has no onboarding link", async () => {
|
|
191
|
+
await renderItem("message", "int_plain", "fr");
|
|
192
|
+
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Hello");
|
|
193
|
+
expect(screen.queryByRole("link", { name: "Open onboarding" })).not.toBeInTheDocument();
|
|
194
|
+
expect(screen.queryByTestId("account-item-action-onboarding-desk")).not.toBeInTheDocument();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account layout hands the generated seam's sign-out wiring to the client
|
|
3
|
+
* frame (#8465): a client component cannot import `topology.generated.ts`, so
|
|
4
|
+
* the server layout reads `SIGN_OUT_ENDPOINT` / `SIGN_IN_PATH` and passes them
|
|
5
|
+
* as props — the signed-in Nav's own pattern. Also pins that the Help center
|
|
6
|
+
* href is rendered only for an https URL (the #8467 review's Low finding).
|
|
7
|
+
*/
|
|
8
|
+
import { render } from "@testing-library/react";
|
|
9
|
+
import type { ReactElement } from "react";
|
|
10
|
+
|
|
11
|
+
jest.mock("next-intl/server", () => ({
|
|
12
|
+
__esModule: true,
|
|
13
|
+
getTranslations: async (arg: { namespace?: string }) => (key: string) =>
|
|
14
|
+
`${arg.namespace}.${key}`,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
let mockSeam: {
|
|
18
|
+
SIGN_IN_PATH: string | null;
|
|
19
|
+
SIGN_OUT_ENDPOINT: string | null;
|
|
20
|
+
} = {
|
|
21
|
+
SIGN_IN_PATH: "/sign-in",
|
|
22
|
+
SIGN_OUT_ENDPOINT: null,
|
|
23
|
+
};
|
|
24
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the seam is generated per build (#7894 D8); its `signIn: "hub"` render is the boundary under test, and no env or HTTP surface selects it.
|
|
25
|
+
jest.mock("../../../../../lib/topology.generated", () => ({
|
|
26
|
+
__esModule: true,
|
|
27
|
+
get SIGN_IN_PATH() {
|
|
28
|
+
return mockSeam.SIGN_IN_PATH;
|
|
29
|
+
},
|
|
30
|
+
get SIGN_OUT_ENDPOINT() {
|
|
31
|
+
return mockSeam.SIGN_OUT_ENDPOINT;
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// 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
|
|
36
|
+
jest.mock("../account-data", () => ({
|
|
37
|
+
__esModule: true,
|
|
38
|
+
accountSession: async () => ({
|
|
39
|
+
ctx: { user: { name: "Ada", email: "ada@example.com" }, t: {} },
|
|
40
|
+
}),
|
|
41
|
+
loadForYou: async () => ({ items: [], now: "2026-10-12T00:00:00.000Z" }),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
// 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
|
|
45
|
+
jest.mock("../for-you-labels", () => ({
|
|
46
|
+
__esModule: true,
|
|
47
|
+
forYouLabels: () => ({}),
|
|
48
|
+
}));
|
|
49
|
+
// 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
|
|
50
|
+
jest.mock("../../../../../lib/site-logo", () => ({
|
|
51
|
+
__esModule: true,
|
|
52
|
+
siteLogo: () => null,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const mockFrame = jest.fn();
|
|
56
|
+
// 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
|
+
jest.mock("../account-frame", () => ({
|
|
58
|
+
__esModule: true,
|
|
59
|
+
AccountFrame: (props: Record<string, unknown>) => {
|
|
60
|
+
mockFrame(props);
|
|
61
|
+
return null;
|
|
62
|
+
},
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
import AccountLayout from "../layout";
|
|
66
|
+
|
|
67
|
+
const ENV = process.env;
|
|
68
|
+
|
|
69
|
+
async function renderLayout(locale = "en"): Promise<Record<string, unknown>> {
|
|
70
|
+
const node = (await AccountLayout({
|
|
71
|
+
children: null,
|
|
72
|
+
params: Promise.resolve({ locale }),
|
|
73
|
+
})) as ReactElement;
|
|
74
|
+
render(node);
|
|
75
|
+
return mockFrame.mock.calls[0]![0] as Record<string, unknown>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
mockFrame.mockReset();
|
|
80
|
+
mockSeam = { SIGN_IN_PATH: "/sign-in", SIGN_OUT_ENDPOINT: null };
|
|
81
|
+
process.env = { ...ENV };
|
|
82
|
+
delete process.env.AILK_HELP_CENTER_URL;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
process.env = ENV;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("sign-out wiring (#8465)", () => {
|
|
90
|
+
it("passes the endpoint and the localized sign-in path on a hub sign-in spoke", async () => {
|
|
91
|
+
mockSeam = {
|
|
92
|
+
SIGN_IN_PATH: "/account",
|
|
93
|
+
SIGN_OUT_ENDPOINT: "/api/hub-session/sign-out",
|
|
94
|
+
};
|
|
95
|
+
const props = await renderLayout("fr");
|
|
96
|
+
expect(props.signOutEndpoint).toBe("/api/hub-session/sign-out");
|
|
97
|
+
expect(props.signInHref).toBe("/fr/account");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("falls back to the home when the seam names no sign-in path", async () => {
|
|
101
|
+
mockSeam = {
|
|
102
|
+
SIGN_IN_PATH: null,
|
|
103
|
+
SIGN_OUT_ENDPOINT: "/api/hub-session/sign-out",
|
|
104
|
+
};
|
|
105
|
+
expect((await renderLayout()).signInHref).toBe("/");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("passes neither on a hub or a single site (no endpoint)", async () => {
|
|
109
|
+
const props = await renderLayout();
|
|
110
|
+
expect(props).not.toHaveProperty("signOutEndpoint");
|
|
111
|
+
expect(props).not.toHaveProperty("signInHref");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("carries the failed-sign-out label to the frame", async () => {
|
|
115
|
+
const props = await renderLayout();
|
|
116
|
+
expect((props.labels as Record<string, string>).signOutFailed).toBe(
|
|
117
|
+
"account.frame.signOutFailed",
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("Help center href (#8467 review, Low)", () => {
|
|
123
|
+
const helpItems = (
|
|
124
|
+
props: Record<string, unknown>,
|
|
125
|
+
): { id: string; href: string }[] =>
|
|
126
|
+
props.helpItems as { id: string; href: string }[];
|
|
127
|
+
|
|
128
|
+
it("renders an https URL", async () => {
|
|
129
|
+
process.env.AILK_HELP_CENTER_URL = "https://help.example.com";
|
|
130
|
+
expect(helpItems(await renderLayout()).map((i) => i.id)).toContain(
|
|
131
|
+
"help-center",
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it.each([
|
|
136
|
+
"javascript:alert(1)",
|
|
137
|
+
"http://help.example.com",
|
|
138
|
+
"not a url",
|
|
139
|
+
"data:text/html,x",
|
|
140
|
+
])("drops %s", async (value) => {
|
|
141
|
+
process.env.AILK_HELP_CENTER_URL = value;
|
|
142
|
+
expect(helpItems(await renderLayout()).map((i) => i.id)).not.toContain(
|
|
143
|
+
"help-center",
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
});
|