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
|
@@ -9,19 +9,38 @@
|
|
|
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 billing 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.
|
|
33
|
+
* - #8431 AC-4 (spec docs/specs/8431-account-security-tab-human-sign-spec.md
|
|
34
|
+
* D1–D3): Security says the hub manages sign-in and links to the hub's
|
|
35
|
+
* own Security page, offers no password control and never reads the
|
|
36
|
+
* seam's accounts; Log out POSTs the spoke's sign-out endpoint, never
|
|
37
|
+
* Better Auth, and lands on `/account` only when it succeeded.
|
|
19
38
|
*/
|
|
20
39
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
21
40
|
import { tmpdir } from "node:os";
|
|
22
41
|
import { join } from "node:path";
|
|
23
42
|
|
|
24
|
-
import { render, screen } from "@testing-library/react";
|
|
43
|
+
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
25
44
|
|
|
26
45
|
jest.mock("next-intl/server", () => {
|
|
27
46
|
const msgs: unknown = require("../../../../../messages/en.json");
|
|
@@ -35,22 +54,31 @@ jest.mock("next-intl/server", () => {
|
|
|
35
54
|
getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
|
|
36
55
|
const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
|
|
37
56
|
const messages = dig(msgs, ns);
|
|
38
|
-
return (key: string): string => {
|
|
57
|
+
return (key: string, values?: Record<string, unknown>): string => {
|
|
39
58
|
const value = dig(messages, key);
|
|
40
|
-
|
|
59
|
+
if (typeof value !== "string") return `${ns}.${key}`;
|
|
60
|
+
return value.replace(/\{(\w+)\}/g, (m, name: string) =>
|
|
61
|
+
values && name in values ? String(values[name]) : m,
|
|
62
|
+
);
|
|
41
63
|
};
|
|
42
64
|
},
|
|
43
65
|
};
|
|
44
66
|
});
|
|
45
67
|
|
|
46
68
|
const mockHeaders = jest.fn();
|
|
69
|
+
const mockCookieSet = jest.fn();
|
|
47
70
|
jest.mock("next/headers", () => ({
|
|
48
71
|
__esModule: true,
|
|
49
72
|
headers: (...args: unknown[]) => mockHeaders(...args),
|
|
50
|
-
cookies: jest.fn().
|
|
73
|
+
cookies: jest.fn().mockImplementation(async () => ({
|
|
74
|
+
toString: () => "",
|
|
75
|
+
set: (...args: unknown[]) => mockCookieSet(...args),
|
|
76
|
+
})),
|
|
51
77
|
}));
|
|
52
78
|
|
|
53
79
|
const mockReadSession = jest.fn();
|
|
80
|
+
const mockLoadSpokeBilling = jest.fn();
|
|
81
|
+
const mockReadOwnAccounts = jest.fn();
|
|
54
82
|
// eslint-disable-next-line no-restricted-syntax -- #507: the seam is generated per build (#7894 D8); its `signIn: "hub"` render is the boundary under test, and no env or HTTP surface selects it.
|
|
55
83
|
jest.mock("../../../../../lib/topology.generated", () => ({
|
|
56
84
|
__esModule: true,
|
|
@@ -62,6 +90,10 @@ jest.mock("../../../../../lib/topology.generated", () => ({
|
|
|
62
90
|
SIGN_OUT_ENDPOINT: "/api/hub-session/sign-out",
|
|
63
91
|
readSession: (...args: unknown[]) => mockReadSession(...args),
|
|
64
92
|
registerSiteStores: () => Promise.resolve(),
|
|
93
|
+
// #8217 D7 — the billing render's loader; `null` is a spoke without the action.
|
|
94
|
+
loadSpokeBilling: (...args: unknown[]) => mockLoadSpokeBilling(...args),
|
|
95
|
+
// #8431 D2a — a spoke has no local accounts; Security must never ask.
|
|
96
|
+
readOwnAccounts: (...args: unknown[]) => mockReadOwnAccounts(...args),
|
|
65
97
|
}));
|
|
66
98
|
|
|
67
99
|
const mockLeadsSubmit = jest.fn();
|
|
@@ -70,40 +102,61 @@ const mockWaitlistMe = jest.fn();
|
|
|
70
102
|
const mockFlowCheckoutsMe = jest.fn();
|
|
71
103
|
const mockSubscriptionMe = jest.fn();
|
|
72
104
|
const mockMemberRead = jest.fn();
|
|
105
|
+
const mockMemberPurchases = jest.fn();
|
|
106
|
+
const mockMemberAppointments = jest.fn();
|
|
107
|
+
const mockUpdateProfile = jest.fn();
|
|
108
|
+
const mockRequestDeletion = jest.fn();
|
|
109
|
+
const mockHubMe = jest.fn();
|
|
110
|
+
const mockMemberBilling = jest.fn();
|
|
111
|
+
const mockPortalSession = jest.fn();
|
|
73
112
|
jest.mock("@working-theory/api-client", () => ({
|
|
74
113
|
__esModule: true,
|
|
75
114
|
waitlistSignups: { me: (...args: unknown[]) => mockWaitlistMe(...args) },
|
|
76
115
|
flowCheckouts: { me: (...args: unknown[]) => mockFlowCheckoutsMe(...args) },
|
|
77
116
|
subscription: {
|
|
78
117
|
me: (...args: unknown[]) => mockSubscriptionMe(...args),
|
|
79
|
-
portalSession:
|
|
118
|
+
portalSession: (...args: unknown[]) => mockPortalSession(...args),
|
|
80
119
|
},
|
|
81
120
|
leads: { submit: (...args: unknown[]) => mockLeadsSubmit(...args) },
|
|
82
121
|
// #7920 — the member kinds read the hub with the credential; 404 here.
|
|
83
122
|
member: {
|
|
84
123
|
pieces: (...args: unknown[]) => mockMemberRead(...args),
|
|
85
124
|
messages: (...args: unknown[]) => mockMemberRead(...args),
|
|
125
|
+
purchases: (...args: unknown[]) => mockMemberPurchases(...args),
|
|
126
|
+
appointments: (...args: unknown[]) => mockMemberAppointments(...args),
|
|
127
|
+
updateProfile: (...args: unknown[]) => mockUpdateProfile(...args),
|
|
128
|
+
requestDeletion: (...args: unknown[]) => mockRequestDeletion(...args),
|
|
129
|
+
// #8217 — only the rendered billing action reads it; billing-source.ts never does.
|
|
130
|
+
billing: (...args: unknown[]) => mockMemberBilling(...args),
|
|
86
131
|
},
|
|
87
132
|
visitorForwardHeaders: () => null,
|
|
88
|
-
hubSignIn: { me:
|
|
133
|
+
hubSignIn: { me: (...args: unknown[]) => mockHubMe(...args) },
|
|
89
134
|
}));
|
|
90
135
|
|
|
136
|
+
const mockUpdateUser = jest.fn();
|
|
137
|
+
const mockSignOut = jest.fn();
|
|
91
138
|
jest.mock("better-auth/react", () => ({
|
|
92
139
|
__esModule: true,
|
|
93
|
-
createAuthClient: () => ({
|
|
140
|
+
createAuthClient: () => ({
|
|
141
|
+
signOut: (...args: unknown[]) => mockSignOut(...args),
|
|
142
|
+
updateUser: (...args: unknown[]) => mockUpdateUser(...args),
|
|
143
|
+
}),
|
|
94
144
|
}));
|
|
95
145
|
|
|
146
|
+
const mockRedirect = jest.fn();
|
|
147
|
+
const mockPush = jest.fn();
|
|
96
148
|
jest.mock("next/navigation", () => ({
|
|
97
149
|
__esModule: true,
|
|
98
|
-
useRouter: () => ({ push: jest.fn(),
|
|
99
|
-
usePathname: () => "/account/
|
|
100
|
-
redirect:
|
|
150
|
+
useRouter: () => ({ push: mockPush, refresh: jest.fn(), replace: jest.fn() }),
|
|
151
|
+
usePathname: () => "/account/general",
|
|
152
|
+
redirect: (...args: unknown[]) => mockRedirect(...args),
|
|
101
153
|
}));
|
|
102
154
|
|
|
103
|
-
import { seal } from "../../../../../lib/hub-session/seal";
|
|
155
|
+
import { seal, unseal } from "../../../../../lib/hub-session/seal";
|
|
104
156
|
import { accountSession, loadInboxItems } from "../account-data";
|
|
157
|
+
import { openBillingPortal } from "../actions";
|
|
158
|
+
import GeneralPage from "../general/page";
|
|
105
159
|
import { submitSupportRequest } from "../support-actions";
|
|
106
|
-
import Page from "../settings/page";
|
|
107
160
|
|
|
108
161
|
const HUB = "https://hub.example.com";
|
|
109
162
|
const SESSION_SECRET = "s".repeat(43);
|
|
@@ -142,6 +195,20 @@ beforeEach(() => {
|
|
|
142
195
|
mockFlowCheckoutsMe.mockReset().mockResolvedValue(notFound);
|
|
143
196
|
mockSubscriptionMe.mockReset().mockResolvedValue(notFound);
|
|
144
197
|
mockMemberRead.mockReset().mockResolvedValue(notFound);
|
|
198
|
+
mockMemberPurchases.mockReset().mockResolvedValue(notFound);
|
|
199
|
+
mockMemberAppointments.mockReset().mockResolvedValue(notFound);
|
|
200
|
+
mockUpdateProfile.mockReset();
|
|
201
|
+
mockRequestDeletion.mockReset();
|
|
202
|
+
mockHubMe.mockReset();
|
|
203
|
+
mockLoadSpokeBilling.mockReset().mockResolvedValue(null);
|
|
204
|
+
mockMemberBilling.mockReset();
|
|
205
|
+
mockPortalSession.mockReset();
|
|
206
|
+
mockRedirect.mockReset();
|
|
207
|
+
mockCookieSet.mockReset();
|
|
208
|
+
mockUpdateUser.mockReset();
|
|
209
|
+
mockReadOwnAccounts.mockReset().mockResolvedValue(null);
|
|
210
|
+
mockSignOut.mockReset();
|
|
211
|
+
mockPush.mockReset();
|
|
145
212
|
});
|
|
146
213
|
|
|
147
214
|
afterEach(() => {
|
|
@@ -150,16 +217,17 @@ afterEach(() => {
|
|
|
150
217
|
});
|
|
151
218
|
|
|
152
219
|
describe("AC-7 — the base Inbox kinds on a hub-mode spoke", () => {
|
|
153
|
-
it("loads [] for
|
|
220
|
+
it("loads [] for every kind, with failed false, when the hub answers 404", async () => {
|
|
154
221
|
const session = await accountSession("en");
|
|
155
222
|
if (session === null) throw new Error("expected a session");
|
|
156
223
|
expect(session.ctx.init).toEqual({ headers: { authorization: `Bearer ${CREDENTIAL}` } });
|
|
157
224
|
|
|
158
225
|
const inbox = await loadInboxItems(session.ctx);
|
|
159
226
|
expect(inbox).toEqual({ items: [], failed: false });
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
227
|
+
const bearer = { headers: { authorization: `Bearer ${CREDENTIAL}` } };
|
|
228
|
+
expect(mockMemberPurchases).toHaveBeenCalledWith(bearer);
|
|
229
|
+
expect(mockMemberAppointments).toHaveBeenCalledWith(bearer);
|
|
230
|
+
expect(mockFlowCheckoutsMe).not.toHaveBeenCalled();
|
|
163
231
|
});
|
|
164
232
|
|
|
165
233
|
it("has no session when the seam reads none", async () => {
|
|
@@ -168,23 +236,77 @@ describe("AC-7 — the base Inbox kinds on a hub-mode spoke", () => {
|
|
|
168
236
|
});
|
|
169
237
|
});
|
|
170
238
|
|
|
171
|
-
describe("AC-5 —
|
|
172
|
-
|
|
173
|
-
|
|
239
|
+
describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
|
|
240
|
+
const bearer = { headers: { authorization: `Bearer ${CREDENTIAL}` } };
|
|
241
|
+
|
|
242
|
+
async function renderGeneral(): Promise<void> {
|
|
243
|
+
render(await GeneralPage({ params: Promise.resolve({ locale: "en" }) }));
|
|
174
244
|
}
|
|
175
245
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
246
|
+
describe("a hub that answers the member routes", () => {
|
|
247
|
+
beforeEach(() => {
|
|
248
|
+
mockMemberRead.mockResolvedValue({ ok: true, body: [] });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("renders the name and photo form and the deletion button, with no link to the hub's settings", async () => {
|
|
252
|
+
await renderGeneral();
|
|
253
|
+
expect(screen.getByTestId("account-general-form")).toBeInTheDocument();
|
|
254
|
+
expect(screen.getByLabelText("Name")).toHaveValue("Ada Lovelace");
|
|
255
|
+
// No hub read carries the photo, so the field starts empty (C5).
|
|
256
|
+
expect(screen.getByLabelText("Photo")).toHaveValue("");
|
|
257
|
+
expect(screen.getByTestId("account-deletion-request")).toBeInTheDocument();
|
|
258
|
+
expect(screen.queryByTestId("account-settings-manage-on-hub")).not.toBeInTheDocument();
|
|
259
|
+
expect(screen.queryByTestId("account-privacy-manage-on-hub")).not.toBeInTheDocument();
|
|
260
|
+
expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("saves the name through member.updateProfile with the Bearer credential, then re-seals the session", async () => {
|
|
264
|
+
mockUpdateProfile.mockResolvedValue({ ok: true, body: { name: "Ada King", image: null } });
|
|
265
|
+
mockHubMe.mockResolvedValue({ ok: true, body: { user: { ...USER, name: "Ada King" }, billingCustomerId: null } });
|
|
266
|
+
await renderGeneral();
|
|
267
|
+
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Ada King" } });
|
|
268
|
+
await act(async () => {
|
|
269
|
+
fireEvent.click(screen.getByTestId("account-general-save"));
|
|
270
|
+
});
|
|
271
|
+
expect(mockUpdateProfile).toHaveBeenCalledWith({ name: "Ada King" }, bearer);
|
|
272
|
+
expect(mockUpdateUser).not.toHaveBeenCalled();
|
|
273
|
+
expect(mockHubMe).toHaveBeenCalledWith(bearer);
|
|
274
|
+
expect(mockCookieSet).toHaveBeenCalledTimes(1);
|
|
275
|
+
const [name, value] = mockCookieSet.mock.calls[0] as [string, string];
|
|
276
|
+
expect(name).toBe("__Host-ailk-hub-session");
|
|
277
|
+
expect(unseal(value, SESSION_SECRET, "session", name)).toMatchObject({
|
|
278
|
+
accessToken: CREDENTIAL,
|
|
279
|
+
user: { name: "Ada King" },
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("the deletion request goes through the member API and names the hub operator", async () => {
|
|
284
|
+
mockRequestDeletion.mockResolvedValue({ ok: true, body: { sentTo: "hub-operator" } });
|
|
285
|
+
await renderGeneral();
|
|
286
|
+
await act(async () => {
|
|
287
|
+
fireEvent.click(screen.getByTestId("account-deletion-request"));
|
|
288
|
+
});
|
|
289
|
+
expect(mockRequestDeletion).toHaveBeenCalledWith({}, bearer);
|
|
290
|
+
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Sent to the hub operator."));
|
|
291
|
+
expect(mockLeadsSubmit).not.toHaveBeenCalled();
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("a hub that answers no member route (the fixture's 404)", () => {
|
|
296
|
+
it("shows the name read-only and offers no save and no deletion path", async () => {
|
|
297
|
+
await renderGeneral();
|
|
298
|
+
expect(screen.getByTestId("account-general-name-text")).toHaveTextContent("Ada Lovelace");
|
|
299
|
+
expect(screen.queryByTestId("account-general-form")).not.toBeInTheDocument();
|
|
300
|
+
expect(screen.queryByLabelText("Photo")).not.toBeInTheDocument();
|
|
301
|
+
expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
|
|
302
|
+
expect(screen.queryByTestId("account-deletion-support")).not.toBeInTheDocument();
|
|
303
|
+
expect(screen.getByText("This site can't take deletion requests right now.")).toBeInTheDocument();
|
|
304
|
+
expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
|
|
305
|
+
expect(mockUpdateUser).not.toHaveBeenCalled();
|
|
306
|
+
});
|
|
185
307
|
});
|
|
186
308
|
|
|
187
|
-
it("files a posted deletion request as a support request —
|
|
309
|
+
it("files a posted deletion request as a support request — Support files support only (SEC-7894-11)", async () => {
|
|
188
310
|
mockLeadsSubmit.mockResolvedValue({ ok: true, body: {} });
|
|
189
311
|
const form = new FormData();
|
|
190
312
|
form.set("kind", "deletion");
|
|
@@ -196,11 +318,194 @@ describe("AC-5 — the settings page on a hub-mode spoke", () => {
|
|
|
196
318
|
);
|
|
197
319
|
});
|
|
198
320
|
|
|
199
|
-
it("
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
321
|
+
it("Billing says there is no subscription, with no button, without reading subscription.me", async () => {
|
|
322
|
+
// The Billing route's own page, loaded with `import()`: architecture.yaml bans
|
|
323
|
+
// a static import of a relative billing/ path to keep OSS code out of the
|
|
324
|
+
// apps-paid/billing service's source, and this route directory shares the name.
|
|
325
|
+
const { default: BillingPage } = await import("../billing/page");
|
|
326
|
+
render(await BillingPage({ params: Promise.resolve({ locale: "en" }) }));
|
|
327
|
+
expect(screen.getByTestId("account-billing-none")).toHaveTextContent("You don't have a subscription.");
|
|
328
|
+
expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
|
|
329
|
+
expect(mockSubscriptionMe).not.toHaveBeenCalled();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe("#8431 AC-4 — Security on a hub-mode spoke", () => {
|
|
334
|
+
const realFetch = Object.getOwnPropertyDescriptor(globalThis, "fetch");
|
|
335
|
+
const mockFetch = jest.fn();
|
|
336
|
+
|
|
337
|
+
beforeEach(() => {
|
|
338
|
+
mockFetch.mockReset();
|
|
339
|
+
Object.defineProperty(globalThis, "fetch", { value: mockFetch, writable: true, configurable: true });
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
afterEach(() => {
|
|
343
|
+
if (realFetch) Object.defineProperty(globalThis, "fetch", realFetch);
|
|
344
|
+
else Reflect.deleteProperty(globalThis, "fetch");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
async function renderSecurity(): Promise<void> {
|
|
348
|
+
const { default: SecurityPage } = await import("../security/page");
|
|
349
|
+
render(await SecurityPage({ params: Promise.resolve({ locale: "en" }) }));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
it("says the hub manages sign-in and links to the hub's Security page — no password control", async () => {
|
|
353
|
+
await renderSecurity();
|
|
354
|
+
expect(screen.getByTestId("account-security-hub").textContent).toBe(
|
|
355
|
+
"Your sign-in is managed at hub.example.com.",
|
|
356
|
+
);
|
|
357
|
+
const links = screen.getAllByTestId("account-security-hub-link");
|
|
358
|
+
expect(links).toHaveLength(1);
|
|
359
|
+
expect(links[0]).toHaveAttribute("href", `${HUB}/account/security`);
|
|
360
|
+
expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
|
|
361
|
+
expect(screen.queryByTestId("account-security-password")).not.toBeInTheDocument();
|
|
362
|
+
expect(screen.queryByTestId("account-security-sentence")).not.toBeInTheDocument();
|
|
363
|
+
expect(screen.queryByText(/other devices/i)).toBeNull();
|
|
364
|
+
expect(mockReadOwnAccounts).not.toHaveBeenCalled();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("with the hub config absent says 'our main site' and links nowhere", async () => {
|
|
368
|
+
delete process.env.AILK_HUB_CLIENT_SECRET;
|
|
369
|
+
await renderSecurity();
|
|
370
|
+
expect(screen.getByTestId("account-security-hub").textContent).toBe(
|
|
371
|
+
"Your sign-in is managed by our main site.",
|
|
372
|
+
);
|
|
373
|
+
expect(screen.queryByTestId("account-security-hub-link")).not.toBeInTheDocument();
|
|
374
|
+
expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("Log out POSTs the spoke's sign-out endpoint, never Better Auth, and on a 204 lands on /account", async () => {
|
|
378
|
+
mockFetch.mockResolvedValue({ ok: true, status: 204 });
|
|
379
|
+
await renderSecurity();
|
|
380
|
+
await act(async () => {
|
|
381
|
+
fireEvent.click(screen.getByTestId("account-security-log-out"));
|
|
382
|
+
});
|
|
383
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
384
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/hub-session/sign-out", {
|
|
385
|
+
method: "POST",
|
|
386
|
+
credentials: "same-origin",
|
|
204
387
|
});
|
|
388
|
+
expect(mockSignOut).not.toHaveBeenCalled();
|
|
389
|
+
expect(mockPush).toHaveBeenCalledWith("/account");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("a refused sign-out (403) navigates nowhere and shows the error", async () => {
|
|
393
|
+
mockFetch.mockResolvedValue({ ok: false, status: 403 });
|
|
394
|
+
await renderSecurity();
|
|
395
|
+
await act(async () => {
|
|
396
|
+
fireEvent.click(screen.getByTestId("account-security-log-out"));
|
|
397
|
+
});
|
|
398
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
399
|
+
expect(mockSignOut).not.toHaveBeenCalled();
|
|
400
|
+
expect(screen.getByTestId("account-security-log-out-error")).toBeInTheDocument();
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe("#8217 AC-1 — Billing on a hub-mode spoke reads the seam's spoke billing (D10–D12)", () => {
|
|
405
|
+
const VISA = { brand: "visa", last4: "4242", expMonth: 4, expYear: 2030 };
|
|
406
|
+
const PORTAL_URL = "https://billing.stripe.com/p/session/test_fake";
|
|
407
|
+
|
|
408
|
+
/** A `SpokeBilling` stub: what the rendered action returns. */
|
|
409
|
+
function stub(read: unknown, portalUrl: string | null = PORTAL_URL) {
|
|
410
|
+
return { read: jest.fn().mockResolvedValue(read), portalUrl: jest.fn().mockResolvedValue(portalUrl) };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function renderBilling(): Promise<void> {
|
|
414
|
+
// The Billing route's own page, loaded with `import()` — see the AC-5 case above.
|
|
415
|
+
const { default: BillingPage } = await import("../billing/page");
|
|
416
|
+
render(await BillingPage({ params: Promise.resolve({ locale: "en" }) }));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
afterEach(() => {
|
|
420
|
+
// Every case: billing-source.ts reads neither the hub's subscription nor its billing ids.
|
|
421
|
+
expect(mockSubscriptionMe).not.toHaveBeenCalled();
|
|
422
|
+
expect(mockMemberBilling).not.toHaveBeenCalled();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("a subscription renders the plan, the card and Manage billing", async () => {
|
|
426
|
+
mockLoadSpokeBilling.mockResolvedValue(
|
|
427
|
+
stub({ kind: "subscription", status: "active", planName: "Pro", paymentMethod: VISA, unreadable: false }),
|
|
428
|
+
);
|
|
429
|
+
await renderBilling();
|
|
430
|
+
const card = screen.getByTestId("account-billing-card");
|
|
431
|
+
expect(card).toHaveTextContent("Pro");
|
|
432
|
+
expect(card).toHaveTextContent("Active");
|
|
433
|
+
expect(card).toHaveTextContent("visa ending in 4242, expires 04/2030");
|
|
434
|
+
expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage billing");
|
|
435
|
+
expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
|
|
436
|
+
expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("none renders the plain none line and no button", async () => {
|
|
440
|
+
mockLoadSpokeBilling.mockResolvedValue(stub({ kind: "none", unreadable: false }, null));
|
|
441
|
+
await renderBilling();
|
|
442
|
+
expect(screen.getByTestId("account-billing-none")).toHaveTextContent("You don't have a subscription.");
|
|
443
|
+
expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
|
|
444
|
+
expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it.each([
|
|
448
|
+
["none", { kind: "none", unreadable: true }, "account-billing-card"],
|
|
449
|
+
[
|
|
450
|
+
"a subscription",
|
|
451
|
+
{ kind: "subscription", status: "past_due", planName: null, paymentMethod: null, unreadable: true },
|
|
452
|
+
"account-billing-card",
|
|
453
|
+
],
|
|
454
|
+
])("unreadable: true on %s adds the unreadable line after it", async (_label, read, baseTestId) => {
|
|
455
|
+
mockLoadSpokeBilling.mockResolvedValue(stub(read));
|
|
456
|
+
await renderBilling();
|
|
457
|
+
const line = screen.getByTestId("account-billing-unreadable");
|
|
458
|
+
expect(line).toHaveTextContent("Some of your billing is with another Stripe account, so it isn't shown here.");
|
|
459
|
+
const base = screen.getByTestId(baseTestId);
|
|
460
|
+
expect(base.compareDocumentPosition(line) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("a failed read renders the error row, never 'no subscription'", async () => {
|
|
464
|
+
mockLoadSpokeBilling.mockResolvedValue(stub({ kind: "failed" }));
|
|
465
|
+
await renderBilling();
|
|
466
|
+
expect(screen.getByTestId("inbox-error")).toHaveTextContent("We couldn't load your billing details.");
|
|
467
|
+
expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
|
|
468
|
+
expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("a loader that fails to load renders the error row", async () => {
|
|
472
|
+
const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
473
|
+
mockLoadSpokeBilling.mockRejectedValue(new SyntaxError("checkout-config.ts is malformed"));
|
|
474
|
+
await renderBilling();
|
|
475
|
+
expect(screen.getByTestId("inbox-error")).toBeInTheDocument();
|
|
476
|
+
expect(errorSpy.mock.calls.map((args) => args.join(" "))).toEqual([
|
|
477
|
+
"[account] read failed source=spoke-billing errName=SyntaxError",
|
|
478
|
+
]);
|
|
479
|
+
errorSpy.mockRestore();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("a spoke without the action (the loader resolves null) renders none and no button", async () => {
|
|
483
|
+
await renderBilling();
|
|
484
|
+
expect(screen.getByTestId("account-billing-none")).toBeInTheDocument();
|
|
485
|
+
expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
|
|
486
|
+
expect(mockLoadSpokeBilling).toHaveBeenCalledTimes(1);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("openBillingPortal redirects to the loader's portal URL", async () => {
|
|
490
|
+
const billing = stub({ kind: "none", unreadable: false });
|
|
491
|
+
mockLoadSpokeBilling.mockResolvedValue(billing);
|
|
492
|
+
await openBillingPortal();
|
|
493
|
+
expect(billing.portalUrl).toHaveBeenCalledWith();
|
|
494
|
+
expect(mockRedirect).toHaveBeenCalledTimes(1);
|
|
495
|
+
expect(mockRedirect).toHaveBeenCalledWith(PORTAL_URL);
|
|
496
|
+
expect(mockPortalSession).not.toHaveBeenCalled();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it.each([
|
|
500
|
+
["the loader's portal is null", () => mockLoadSpokeBilling.mockResolvedValue(stub({ kind: "none", unreadable: false }, null))],
|
|
501
|
+
["there is no loader", () => mockLoadSpokeBilling.mockResolvedValue(null)],
|
|
502
|
+
["the loader fails to load", () => mockLoadSpokeBilling.mockRejectedValue(new Error("load failed"))],
|
|
503
|
+
])("openBillingPortal redirects nowhere when %s", async (_label, arrange) => {
|
|
504
|
+
const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
505
|
+
arrange();
|
|
506
|
+
await openBillingPortal();
|
|
507
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
508
|
+
expect(mockPortalSession).not.toHaveBeenCalled();
|
|
509
|
+
errorSpy.mockRestore();
|
|
205
510
|
});
|
|
206
511
|
});
|
|
@@ -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.");
|