create-ailk 0.18.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/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 +7 -2
- package/dist/topology.js +60 -3
- 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 +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__/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 +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 +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/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/topology.generated.ts +24 -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
|
@@ -9,19 +9,33 @@
|
|
|
9
9
|
* suites (spec 7894 D9), so it runs in this monorepo only.
|
|
10
10
|
*
|
|
11
11
|
* Covers:
|
|
12
|
-
* - AC-7: with the hub answering 404 to
|
|
13
|
-
* is admitted on none of them, D6), `loadInboxItems` loads `[]`
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
12
|
+
* - AC-7: with the hub answering 404 to every read (the hub credential
|
|
13
|
+
* is admitted on none of them, D6), `loadInboxItems` loads `[]` with
|
|
14
|
+
* `failed` false — an empty Inbox, never an error row — and every read
|
|
15
|
+
* carries the Bearer credential, not a cookie. The purchase and
|
|
16
|
+
* appointment kinds read `member.purchases` and `member.appointments`,
|
|
17
|
+
* never `flowCheckouts.me` (#8231 D5–D7).
|
|
18
|
+
* - AC-5, as #8232 rewrites it (spec
|
|
19
|
+
* docs/specs/8232-member-settings-account-general-and-spec.md D2–D7, D9;
|
|
20
|
+
* its AC-2 and AC-7): General no longer links to the hub's settings.
|
|
21
|
+
* Where the hub answers the member routes, the name, photo and deletion
|
|
22
|
+
* go through the member API with the Bearer credential, and a save
|
|
23
|
+
* re-seals the session cookie; where it answers none (the fixture's 404),
|
|
24
|
+
* the name is read-only and no deletion path is offered. Billing says
|
|
25
|
+
* there is no subscription without reading `subscription.me`.
|
|
26
|
+
* - #8217 AC-1 (spec docs/specs/8217-spoke-billing-spoke-checkout-reads-spec.md
|
|
27
|
+
* D10–D12): Billing reads the seam's `loadSpokeBilling` — the plan, the
|
|
28
|
+
* card and Manage in Stripe for a subscription; the plain none line with
|
|
29
|
+
* no button for none or a spoke without the action (`null`); the
|
|
30
|
+
* unreadable line; the error row for a failed read — and never calls
|
|
31
|
+
* `subscription.me` or `member.billing` itself. `openBillingPortal`
|
|
32
|
+
* redirects to the loader's portal URL, and to nothing without one.
|
|
19
33
|
*/
|
|
20
34
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
21
35
|
import { tmpdir } from "node:os";
|
|
22
36
|
import { join } from "node:path";
|
|
23
37
|
|
|
24
|
-
import { render, screen } from "@testing-library/react";
|
|
38
|
+
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
25
39
|
|
|
26
40
|
jest.mock("next-intl/server", () => {
|
|
27
41
|
const msgs: unknown = require("../../../../../messages/en.json");
|
|
@@ -35,22 +49,30 @@ jest.mock("next-intl/server", () => {
|
|
|
35
49
|
getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
|
|
36
50
|
const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
|
|
37
51
|
const messages = dig(msgs, ns);
|
|
38
|
-
return (key: string): string => {
|
|
52
|
+
return (key: string, values?: Record<string, unknown>): string => {
|
|
39
53
|
const value = dig(messages, key);
|
|
40
|
-
|
|
54
|
+
if (typeof value !== "string") return `${ns}.${key}`;
|
|
55
|
+
return value.replace(/\{(\w+)\}/g, (m, name: string) =>
|
|
56
|
+
values && name in values ? String(values[name]) : m,
|
|
57
|
+
);
|
|
41
58
|
};
|
|
42
59
|
},
|
|
43
60
|
};
|
|
44
61
|
});
|
|
45
62
|
|
|
46
63
|
const mockHeaders = jest.fn();
|
|
64
|
+
const mockCookieSet = jest.fn();
|
|
47
65
|
jest.mock("next/headers", () => ({
|
|
48
66
|
__esModule: true,
|
|
49
67
|
headers: (...args: unknown[]) => mockHeaders(...args),
|
|
50
|
-
cookies: jest.fn().
|
|
68
|
+
cookies: jest.fn().mockImplementation(async () => ({
|
|
69
|
+
toString: () => "",
|
|
70
|
+
set: (...args: unknown[]) => mockCookieSet(...args),
|
|
71
|
+
})),
|
|
51
72
|
}));
|
|
52
73
|
|
|
53
74
|
const mockReadSession = jest.fn();
|
|
75
|
+
const mockLoadSpokeBilling = jest.fn();
|
|
54
76
|
// 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.
|
|
55
77
|
jest.mock("../../../../../lib/topology.generated", () => ({
|
|
56
78
|
__esModule: true,
|
|
@@ -62,6 +84,8 @@ jest.mock("../../../../../lib/topology.generated", () => ({
|
|
|
62
84
|
SIGN_OUT_ENDPOINT: "/api/hub-session/sign-out",
|
|
63
85
|
readSession: (...args: unknown[]) => mockReadSession(...args),
|
|
64
86
|
registerSiteStores: () => Promise.resolve(),
|
|
87
|
+
// #8217 D7 — the billing render's loader; `null` is a spoke without the action.
|
|
88
|
+
loadSpokeBilling: (...args: unknown[]) => mockLoadSpokeBilling(...args),
|
|
65
89
|
}));
|
|
66
90
|
|
|
67
91
|
const mockLeadsSubmit = jest.fn();
|
|
@@ -70,40 +94,59 @@ const mockWaitlistMe = jest.fn();
|
|
|
70
94
|
const mockFlowCheckoutsMe = jest.fn();
|
|
71
95
|
const mockSubscriptionMe = jest.fn();
|
|
72
96
|
const mockMemberRead = jest.fn();
|
|
97
|
+
const mockMemberPurchases = jest.fn();
|
|
98
|
+
const mockMemberAppointments = jest.fn();
|
|
99
|
+
const mockUpdateProfile = jest.fn();
|
|
100
|
+
const mockRequestDeletion = jest.fn();
|
|
101
|
+
const mockHubMe = jest.fn();
|
|
102
|
+
const mockMemberBilling = jest.fn();
|
|
103
|
+
const mockPortalSession = jest.fn();
|
|
73
104
|
jest.mock("@working-theory/api-client", () => ({
|
|
74
105
|
__esModule: true,
|
|
75
106
|
waitlistSignups: { me: (...args: unknown[]) => mockWaitlistMe(...args) },
|
|
76
107
|
flowCheckouts: { me: (...args: unknown[]) => mockFlowCheckoutsMe(...args) },
|
|
77
108
|
subscription: {
|
|
78
109
|
me: (...args: unknown[]) => mockSubscriptionMe(...args),
|
|
79
|
-
portalSession:
|
|
110
|
+
portalSession: (...args: unknown[]) => mockPortalSession(...args),
|
|
80
111
|
},
|
|
81
112
|
leads: { submit: (...args: unknown[]) => mockLeadsSubmit(...args) },
|
|
82
113
|
// #7920 — the member kinds read the hub with the credential; 404 here.
|
|
83
114
|
member: {
|
|
84
115
|
pieces: (...args: unknown[]) => mockMemberRead(...args),
|
|
85
116
|
messages: (...args: unknown[]) => mockMemberRead(...args),
|
|
117
|
+
purchases: (...args: unknown[]) => mockMemberPurchases(...args),
|
|
118
|
+
appointments: (...args: unknown[]) => mockMemberAppointments(...args),
|
|
119
|
+
updateProfile: (...args: unknown[]) => mockUpdateProfile(...args),
|
|
120
|
+
requestDeletion: (...args: unknown[]) => mockRequestDeletion(...args),
|
|
121
|
+
// #8217 — only the rendered billing action reads it; billing-source.ts never does.
|
|
122
|
+
billing: (...args: unknown[]) => mockMemberBilling(...args),
|
|
86
123
|
},
|
|
87
124
|
visitorForwardHeaders: () => null,
|
|
88
|
-
hubSignIn: { me:
|
|
125
|
+
hubSignIn: { me: (...args: unknown[]) => mockHubMe(...args) },
|
|
89
126
|
}));
|
|
90
127
|
|
|
128
|
+
const mockUpdateUser = jest.fn();
|
|
91
129
|
jest.mock("better-auth/react", () => ({
|
|
92
130
|
__esModule: true,
|
|
93
|
-
createAuthClient: () => ({
|
|
131
|
+
createAuthClient: () => ({
|
|
132
|
+
signOut: jest.fn(),
|
|
133
|
+
updateUser: (...args: unknown[]) => mockUpdateUser(...args),
|
|
134
|
+
}),
|
|
94
135
|
}));
|
|
95
136
|
|
|
137
|
+
const mockRedirect = jest.fn();
|
|
96
138
|
jest.mock("next/navigation", () => ({
|
|
97
139
|
__esModule: true,
|
|
98
|
-
useRouter: () => ({ push: jest.fn(), refresh: jest.fn() }),
|
|
99
|
-
usePathname: () => "/account/
|
|
100
|
-
redirect:
|
|
140
|
+
useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() }),
|
|
141
|
+
usePathname: () => "/account/general",
|
|
142
|
+
redirect: (...args: unknown[]) => mockRedirect(...args),
|
|
101
143
|
}));
|
|
102
144
|
|
|
103
|
-
import { seal } from "../../../../../lib/hub-session/seal";
|
|
145
|
+
import { seal, unseal } from "../../../../../lib/hub-session/seal";
|
|
104
146
|
import { accountSession, loadInboxItems } from "../account-data";
|
|
147
|
+
import { openBillingPortal } from "../actions";
|
|
148
|
+
import GeneralPage from "../general/page";
|
|
105
149
|
import { submitSupportRequest } from "../support-actions";
|
|
106
|
-
import Page from "../settings/page";
|
|
107
150
|
|
|
108
151
|
const HUB = "https://hub.example.com";
|
|
109
152
|
const SESSION_SECRET = "s".repeat(43);
|
|
@@ -142,6 +185,17 @@ beforeEach(() => {
|
|
|
142
185
|
mockFlowCheckoutsMe.mockReset().mockResolvedValue(notFound);
|
|
143
186
|
mockSubscriptionMe.mockReset().mockResolvedValue(notFound);
|
|
144
187
|
mockMemberRead.mockReset().mockResolvedValue(notFound);
|
|
188
|
+
mockMemberPurchases.mockReset().mockResolvedValue(notFound);
|
|
189
|
+
mockMemberAppointments.mockReset().mockResolvedValue(notFound);
|
|
190
|
+
mockUpdateProfile.mockReset();
|
|
191
|
+
mockRequestDeletion.mockReset();
|
|
192
|
+
mockHubMe.mockReset();
|
|
193
|
+
mockLoadSpokeBilling.mockReset().mockResolvedValue(null);
|
|
194
|
+
mockMemberBilling.mockReset();
|
|
195
|
+
mockPortalSession.mockReset();
|
|
196
|
+
mockRedirect.mockReset();
|
|
197
|
+
mockCookieSet.mockReset();
|
|
198
|
+
mockUpdateUser.mockReset();
|
|
145
199
|
});
|
|
146
200
|
|
|
147
201
|
afterEach(() => {
|
|
@@ -150,16 +204,17 @@ afterEach(() => {
|
|
|
150
204
|
});
|
|
151
205
|
|
|
152
206
|
describe("AC-7 — the base Inbox kinds on a hub-mode spoke", () => {
|
|
153
|
-
it("loads [] for
|
|
207
|
+
it("loads [] for every kind, with failed false, when the hub answers 404", async () => {
|
|
154
208
|
const session = await accountSession("en");
|
|
155
209
|
if (session === null) throw new Error("expected a session");
|
|
156
210
|
expect(session.ctx.init).toEqual({ headers: { authorization: `Bearer ${CREDENTIAL}` } });
|
|
157
211
|
|
|
158
212
|
const inbox = await loadInboxItems(session.ctx);
|
|
159
213
|
expect(inbox).toEqual({ items: [], failed: false });
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
214
|
+
const bearer = { headers: { authorization: `Bearer ${CREDENTIAL}` } };
|
|
215
|
+
expect(mockMemberPurchases).toHaveBeenCalledWith(bearer);
|
|
216
|
+
expect(mockMemberAppointments).toHaveBeenCalledWith(bearer);
|
|
217
|
+
expect(mockFlowCheckoutsMe).not.toHaveBeenCalled();
|
|
163
218
|
});
|
|
164
219
|
|
|
165
220
|
it("has no session when the seam reads none", async () => {
|
|
@@ -168,23 +223,86 @@ describe("AC-7 — the base Inbox kinds on a hub-mode spoke", () => {
|
|
|
168
223
|
});
|
|
169
224
|
});
|
|
170
225
|
|
|
171
|
-
describe("AC-5 —
|
|
172
|
-
|
|
173
|
-
|
|
226
|
+
describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
|
|
227
|
+
const bearer = { headers: { authorization: `Bearer ${CREDENTIAL}` } };
|
|
228
|
+
|
|
229
|
+
async function renderGeneral(): Promise<void> {
|
|
230
|
+
render(await GeneralPage({ params: Promise.resolve({ locale: "en" }) }));
|
|
174
231
|
}
|
|
175
232
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
233
|
+
describe("a hub that answers the member routes", () => {
|
|
234
|
+
beforeEach(() => {
|
|
235
|
+
mockMemberRead.mockResolvedValue({ ok: true, body: [] });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("renders the name and photo form and the deletion button, with no link to the hub's settings", async () => {
|
|
239
|
+
await renderGeneral();
|
|
240
|
+
expect(screen.getByTestId("account-general-form")).toBeInTheDocument();
|
|
241
|
+
expect(screen.getByLabelText("Name")).toHaveValue("Ada Lovelace");
|
|
242
|
+
// No hub read carries the photo, so the field starts empty (C5).
|
|
243
|
+
expect(screen.getByLabelText("Photo")).toHaveValue("");
|
|
244
|
+
expect(screen.getByTestId("account-deletion-request")).toBeInTheDocument();
|
|
245
|
+
expect(screen.queryByTestId("account-settings-manage-on-hub")).not.toBeInTheDocument();
|
|
246
|
+
expect(screen.queryByTestId("account-privacy-manage-on-hub")).not.toBeInTheDocument();
|
|
247
|
+
expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
|
|
248
|
+
});
|
|
249
|
+
|
|
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
|
+
it("saves the name through member.updateProfile with the Bearer credential, then re-seals the session", async () => {
|
|
260
|
+
mockUpdateProfile.mockResolvedValue({ ok: true, body: { name: "Ada King", image: null } });
|
|
261
|
+
mockHubMe.mockResolvedValue({ ok: true, body: { user: { ...USER, name: "Ada King" }, billingCustomerId: null } });
|
|
262
|
+
await renderGeneral();
|
|
263
|
+
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Ada King" } });
|
|
264
|
+
await act(async () => {
|
|
265
|
+
fireEvent.click(screen.getByTestId("account-general-save"));
|
|
266
|
+
});
|
|
267
|
+
expect(mockUpdateProfile).toHaveBeenCalledWith({ name: "Ada King" }, bearer);
|
|
268
|
+
expect(mockUpdateUser).not.toHaveBeenCalled();
|
|
269
|
+
expect(mockHubMe).toHaveBeenCalledWith(bearer);
|
|
270
|
+
expect(mockCookieSet).toHaveBeenCalledTimes(1);
|
|
271
|
+
const [name, value] = mockCookieSet.mock.calls[0] as [string, string];
|
|
272
|
+
expect(name).toBe("__Host-ailk-hub-session");
|
|
273
|
+
expect(unseal(value, SESSION_SECRET, "session", name)).toMatchObject({
|
|
274
|
+
accessToken: CREDENTIAL,
|
|
275
|
+
user: { name: "Ada King" },
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("the deletion request goes through the member API and names the hub operator", async () => {
|
|
280
|
+
mockRequestDeletion.mockResolvedValue({ ok: true, body: { sentTo: "hub-operator" } });
|
|
281
|
+
await renderGeneral();
|
|
282
|
+
await act(async () => {
|
|
283
|
+
fireEvent.click(screen.getByTestId("account-deletion-request"));
|
|
284
|
+
});
|
|
285
|
+
expect(mockRequestDeletion).toHaveBeenCalledWith({}, bearer);
|
|
286
|
+
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Sent to the hub operator."));
|
|
287
|
+
expect(mockLeadsSubmit).not.toHaveBeenCalled();
|
|
288
|
+
});
|
|
185
289
|
});
|
|
186
290
|
|
|
187
|
-
|
|
291
|
+
describe("a hub that answers no member route (the fixture's 404)", () => {
|
|
292
|
+
it("shows the name read-only and offers no save and no deletion path", async () => {
|
|
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();
|
|
297
|
+
expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
|
|
298
|
+
expect(screen.queryByTestId("account-deletion-support")).not.toBeInTheDocument();
|
|
299
|
+
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
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("files a posted deletion request as a support request — Support files support only (SEC-7894-11)", async () => {
|
|
188
306
|
mockLeadsSubmit.mockResolvedValue({ ok: true, body: {} });
|
|
189
307
|
const form = new FormData();
|
|
190
308
|
form.set("kind", "deletion");
|
|
@@ -196,11 +314,123 @@ describe("AC-5 — the settings page on a hub-mode spoke", () => {
|
|
|
196
314
|
);
|
|
197
315
|
});
|
|
198
316
|
|
|
199
|
-
it("
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
});
|
|
317
|
+
it("Billing says there is no subscription, with no button, without reading subscription.me", async () => {
|
|
318
|
+
// The Billing route's own page, loaded with `import()`: architecture.yaml bans
|
|
319
|
+
// a static import of a relative billing/ path to keep OSS code out of the
|
|
320
|
+
// apps-paid/billing service's source, and this route directory shares the name.
|
|
321
|
+
const { default: BillingPage } = await import("../billing/page");
|
|
322
|
+
render(await BillingPage({ params: Promise.resolve({ locale: "en" }) }));
|
|
323
|
+
expect(screen.getByTestId("account-billing-none")).toHaveTextContent("You don't have a subscription.");
|
|
324
|
+
expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
|
|
325
|
+
expect(mockSubscriptionMe).not.toHaveBeenCalled();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("#8217 AC-1 — Billing on a hub-mode spoke reads the seam's spoke billing (D10–D12)", () => {
|
|
330
|
+
const VISA = { brand: "visa", last4: "4242", expMonth: 4, expYear: 2030 };
|
|
331
|
+
const PORTAL_URL = "https://billing.stripe.com/p/session/test_fake";
|
|
332
|
+
|
|
333
|
+
/** A `SpokeBilling` stub: what the rendered action returns. */
|
|
334
|
+
function stub(read: unknown, portalUrl: string | null = PORTAL_URL) {
|
|
335
|
+
return { read: jest.fn().mockResolvedValue(read), portalUrl: jest.fn().mockResolvedValue(portalUrl) };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function renderBilling(): Promise<void> {
|
|
339
|
+
// The Billing route's own page, loaded with `import()` — see the AC-5 case above.
|
|
340
|
+
const { default: BillingPage } = await import("../billing/page");
|
|
341
|
+
render(await BillingPage({ params: Promise.resolve({ locale: "en" }) }));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
afterEach(() => {
|
|
345
|
+
// Every case: billing-source.ts reads neither the hub's subscription nor its billing ids.
|
|
346
|
+
expect(mockSubscriptionMe).not.toHaveBeenCalled();
|
|
347
|
+
expect(mockMemberBilling).not.toHaveBeenCalled();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("a subscription renders the plan, the card and Manage in Stripe", async () => {
|
|
351
|
+
mockLoadSpokeBilling.mockResolvedValue(
|
|
352
|
+
stub({ kind: "subscription", status: "active", planName: "Pro", paymentMethod: VISA, unreadable: false }),
|
|
353
|
+
);
|
|
354
|
+
await renderBilling();
|
|
355
|
+
const card = screen.getByTestId("account-billing-card");
|
|
356
|
+
expect(card).toHaveTextContent("Pro");
|
|
357
|
+
expect(card).toHaveTextContent("Active");
|
|
358
|
+
expect(card).toHaveTextContent("visa ending in 4242, expires 04/2030");
|
|
359
|
+
expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage in Stripe");
|
|
360
|
+
expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
|
|
361
|
+
expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("none renders the plain none line and no button", async () => {
|
|
365
|
+
mockLoadSpokeBilling.mockResolvedValue(stub({ kind: "none", unreadable: false }, null));
|
|
366
|
+
await renderBilling();
|
|
367
|
+
expect(screen.getByTestId("account-billing-none")).toHaveTextContent("You don't have a subscription.");
|
|
368
|
+
expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
|
|
369
|
+
expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it.each([
|
|
373
|
+
["none", { kind: "none", unreadable: true }, "account-billing-card"],
|
|
374
|
+
[
|
|
375
|
+
"a subscription",
|
|
376
|
+
{ kind: "subscription", status: "past_due", planName: null, paymentMethod: null, unreadable: true },
|
|
377
|
+
"account-billing-card",
|
|
378
|
+
],
|
|
379
|
+
])("unreadable: true on %s adds the unreadable line after it", async (_label, read, baseTestId) => {
|
|
380
|
+
mockLoadSpokeBilling.mockResolvedValue(stub(read));
|
|
381
|
+
await renderBilling();
|
|
382
|
+
const line = screen.getByTestId("account-billing-unreadable");
|
|
383
|
+
expect(line).toHaveTextContent("Some of your billing is with another Stripe account, so it isn't shown here.");
|
|
384
|
+
const base = screen.getByTestId(baseTestId);
|
|
385
|
+
expect(base.compareDocumentPosition(line) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("a failed read renders the error row, never 'no subscription'", async () => {
|
|
389
|
+
mockLoadSpokeBilling.mockResolvedValue(stub({ kind: "failed" }));
|
|
390
|
+
await renderBilling();
|
|
391
|
+
expect(screen.getByTestId("inbox-error")).toHaveTextContent("We couldn't load your billing details.");
|
|
392
|
+
expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
|
|
393
|
+
expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("a loader that fails to load renders the error row", async () => {
|
|
397
|
+
const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
398
|
+
mockLoadSpokeBilling.mockRejectedValue(new SyntaxError("checkout-config.ts is malformed"));
|
|
399
|
+
await renderBilling();
|
|
400
|
+
expect(screen.getByTestId("inbox-error")).toBeInTheDocument();
|
|
401
|
+
expect(errorSpy.mock.calls.map((args) => args.join(" "))).toEqual([
|
|
402
|
+
"[account] read failed source=spoke-billing errName=SyntaxError",
|
|
403
|
+
]);
|
|
404
|
+
errorSpy.mockRestore();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("a spoke without the action (the loader resolves null) renders none and no button", async () => {
|
|
408
|
+
await renderBilling();
|
|
409
|
+
expect(screen.getByTestId("account-billing-none")).toBeInTheDocument();
|
|
410
|
+
expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
|
|
411
|
+
expect(mockLoadSpokeBilling).toHaveBeenCalledTimes(1);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("openBillingPortal redirects to the loader's portal URL", async () => {
|
|
415
|
+
const billing = stub({ kind: "none", unreadable: false });
|
|
416
|
+
mockLoadSpokeBilling.mockResolvedValue(billing);
|
|
417
|
+
await openBillingPortal();
|
|
418
|
+
expect(billing.portalUrl).toHaveBeenCalledWith();
|
|
419
|
+
expect(mockRedirect).toHaveBeenCalledTimes(1);
|
|
420
|
+
expect(mockRedirect).toHaveBeenCalledWith(PORTAL_URL);
|
|
421
|
+
expect(mockPortalSession).not.toHaveBeenCalled();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it.each([
|
|
425
|
+
["the loader's portal is null", () => mockLoadSpokeBilling.mockResolvedValue(stub({ kind: "none", unreadable: false }, null))],
|
|
426
|
+
["there is no loader", () => mockLoadSpokeBilling.mockResolvedValue(null)],
|
|
427
|
+
["the loader fails to load", () => mockLoadSpokeBilling.mockRejectedValue(new Error("load failed"))],
|
|
428
|
+
])("openBillingPortal redirects nowhere when %s", async (_label, arrange) => {
|
|
429
|
+
const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
430
|
+
arrange();
|
|
431
|
+
await openBillingPortal();
|
|
432
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
433
|
+
expect(mockPortalSession).not.toHaveBeenCalled();
|
|
434
|
+
errorSpy.mockRestore();
|
|
205
435
|
});
|
|
206
436
|
});
|
|
@@ -9,6 +9,8 @@
|
|
|
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
|
+
*
|
|
13
|
+
* The `purchase` kind reads `member.purchases` (#8231 D5).
|
|
12
14
|
*/
|
|
13
15
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
14
16
|
|
|
@@ -49,12 +51,12 @@ jest.mock("../../../../../auth", () => ({
|
|
|
49
51
|
}));
|
|
50
52
|
|
|
51
53
|
const mockSignupMe = jest.fn();
|
|
52
|
-
const
|
|
54
|
+
const mockMemberPurchases = jest.fn();
|
|
53
55
|
jest.mock("@working-theory/api-client", () => ({
|
|
54
56
|
__esModule: true,
|
|
55
57
|
waitlistSignups: { me: (...args: unknown[]) => mockSignupMe(...args) },
|
|
56
|
-
flowCheckouts: { me: (...args: unknown[]) => mockCheckoutsMe(...args) },
|
|
57
58
|
subscription: { me: jest.fn() },
|
|
59
|
+
member: { purchases: (...args: unknown[]) => mockMemberPurchases(...args) },
|
|
58
60
|
}));
|
|
59
61
|
|
|
60
62
|
const mockNotFound = jest.fn(() => {
|
|
@@ -74,22 +76,18 @@ const SESSION = {
|
|
|
74
76
|
user: { id: "user_1", name: "Ada", email: "ada@example.com", emailVerified: true },
|
|
75
77
|
};
|
|
76
78
|
|
|
77
|
-
const
|
|
79
|
+
const PURCHASE = {
|
|
78
80
|
id: "fc_done",
|
|
79
|
-
|
|
80
|
-
experimentKey: "ailk-founder-diagnostic",
|
|
81
|
-
offerKey: "pro",
|
|
82
|
-
status: "completed",
|
|
81
|
+
offering: "pro",
|
|
83
82
|
amountMinor: 4900,
|
|
84
83
|
currency: "usd",
|
|
85
84
|
period: "monthly",
|
|
86
|
-
|
|
87
|
-
createdAt: "2026-09-04T09:58:00.000Z",
|
|
85
|
+
purchasedAt: "2026-09-04T10:00:00.000Z",
|
|
88
86
|
};
|
|
89
87
|
|
|
90
88
|
beforeEach(() => {
|
|
91
89
|
mockGetSession.mockReset().mockResolvedValue(SESSION);
|
|
92
|
-
|
|
90
|
+
mockMemberPurchases.mockReset().mockResolvedValue({ ok: true, body: { items: [PURCHASE] } });
|
|
93
91
|
mockSignupMe.mockReset();
|
|
94
92
|
mockNotFound.mockClear();
|
|
95
93
|
mockRefresh.mockReset();
|
|
@@ -113,7 +111,7 @@ describe("one Inbox item", () => {
|
|
|
113
111
|
});
|
|
114
112
|
|
|
115
113
|
it("decodes an encoded id segment", async () => {
|
|
116
|
-
|
|
114
|
+
mockMemberPurchases.mockResolvedValue({ ok: true, body: { items: [{ ...PURCHASE, id: "fc done" }] } });
|
|
117
115
|
await renderItem("purchase", "fc%20done");
|
|
118
116
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("pro");
|
|
119
117
|
});
|
|
@@ -125,7 +123,7 @@ describe("one Inbox item", () => {
|
|
|
125
123
|
|
|
126
124
|
it("an unknown kind is a not-found", async () => {
|
|
127
125
|
await expect(renderItem("nope", "fc_done")).rejects.toThrow("NEXT_NOT_FOUND");
|
|
128
|
-
expect(
|
|
126
|
+
expect(mockMemberPurchases).not.toHaveBeenCalled();
|
|
129
127
|
});
|
|
130
128
|
|
|
131
129
|
it("an id the kind did not load is a not-found", async () => {
|
|
@@ -133,7 +131,7 @@ describe("one Inbox item", () => {
|
|
|
133
131
|
});
|
|
134
132
|
|
|
135
133
|
it("a failed read renders an error with Retry, not a not-found", async () => {
|
|
136
|
-
|
|
134
|
+
mockMemberPurchases.mockResolvedValue({ ok: false, status: 500, body: {} });
|
|
137
135
|
await renderItem("purchase", "fc_done");
|
|
138
136
|
expect(mockNotFound).not.toHaveBeenCalled();
|
|
139
137
|
expect(screen.getByTestId("inbox-error")).toHaveTextContent("We couldn't load this item.");
|