create-ailk 0.18.0 → 0.19.1
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/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +11 -3
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +32 -0
- package/dist/lib/scaffold-render-sweep.js +76 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +128 -3
- package/dist/programmatic.js +11 -1
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +170 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +88 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +31 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -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__/deliverable-asset-store.test.ts +38 -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/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +176 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/middleware/auth.ts +214 -46
- package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
- 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 +19 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- 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-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
- package/templates/apps/api/src/server.ts +218 -16
- 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 +173 -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/consent.ts +488 -3
- package/templates/apps/api/src/services/lead-routing.ts +135 -14
- 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/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- 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__/billing.test.tsx +302 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- 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 +232 -29
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
- 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/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +25 -2
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +194 -33
- package/templates/database/CHANGELOG.md +37 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- 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__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +166 -25
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- 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/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log out in AccountFrame (#8465).
|
|
3
|
+
*
|
|
4
|
+
* A spoke that signs in through its hub holds its session in the sealed
|
|
5
|
+
* hub-session cookie, which only `POST /api/hub-session/sign-out` clears
|
|
6
|
+
* (and which also revokes the hub credential); Better Auth's `signOut()` hits
|
|
7
|
+
* a route that spoke does not serve. The server layout hands the seam's
|
|
8
|
+
* `SIGN_OUT_ENDPOINT` down as `signOutEndpoint`:
|
|
9
|
+
*
|
|
10
|
+
* - with an endpoint, Log out POSTs it (same-origin), never calls Better
|
|
11
|
+
* Auth, and lands on the sign-in page once the answer is 2xx;
|
|
12
|
+
* - a refused or failed sign-out (403 on an Origin mismatch, 5xx, a
|
|
13
|
+
* rejected fetch) stays on the page, shows the translated error, and
|
|
14
|
+
* navigates nowhere — the cookie is still valid (#8467 review addendum);
|
|
15
|
+
* - without an endpoint (a hub or a single site) it calls Better Auth and
|
|
16
|
+
* routes home, as before.
|
|
17
|
+
*/
|
|
18
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
19
|
+
import type { ReactNode } from "react";
|
|
20
|
+
|
|
21
|
+
const mockSignOut = jest.fn();
|
|
22
|
+
jest.mock("better-auth/react", () => ({
|
|
23
|
+
__esModule: true,
|
|
24
|
+
createAuthClient: () => ({
|
|
25
|
+
signOut: (...args: unknown[]) => mockSignOut(...args),
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const mockPush = jest.fn();
|
|
30
|
+
const mockRefresh = jest.fn();
|
|
31
|
+
jest.mock("next/navigation", () => ({
|
|
32
|
+
__esModule: true,
|
|
33
|
+
useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
|
|
34
|
+
usePathname: () => "/account",
|
|
35
|
+
useSearchParams: () => new URLSearchParams(""),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
jest.mock("@working-theory/templates/shells", () => ({
|
|
39
|
+
__esModule: true,
|
|
40
|
+
SignedInShell: ({
|
|
41
|
+
onSignOut,
|
|
42
|
+
children,
|
|
43
|
+
}: {
|
|
44
|
+
onSignOut: () => void;
|
|
45
|
+
children: ReactNode;
|
|
46
|
+
}) => (
|
|
47
|
+
<div>
|
|
48
|
+
<button type="button" data-testid="sign-out" onClick={onSignOut} />
|
|
49
|
+
{children}
|
|
50
|
+
</div>
|
|
51
|
+
),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
jest.mock("@working-theory/templates/account", () => ({
|
|
55
|
+
__esModule: true,
|
|
56
|
+
ForYouPanel: () => null,
|
|
57
|
+
PhoneTabProvider: ({ children }: { children: ReactNode }) => children,
|
|
58
|
+
useForYouDismissed: () => [new Set<string>(), jest.fn()],
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
import { AccountFrame, type AccountFrameProps } from "../account-frame";
|
|
62
|
+
|
|
63
|
+
const ENDPOINT = "/api/hub-session/sign-out";
|
|
64
|
+
const FAILED = "We couldn't log you out. You're still signed in; try again.";
|
|
65
|
+
const mockFetch = jest.fn();
|
|
66
|
+
|
|
67
|
+
function renderFrame(extra: Partial<AccountFrameProps> = {}): void {
|
|
68
|
+
render(
|
|
69
|
+
<AccountFrame
|
|
70
|
+
locale="en"
|
|
71
|
+
user={{ name: "Ada", email: "ada@example.com" }}
|
|
72
|
+
logo={null}
|
|
73
|
+
helpItems={[]}
|
|
74
|
+
// eslint-disable-next-line no-restricted-syntax -- partial fixture: the suite exercises only the sign-out form, so the full forYou/labels shapes are not built
|
|
75
|
+
forYou={{ items: [] } as unknown as AccountFrameProps["forYou"]}
|
|
76
|
+
labels={
|
|
77
|
+
// eslint-disable-next-line no-restricted-syntax -- partial fixture (see forYou above)
|
|
78
|
+
{
|
|
79
|
+
home: "Home",
|
|
80
|
+
signOutFailed: FAILED,
|
|
81
|
+
} as unknown as AccountFrameProps["labels"]
|
|
82
|
+
}
|
|
83
|
+
{...extra}
|
|
84
|
+
>
|
|
85
|
+
<p>page</p>
|
|
86
|
+
</AccountFrame>,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
jest.clearAllMocks();
|
|
92
|
+
globalThis.fetch = mockFetch.mockResolvedValue({ ok: true, status: 204 });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("AccountFrame Log out — a spoke that signs in through its hub", () => {
|
|
96
|
+
it("POSTs the hub sign-out endpoint, never Better Auth, then lands on the sign-in page and refreshes", async () => {
|
|
97
|
+
renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
|
|
98
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
99
|
+
await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/account"));
|
|
100
|
+
expect(mockFetch).toHaveBeenCalledWith(ENDPOINT, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
credentials: "same-origin",
|
|
103
|
+
});
|
|
104
|
+
expect(mockSignOut).not.toHaveBeenCalled();
|
|
105
|
+
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
|
106
|
+
expect(
|
|
107
|
+
screen.queryByTestId("account-sign-out-error"),
|
|
108
|
+
).not.toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("falls back to the site home when no sign-in href is given", async () => {
|
|
112
|
+
renderFrame({ signOutEndpoint: ENDPOINT });
|
|
113
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
114
|
+
await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/"));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it.each([
|
|
118
|
+
["a 403 (Origin mismatch)", { ok: false, status: 403 }],
|
|
119
|
+
["a 500", { ok: false, status: 500 }],
|
|
120
|
+
["a 404", { ok: false, status: 404 }],
|
|
121
|
+
])(
|
|
122
|
+
"%s stays on the page with the error and routes nowhere",
|
|
123
|
+
async (_label, response) => {
|
|
124
|
+
mockFetch.mockResolvedValue(response);
|
|
125
|
+
renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
|
|
126
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
127
|
+
expect(
|
|
128
|
+
await screen.findByTestId("account-sign-out-error"),
|
|
129
|
+
).toHaveTextContent(FAILED);
|
|
130
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
131
|
+
expect(mockRefresh).not.toHaveBeenCalled();
|
|
132
|
+
expect(mockSignOut).not.toHaveBeenCalled();
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
it("a rejected request stays on the page with the error and routes nowhere", async () => {
|
|
137
|
+
mockFetch.mockRejectedValue(new TypeError("network down"));
|
|
138
|
+
renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
|
|
139
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
140
|
+
expect(
|
|
141
|
+
await screen.findByTestId("account-sign-out-error"),
|
|
142
|
+
).toHaveTextContent(FAILED);
|
|
143
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
144
|
+
expect(mockRefresh).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("a retry that succeeds clears the error and lands on the sign-in page", async () => {
|
|
148
|
+
mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
|
|
149
|
+
renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
|
|
150
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
151
|
+
await screen.findByTestId("account-sign-out-error");
|
|
152
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
153
|
+
await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/account"));
|
|
154
|
+
expect(
|
|
155
|
+
screen.queryByTestId("account-sign-out-error"),
|
|
156
|
+
).not.toBeInTheDocument();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("AccountFrame Log out — a hub or a single site", () => {
|
|
161
|
+
it.each([
|
|
162
|
+
["absent", {}],
|
|
163
|
+
["null", { signOutEndpoint: null }],
|
|
164
|
+
])(
|
|
165
|
+
"with the endpoint %s calls Better Auth, then routes home and refreshes",
|
|
166
|
+
async (_label, extra) => {
|
|
167
|
+
renderFrame(extra);
|
|
168
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
169
|
+
await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/"));
|
|
170
|
+
expect(mockSignOut).toHaveBeenCalledTimes(1);
|
|
171
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
172
|
+
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for /[locale]/account/billing — User settings › Billing (#8232, spec
|
|
3
|
+
* docs/specs/8232-member-settings-account-general-and-spec.md D1, D9; AC-2).
|
|
4
|
+
* The single-site render: `subscription.me` is the mocked boundary.
|
|
5
|
+
*
|
|
6
|
+
* Covers:
|
|
7
|
+
* (a) a subscription shows the plan, the status, the card and Manage in
|
|
8
|
+
* Stripe; (b) no Manage without a portal; (c) `status: null` and each
|
|
9
|
+
* of 401, 403 and 404 say plainly there is no subscription, with no
|
|
10
|
+
* button; (d) a 500 or a throw shows the error row, never "no
|
|
11
|
+
* subscription"; (e) a contributed billing section renders after every
|
|
12
|
+
* one of these; (f) the read asks for `include: "billing"`.
|
|
13
|
+
* #8217 AC-1 (spec docs/specs/8217-spoke-billing-spoke-checkout-reads-spec.md
|
|
14
|
+
* D10, D11): `openBillingPortal()` mints the portal through
|
|
15
|
+
* `billingPortalUrl()` — the hub's route, once, with the cookie init — and
|
|
16
|
+
* redirects to it; a non-2xx or a throw redirects nowhere. The single site
|
|
17
|
+
* never shows the unreadable line.
|
|
18
|
+
*/
|
|
19
|
+
import { render, screen, within } from "@testing-library/react";
|
|
20
|
+
|
|
21
|
+
jest.mock("next-intl/server", () => {
|
|
22
|
+
const msgs: unknown = require("../../../../../messages/en.json");
|
|
23
|
+
const dig = (obj: unknown, path: string): unknown =>
|
|
24
|
+
path.split(".").reduce<unknown>(
|
|
25
|
+
(acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
|
|
26
|
+
obj,
|
|
27
|
+
);
|
|
28
|
+
return {
|
|
29
|
+
__esModule: true,
|
|
30
|
+
getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
|
|
31
|
+
const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
|
|
32
|
+
const messages = dig(msgs, ns);
|
|
33
|
+
return (key: string, values?: Record<string, unknown>): string => {
|
|
34
|
+
const value = dig(messages, key);
|
|
35
|
+
if (typeof value !== "string") return `${ns}.${key}`;
|
|
36
|
+
return value.replace(/\{(\w+)\}/g, (m, name: string) =>
|
|
37
|
+
values && name in values ? String(values[name]) : m,
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
jest.mock("next/headers", () => ({
|
|
45
|
+
__esModule: true,
|
|
46
|
+
headers: jest.fn().mockResolvedValue(new Headers()),
|
|
47
|
+
cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
const mockGetSession = jest.fn();
|
|
51
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth.ts's lazy Better-Auth singleton has no HTTP/DB boundary a unit test of the session fields can mock without standing up Prisma.
|
|
52
|
+
jest.mock("../../../../../auth", () => ({
|
|
53
|
+
__esModule: true,
|
|
54
|
+
auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const mockSubscriptionMe = jest.fn();
|
|
58
|
+
const mockPortalSession = jest.fn();
|
|
59
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
60
|
+
__esModule: true,
|
|
61
|
+
waitlistSignups: { me: jest.fn() },
|
|
62
|
+
flowCheckouts: { me: jest.fn() },
|
|
63
|
+
subscription: {
|
|
64
|
+
me: (...args: unknown[]) => mockSubscriptionMe(...args),
|
|
65
|
+
portalSession: (...args: unknown[]) => mockPortalSession(...args),
|
|
66
|
+
},
|
|
67
|
+
leads: { submit: jest.fn() },
|
|
68
|
+
member: {},
|
|
69
|
+
hubSignIn: { me: jest.fn() },
|
|
70
|
+
visitorForwardHeaders: () => null,
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
const mockRedirect = jest.fn();
|
|
74
|
+
jest.mock("next/navigation", () => ({
|
|
75
|
+
__esModule: true,
|
|
76
|
+
useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() }),
|
|
77
|
+
usePathname: () => "/account/billing",
|
|
78
|
+
redirect: (...args: unknown[]) => mockRedirect(...args),
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
import type { AccountBillingSection } from "@working-theory/templates/account";
|
|
82
|
+
|
|
83
|
+
import { accountBillingSections } from "../../../../../lib/account-config";
|
|
84
|
+
import { openBillingPortal } from "../actions";
|
|
85
|
+
|
|
86
|
+
// The Billing route's own page, loaded with `import()`: architecture.yaml bans
|
|
87
|
+
// a static import of a relative billing/ path to keep OSS code out of the
|
|
88
|
+
// apps-paid/billing service's source, and this route directory shares the name.
|
|
89
|
+
const loadPage = async () => (await import("../billing/page")).default;
|
|
90
|
+
|
|
91
|
+
const SESSION = {
|
|
92
|
+
user: {
|
|
93
|
+
id: "user_1",
|
|
94
|
+
name: "Ada Lovelace",
|
|
95
|
+
email: "ada@example.com",
|
|
96
|
+
emailVerified: true,
|
|
97
|
+
createdAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const ACTIVE = {
|
|
102
|
+
actionRequired: false,
|
|
103
|
+
status: "active",
|
|
104
|
+
accessRevokesAt: null,
|
|
105
|
+
portalAvailable: true,
|
|
106
|
+
planName: "Pro",
|
|
107
|
+
paymentMethod: { brand: "visa", last4: "4242", expMonth: 4, expYear: 2027 },
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const answer = (body: Record<string, unknown>) => ({ ok: true, body: { ...ACTIVE, ...body } });
|
|
111
|
+
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
mockGetSession.mockReset().mockResolvedValue(SESSION);
|
|
114
|
+
mockSubscriptionMe.mockReset().mockResolvedValue(answer({}));
|
|
115
|
+
mockPortalSession.mockReset();
|
|
116
|
+
mockRedirect.mockReset();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
async function renderPage(locale = "en"): Promise<void> {
|
|
120
|
+
const Page = await loadPage();
|
|
121
|
+
render(await Page({ params: Promise.resolve({ locale }) }));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe("the frame (D7)", () => {
|
|
125
|
+
it("renders in the settings frame with Billing current", async () => {
|
|
126
|
+
await renderPage();
|
|
127
|
+
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Billing");
|
|
128
|
+
expect(screen.getByTestId("account-settings-back")).toHaveAttribute("href", "/account");
|
|
129
|
+
expect(screen.getByTestId("account-settings-rail-billing")).toHaveAttribute("aria-current", "page");
|
|
130
|
+
expect(screen.getByTestId("account-settings-rail-general")).not.toHaveAttribute("aria-current");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("the standalone shape (#8430)", () => {
|
|
135
|
+
it("takes the screen as the Admin's settings do: the rail column and the pane are the page's own landmarks", async () => {
|
|
136
|
+
await renderPage();
|
|
137
|
+
const settings = screen.getByTestId("account-settings");
|
|
138
|
+
expect(settings).toHaveClass("min-h-screen");
|
|
139
|
+
const column = within(settings).getByRole("complementary");
|
|
140
|
+
expect(column.firstElementChild).toBe(screen.getByTestId("account-settings-back"));
|
|
141
|
+
expect(column).toContainElement(screen.getByTestId("account-settings-rail"));
|
|
142
|
+
expect(within(settings).getByRole("main")).toContainElement(
|
|
143
|
+
screen.getByRole("heading", { level: 1 }),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("AC-5 — Billing as the current-plan card (D8)", () => {
|
|
149
|
+
it("(a) shows the plan, the status, the card and Manage billing under the Current plan heading", async () => {
|
|
150
|
+
await renderPage();
|
|
151
|
+
expect(screen.getByText("Current plan")).toBeInTheDocument();
|
|
152
|
+
const card = screen.getByTestId("account-billing-card");
|
|
153
|
+
expect(within(card).getByTestId("account-billing-plan")).toHaveTextContent("Pro");
|
|
154
|
+
expect(within(card).getByTestId("account-billing-status")).toHaveTextContent("Active");
|
|
155
|
+
expect(within(card).getByTestId("account-billing-payment")).toHaveTextContent(
|
|
156
|
+
"visa ending in 4242, expires 04/2027",
|
|
157
|
+
);
|
|
158
|
+
expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage billing");
|
|
159
|
+
expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
|
|
160
|
+
expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("(a) no plan name reads as 'Your subscription', and no card as 'No card on file'", async () => {
|
|
164
|
+
mockSubscriptionMe.mockResolvedValue(
|
|
165
|
+
answer({ status: "past_due", planName: null, paymentMethod: null }),
|
|
166
|
+
);
|
|
167
|
+
await renderPage();
|
|
168
|
+
expect(screen.getByTestId("account-billing-plan")).toHaveTextContent("Your subscription");
|
|
169
|
+
expect(screen.getByTestId("account-billing-status")).toHaveTextContent("Payment due");
|
|
170
|
+
expect(screen.getByTestId("account-billing-payment")).toHaveTextContent("No card on file");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("(a) an older API without the billing fields still renders the status", async () => {
|
|
174
|
+
mockSubscriptionMe.mockResolvedValue({
|
|
175
|
+
ok: true,
|
|
176
|
+
body: { actionRequired: false, status: "trialing", accessRevokesAt: null, portalAvailable: false },
|
|
177
|
+
});
|
|
178
|
+
await renderPage();
|
|
179
|
+
expect(screen.getByTestId("account-billing-status")).toHaveTextContent("Trial");
|
|
180
|
+
expect(screen.getByTestId("account-billing-payment")).toHaveTextContent("No card on file");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("(a) appends when access ends, when set", async () => {
|
|
184
|
+
mockSubscriptionMe.mockResolvedValue(
|
|
185
|
+
answer({ status: "canceled", accessRevokesAt: "2026-10-20T00:00:00.000Z" }),
|
|
186
|
+
);
|
|
187
|
+
await renderPage();
|
|
188
|
+
const status = screen.getByTestId("account-billing-status");
|
|
189
|
+
expect(status).toHaveTextContent("Canceled");
|
|
190
|
+
expect(status).toHaveTextContent("Access ends");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("(b) no portal, no Manage button", async () => {
|
|
194
|
+
mockSubscriptionMe.mockResolvedValue(answer({ portalAvailable: false }));
|
|
195
|
+
await renderPage();
|
|
196
|
+
expect(screen.getByTestId("account-billing-card")).toBeInTheDocument();
|
|
197
|
+
expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("(c) status: null renders the same card shape with the plain line and no button — even with a portal", async () => {
|
|
201
|
+
mockSubscriptionMe.mockResolvedValue(answer({ status: null, portalAvailable: true }));
|
|
202
|
+
await renderPage();
|
|
203
|
+
expect(screen.getByTestId("account-billing-none")).toHaveTextContent("You don't have a subscription.");
|
|
204
|
+
expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
|
|
205
|
+
expect(screen.queryByTestId("account-billing-plan")).not.toBeInTheDocument();
|
|
206
|
+
expect(screen.queryByTestId("account-billing-status")).not.toBeInTheDocument();
|
|
207
|
+
expect(screen.queryByTestId("account-billing-payment")).not.toBeInTheDocument();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it.each([401, 403, 404])("(c) a %i says there is no subscription, with no button", async (status) => {
|
|
211
|
+
mockSubscriptionMe.mockResolvedValue({ ok: false, status, body: {} });
|
|
212
|
+
await renderPage();
|
|
213
|
+
expect(screen.getByTestId("account-billing-none")).toBeInTheDocument();
|
|
214
|
+
expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
|
|
215
|
+
expect(screen.queryByTestId("inbox-error")).not.toBeInTheDocument();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("(g) through D6, the member card renders the shared CurrentPlanCard — exactly one card", async () => {
|
|
219
|
+
await renderPage();
|
|
220
|
+
expect(screen.getAllByTestId("account-billing-card")).toHaveLength(1);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("(d) a 500 shows the error row with Retry, not 'no subscription'", async () => {
|
|
224
|
+
mockSubscriptionMe.mockResolvedValue({ ok: false, status: 500, body: {} });
|
|
225
|
+
await renderPage();
|
|
226
|
+
expect(screen.getByTestId("inbox-error")).toHaveTextContent("We couldn't load your billing details.");
|
|
227
|
+
expect(screen.getByTestId("inbox-retry")).toBeInTheDocument();
|
|
228
|
+
expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("(d) a throw shows the error row, not 'no subscription'", async () => {
|
|
232
|
+
mockSubscriptionMe.mockRejectedValue(new TypeError("fetch failed"));
|
|
233
|
+
await renderPage();
|
|
234
|
+
expect(screen.getByTestId("inbox-error")).toBeInTheDocument();
|
|
235
|
+
expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("(f) the read asks for the billing fields with the caller's cookie", async () => {
|
|
239
|
+
await renderPage();
|
|
240
|
+
expect(mockSubscriptionMe).toHaveBeenCalledWith(
|
|
241
|
+
{ headers: { cookie: "session=abc" } },
|
|
242
|
+
{ include: "billing" },
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("AC-2 (e) — contributed billing sections render after the base content", () => {
|
|
248
|
+
const section: AccountBillingSection = {
|
|
249
|
+
id: "licence",
|
|
250
|
+
title: "settings.billing.title",
|
|
251
|
+
render: () => <p data-testid="licence-body">Licence</p>,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
beforeAll(() => {
|
|
255
|
+
accountBillingSections.push(section);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
afterAll(() => {
|
|
259
|
+
accountBillingSections.splice(accountBillingSections.indexOf(section), 1);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const states: [string, () => void, string][] = [
|
|
263
|
+
["a subscription", () => undefined, "account-billing-card"],
|
|
264
|
+
["no subscription", () => mockSubscriptionMe.mockResolvedValue(answer({ status: null })), "account-billing-card"],
|
|
265
|
+
["a 404", () => mockSubscriptionMe.mockResolvedValue({ ok: false, status: 404, body: {} }), "account-billing-card"],
|
|
266
|
+
["a failed read", () => mockSubscriptionMe.mockResolvedValue({ ok: false, status: 500, body: {} }), "inbox-error"],
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
it.each(states)("after %s", async (_label, arrange, baseTestId) => {
|
|
270
|
+
arrange();
|
|
271
|
+
await renderPage();
|
|
272
|
+
const base = screen.getByTestId(baseTestId);
|
|
273
|
+
const contributed = screen.getByTestId("account-settings-licence");
|
|
274
|
+
expect(within(contributed).getByTestId("licence-body")).toBeInTheDocument();
|
|
275
|
+
expect(base.compareDocumentPosition(contributed) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("#8217 AC-1 — the single site's Manage billing (D10, D11)", () => {
|
|
280
|
+
const PORTAL_URL = "https://billing.stripe.com/p/session/test_single_site";
|
|
281
|
+
|
|
282
|
+
it("mints the portal once through the hub's route with the cookie init, then redirects there", async () => {
|
|
283
|
+
mockPortalSession.mockResolvedValue({ ok: true, body: { url: PORTAL_URL } });
|
|
284
|
+
await openBillingPortal();
|
|
285
|
+
expect(mockPortalSession).toHaveBeenCalledTimes(1);
|
|
286
|
+
expect(mockPortalSession).toHaveBeenCalledWith({ headers: { cookie: "session=abc" } });
|
|
287
|
+
expect(mockRedirect).toHaveBeenCalledTimes(1);
|
|
288
|
+
expect(mockRedirect).toHaveBeenCalledWith(PORTAL_URL);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it.each([
|
|
292
|
+
["a non-2xx", () => mockPortalSession.mockResolvedValue({ ok: false, status: 404, body: {} }), "[openBillingPortal] status=404"],
|
|
293
|
+
["a throw", () => mockPortalSession.mockRejectedValue(new TypeError("fetch failed")), "[openBillingPortal] errName=TypeError"],
|
|
294
|
+
])("%s redirects nowhere and logs only a status or a name", async (_label, arrange, line) => {
|
|
295
|
+
const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
296
|
+
arrange();
|
|
297
|
+
await openBillingPortal();
|
|
298
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
299
|
+
expect(errorSpy.mock.calls.map((args) => args.join(" "))).toEqual([line]);
|
|
300
|
+
errorSpy.mockRestore();
|
|
301
|
+
});
|
|
302
|
+
});
|