create-ailk 0.17.0 → 0.19.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/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- 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/lib/env-catalog.js +5 -9
- 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 +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- 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.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +15 -2
- package/dist/topology.js +83 -4
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -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/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +58 -7
- 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__/app-entry.test.ts +325 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +280 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/index.test.ts +86 -47
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
- package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/server.test.ts +63 -6
- 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/app.ts +32 -0
- package/templates/apps/mcp/src/boot.ts +167 -0
- package/templates/apps/mcp/src/http-transport.ts +132 -88
- package/templates/apps/mcp/src/index.ts +19 -126
- package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
- package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
- package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/server.ts +10 -2
- package/templates/apps/mcp/src/serverless-app.ts +88 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
- package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
- package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
- package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
- package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
- package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
- package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
- package/templates/apps/mcp/src/tools/read_content.ts +7 -7
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/search_content.ts +6 -5
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/mcp/tsconfig.json +2 -0
- 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/.well-known/mcp.json/build-manifest.ts +16 -4
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- 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 +126 -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 +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- 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 +114 -50
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +25 -32
- 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 +177 -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/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -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 +4 -1
- 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 +80 -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 +108 -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 +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- 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/site-brand.tsx +22 -6
- package/templates/apps/web/lib/topology.generated.ts +27 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- 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__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- 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/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
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
* Issue #7396 — `mcpEndpoint` is the configured MCP HTTP host or absent. It
|
|
20
20
|
* was `${baseUrl}/api/mcp`, a path apps/web does not serve, so every agent
|
|
21
21
|
* that followed it got a 404.
|
|
22
|
+
* Issue #8070 — `unservedTools` drops the tools this topology's MCP server
|
|
23
|
+
* does not register (`create_project` on a spoke).
|
|
24
|
+
* Issue #8057 — `description` resolves from SITE_DESCRIPTION
|
|
25
|
+
* (`siteIdentity.description` in _site.mdx → neutral default) instead of the
|
|
26
|
+
* template's own blurb on every site.
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
29
|
import {
|
|
@@ -27,7 +32,7 @@ import {
|
|
|
27
32
|
type McpToolCatalogEntry,
|
|
28
33
|
} from "@working-theory/metadata";
|
|
29
34
|
|
|
30
|
-
import { SITE_NAME } from "../../../lib/site-brand";
|
|
35
|
+
import { SITE_DESCRIPTION, SITE_NAME } from "../../../lib/site-brand";
|
|
31
36
|
|
|
32
37
|
/**
|
|
33
38
|
* Inline transport descriptor shape — mirrors McpTransportDescriptor from
|
|
@@ -107,6 +112,14 @@ export interface BuildMcpManifestOptions {
|
|
|
107
112
|
/** Overrides `MCP_FEATURE_GATE_MODE` — a test seam; `route.ts` never
|
|
108
113
|
* passes it. */
|
|
109
114
|
featureGate?: McpCatalogOptions["featureGate"];
|
|
115
|
+
/**
|
|
116
|
+
* Tool names this site's MCP server does not serve (#8070 D7) — the
|
|
117
|
+
* topology seam's `UNSERVED_MCP_TOOLS`: `create_project` on a spoke, none
|
|
118
|
+
* on a hub. Dropped from `tools`, so the manifest never advertises a tool
|
|
119
|
+
* the server will not register. Injected, like `commerceClaimed`. Omitted ⇒
|
|
120
|
+
* none dropped.
|
|
121
|
+
*/
|
|
122
|
+
unservedTools?: readonly string[];
|
|
110
123
|
}
|
|
111
124
|
|
|
112
125
|
/** MCP HTTP transport version this server implements. */
|
|
@@ -132,8 +145,7 @@ export function buildMcpManifest(opts: BuildMcpManifestOptions): McpManifest {
|
|
|
132
145
|
return {
|
|
133
146
|
schemaVersion: "1",
|
|
134
147
|
name: SITE_NAME,
|
|
135
|
-
description:
|
|
136
|
-
"AX-first OSS template — ship software with API + MCP as canonical agent surfaces.",
|
|
148
|
+
description: SITE_DESCRIPTION,
|
|
137
149
|
// Spread conditional to satisfy exactOptionalPropertyTypes: the key is
|
|
138
150
|
// absent, not undefined, when no MCP HTTP host is configured.
|
|
139
151
|
...(opts.mcpHttpUrl ? { mcpEndpoint: opts.mcpHttpUrl } : {}),
|
|
@@ -141,7 +153,7 @@ export function buildMcpManifest(opts: BuildMcpManifestOptions): McpManifest {
|
|
|
141
153
|
addons: { commerce: opts.commerceClaimed === true },
|
|
142
154
|
features: opts.heldFeatures ?? [],
|
|
143
155
|
...(opts.featureGate ? { featureGate: opts.featureGate } : {}),
|
|
144
|
-
}),
|
|
156
|
+
}).filter((tool) => !(opts.unservedTools ?? []).includes(tool.name)),
|
|
145
157
|
transport: {
|
|
146
158
|
type: "http",
|
|
147
159
|
version: MCP_HTTP_VERSION,
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* is appropriate per the MCP discovery spec.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import { UNSERVED_MCP_TOOLS } from "../../../lib/topology.generated";
|
|
29
30
|
import packageJson from "../../../package.json";
|
|
30
31
|
import {
|
|
31
32
|
COMMERCE_FEATURE,
|
|
@@ -59,6 +60,8 @@ export async function GET(): Promise<Response> {
|
|
|
59
60
|
ailkVersion: packageJson.version,
|
|
60
61
|
commerceClaimed: heldFeatures.includes(COMMERCE_FEATURE),
|
|
61
62
|
heldFeatures,
|
|
63
|
+
// The tools this topology's MCP server does not register (#8070 D7).
|
|
64
|
+
unservedTools: UNSERVED_MCP_TOOLS,
|
|
62
65
|
// Spread conditional to satisfy exactOptionalPropertyTypes: omit the key
|
|
63
66
|
// entirely when the env var is not set rather than passing undefined.
|
|
64
67
|
...(mcpHttpUrlEnv ? { mcpHttpUrl: mcpHttpUrlEnv } : {}),
|
|
@@ -0,0 +1,288 @@
|
|
|
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("AC-5 — Billing as the current-plan card (D8)", () => {
|
|
135
|
+
it("(a) shows the plan, the status, the card and Manage in Stripe under the Current plan heading", async () => {
|
|
136
|
+
await renderPage();
|
|
137
|
+
expect(screen.getByText("Current plan")).toBeInTheDocument();
|
|
138
|
+
const card = screen.getByTestId("account-billing-card");
|
|
139
|
+
expect(within(card).getByTestId("account-billing-plan")).toHaveTextContent("Pro");
|
|
140
|
+
expect(within(card).getByTestId("account-billing-status")).toHaveTextContent("Active");
|
|
141
|
+
expect(within(card).getByTestId("account-billing-payment")).toHaveTextContent(
|
|
142
|
+
"visa ending in 4242, expires 04/2027",
|
|
143
|
+
);
|
|
144
|
+
expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage in Stripe");
|
|
145
|
+
expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
|
|
146
|
+
expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("(a) no plan name reads as 'Your subscription', and no card as 'No card on file'", async () => {
|
|
150
|
+
mockSubscriptionMe.mockResolvedValue(
|
|
151
|
+
answer({ status: "past_due", planName: null, paymentMethod: null }),
|
|
152
|
+
);
|
|
153
|
+
await renderPage();
|
|
154
|
+
expect(screen.getByTestId("account-billing-plan")).toHaveTextContent("Your subscription");
|
|
155
|
+
expect(screen.getByTestId("account-billing-status")).toHaveTextContent("Payment due");
|
|
156
|
+
expect(screen.getByTestId("account-billing-payment")).toHaveTextContent("No card on file");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("(a) an older API without the billing fields still renders the status", async () => {
|
|
160
|
+
mockSubscriptionMe.mockResolvedValue({
|
|
161
|
+
ok: true,
|
|
162
|
+
body: { actionRequired: false, status: "trialing", accessRevokesAt: null, portalAvailable: false },
|
|
163
|
+
});
|
|
164
|
+
await renderPage();
|
|
165
|
+
expect(screen.getByTestId("account-billing-status")).toHaveTextContent("Trial");
|
|
166
|
+
expect(screen.getByTestId("account-billing-payment")).toHaveTextContent("No card on file");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("(a) appends when access ends, when set", async () => {
|
|
170
|
+
mockSubscriptionMe.mockResolvedValue(
|
|
171
|
+
answer({ status: "canceled", accessRevokesAt: "2026-10-20T00:00:00.000Z" }),
|
|
172
|
+
);
|
|
173
|
+
await renderPage();
|
|
174
|
+
const status = screen.getByTestId("account-billing-status");
|
|
175
|
+
expect(status).toHaveTextContent("Canceled");
|
|
176
|
+
expect(status).toHaveTextContent("Access ends");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("(b) no portal, no Manage button", async () => {
|
|
180
|
+
mockSubscriptionMe.mockResolvedValue(answer({ portalAvailable: false }));
|
|
181
|
+
await renderPage();
|
|
182
|
+
expect(screen.getByTestId("account-billing-card")).toBeInTheDocument();
|
|
183
|
+
expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("(c) status: null renders the same card shape with the plain line and no button — even with a portal", async () => {
|
|
187
|
+
mockSubscriptionMe.mockResolvedValue(answer({ status: null, portalAvailable: true }));
|
|
188
|
+
await renderPage();
|
|
189
|
+
expect(screen.getByTestId("account-billing-none")).toHaveTextContent("You don't have a subscription.");
|
|
190
|
+
expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
|
|
191
|
+
expect(screen.queryByTestId("account-billing-plan")).not.toBeInTheDocument();
|
|
192
|
+
expect(screen.queryByTestId("account-billing-status")).not.toBeInTheDocument();
|
|
193
|
+
expect(screen.queryByTestId("account-billing-payment")).not.toBeInTheDocument();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it.each([401, 403, 404])("(c) a %i says there is no subscription, with no button", async (status) => {
|
|
197
|
+
mockSubscriptionMe.mockResolvedValue({ ok: false, status, body: {} });
|
|
198
|
+
await renderPage();
|
|
199
|
+
expect(screen.getByTestId("account-billing-none")).toBeInTheDocument();
|
|
200
|
+
expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
|
|
201
|
+
expect(screen.queryByTestId("inbox-error")).not.toBeInTheDocument();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("(g) through D6, the member card renders the shared CurrentPlanCard — exactly one card", async () => {
|
|
205
|
+
await renderPage();
|
|
206
|
+
expect(screen.getAllByTestId("account-billing-card")).toHaveLength(1);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("(d) a 500 shows the error row with Retry, not 'no subscription'", async () => {
|
|
210
|
+
mockSubscriptionMe.mockResolvedValue({ ok: false, status: 500, body: {} });
|
|
211
|
+
await renderPage();
|
|
212
|
+
expect(screen.getByTestId("inbox-error")).toHaveTextContent("We couldn't load your billing details.");
|
|
213
|
+
expect(screen.getByTestId("inbox-retry")).toBeInTheDocument();
|
|
214
|
+
expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("(d) a throw shows the error row, not 'no subscription'", async () => {
|
|
218
|
+
mockSubscriptionMe.mockRejectedValue(new TypeError("fetch failed"));
|
|
219
|
+
await renderPage();
|
|
220
|
+
expect(screen.getByTestId("inbox-error")).toBeInTheDocument();
|
|
221
|
+
expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("(f) the read asks for the billing fields with the caller's cookie", async () => {
|
|
225
|
+
await renderPage();
|
|
226
|
+
expect(mockSubscriptionMe).toHaveBeenCalledWith(
|
|
227
|
+
{ headers: { cookie: "session=abc" } },
|
|
228
|
+
{ include: "billing" },
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("AC-2 (e) — contributed billing sections render after the base content", () => {
|
|
234
|
+
const section: AccountBillingSection = {
|
|
235
|
+
id: "licence",
|
|
236
|
+
title: "settings.billing.title",
|
|
237
|
+
render: () => <p data-testid="licence-body">Licence</p>,
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
beforeAll(() => {
|
|
241
|
+
accountBillingSections.push(section);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
afterAll(() => {
|
|
245
|
+
accountBillingSections.splice(accountBillingSections.indexOf(section), 1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const states: [string, () => void, string][] = [
|
|
249
|
+
["a subscription", () => undefined, "account-billing-card"],
|
|
250
|
+
["no subscription", () => mockSubscriptionMe.mockResolvedValue(answer({ status: null })), "account-billing-card"],
|
|
251
|
+
["a 404", () => mockSubscriptionMe.mockResolvedValue({ ok: false, status: 404, body: {} }), "account-billing-card"],
|
|
252
|
+
["a failed read", () => mockSubscriptionMe.mockResolvedValue({ ok: false, status: 500, body: {} }), "inbox-error"],
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
it.each(states)("after %s", async (_label, arrange, baseTestId) => {
|
|
256
|
+
arrange();
|
|
257
|
+
await renderPage();
|
|
258
|
+
const base = screen.getByTestId(baseTestId);
|
|
259
|
+
const contributed = screen.getByTestId("account-settings-licence");
|
|
260
|
+
expect(within(contributed).getByTestId("licence-body")).toBeInTheDocument();
|
|
261
|
+
expect(base.compareDocumentPosition(contributed) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("#8217 AC-1 — the single site's Manage in Stripe (D10, D11)", () => {
|
|
266
|
+
const PORTAL_URL = "https://billing.stripe.com/p/session/test_single_site";
|
|
267
|
+
|
|
268
|
+
it("mints the portal once through the hub's route with the cookie init, then redirects there", async () => {
|
|
269
|
+
mockPortalSession.mockResolvedValue({ ok: true, body: { url: PORTAL_URL } });
|
|
270
|
+
await openBillingPortal();
|
|
271
|
+
expect(mockPortalSession).toHaveBeenCalledTimes(1);
|
|
272
|
+
expect(mockPortalSession).toHaveBeenCalledWith({ headers: { cookie: "session=abc" } });
|
|
273
|
+
expect(mockRedirect).toHaveBeenCalledTimes(1);
|
|
274
|
+
expect(mockRedirect).toHaveBeenCalledWith(PORTAL_URL);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it.each([
|
|
278
|
+
["a non-2xx", () => mockPortalSession.mockResolvedValue({ ok: false, status: 404, body: {} }), "[openBillingPortal] status=404"],
|
|
279
|
+
["a throw", () => mockPortalSession.mockRejectedValue(new TypeError("fetch failed")), "[openBillingPortal] errName=TypeError"],
|
|
280
|
+
])("%s redirects nowhere and logs only a status or a name", async (_label, arrange, line) => {
|
|
281
|
+
const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
282
|
+
arrange();
|
|
283
|
+
await openBillingPortal();
|
|
284
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
285
|
+
expect(errorSpy.mock.calls.map((args) => args.join(" "))).toEqual([line]);
|
|
286
|
+
errorSpy.mockRestore();
|
|
287
|
+
});
|
|
288
|
+
});
|