create-ailk 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -7
- package/dist/add-module.js +7 -2
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +64 -5
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +40 -3
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +98 -3
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/topology.js +29 -1
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
- package/templates/apps/api/src/middleware/auth.ts +16 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
- package/templates/apps/api/src/server.ts +125 -18
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/lead-routing.ts +105 -12
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +121 -18
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +55 -0
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +114 -22
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for AgreementsPrompt (#8438 D13) and the carried-agreement cookie
|
|
3
|
+
* `acceptCarriedAgreementsAction` consumes — exercised here because this
|
|
4
|
+
* prompt is its one caller.
|
|
5
|
+
*/
|
|
6
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
7
|
+
|
|
8
|
+
const mockRefresh = jest.fn();
|
|
9
|
+
jest.mock("next/navigation", () => ({
|
|
10
|
+
__esModule: true,
|
|
11
|
+
useRouter: () => ({ refresh: mockRefresh }),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const mockAcceptCarried = jest.fn();
|
|
15
|
+
// eslint-disable-next-line no-restricted-syntax -- test-seam mock: the Server Action module is a "use server" boundary; the component only passes it through.
|
|
16
|
+
jest.mock("../agreements-actions", () => ({
|
|
17
|
+
__esModule: true,
|
|
18
|
+
acceptCarriedAgreementsAction: (...args: unknown[]) => mockAcceptCarried(...args),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const mockSetConsent = jest.fn();
|
|
22
|
+
// eslint-disable-next-line no-restricted-syntax -- test-seam mock: the Server Action module is a "use server" boundary; the component only passes it through.
|
|
23
|
+
jest.mock("../privacy/actions", () => ({
|
|
24
|
+
__esModule: true,
|
|
25
|
+
setConsentAction: (...args: unknown[]) => mockSetConsent(...args),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { AgreementsPrompt, type MissingAgreement } from "../agreements-prompt";
|
|
29
|
+
|
|
30
|
+
const LABELS = {
|
|
31
|
+
titleNew: "Please accept our terms",
|
|
32
|
+
titleUpdated: "We've updated our terms",
|
|
33
|
+
accept: "Accept",
|
|
34
|
+
error: "We couldn't save that. Try again.",
|
|
35
|
+
policyLink: "Read our privacy policy",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const AGREEMENT: MissingAgreement = {
|
|
39
|
+
id: "account_terms",
|
|
40
|
+
statement: "You agree to our Terms and Privacy Policy.",
|
|
41
|
+
policyHref: "/terms",
|
|
42
|
+
version: 1,
|
|
43
|
+
hadPriorGrant: false,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
mockRefresh.mockReset();
|
|
48
|
+
mockAcceptCarried.mockReset();
|
|
49
|
+
mockSetConsent.mockReset();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("AgreementsPrompt — with nothing missing", () => {
|
|
53
|
+
it("renders nothing and calls nothing", () => {
|
|
54
|
+
render(<AgreementsPrompt missing={[]} labels={LABELS} />);
|
|
55
|
+
expect(screen.queryByTestId("account-agreements-prompt")).not.toBeInTheDocument();
|
|
56
|
+
expect(mockAcceptCarried).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("AgreementsPrompt — the carried-cookie fast path (D13)", () => {
|
|
61
|
+
it("tries it once on mount, with every missing id", async () => {
|
|
62
|
+
mockAcceptCarried.mockResolvedValue({ accepted: false });
|
|
63
|
+
render(<AgreementsPrompt missing={[AGREEMENT]} labels={LABELS} />);
|
|
64
|
+
await waitFor(() => expect(mockAcceptCarried).toHaveBeenCalledWith(["account_terms"]));
|
|
65
|
+
expect(mockAcceptCarried).toHaveBeenCalledTimes(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("on accept, hides and refreshes — the card never shows", async () => {
|
|
69
|
+
mockAcceptCarried.mockResolvedValue({ accepted: true });
|
|
70
|
+
render(<AgreementsPrompt missing={[AGREEMENT]} labels={LABELS} />);
|
|
71
|
+
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
|
|
72
|
+
expect(screen.queryByTestId("account-agreements-prompt")).not.toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("on a failed check, shows the card with the statement and policy link", async () => {
|
|
76
|
+
mockAcceptCarried.mockResolvedValue({ accepted: false });
|
|
77
|
+
render(<AgreementsPrompt missing={[AGREEMENT]} labels={LABELS} />);
|
|
78
|
+
expect(await screen.findByTestId("account-agreements-prompt")).toBeInTheDocument();
|
|
79
|
+
expect(screen.getByText("Please accept our terms")).toBeInTheDocument();
|
|
80
|
+
expect(screen.getByText("You agree to our Terms and Privacy Policy.")).toBeInTheDocument();
|
|
81
|
+
expect(screen.getByTestId("account-agreements-prompt-account_terms-policy")).toHaveAttribute(
|
|
82
|
+
"href",
|
|
83
|
+
"/terms",
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("shows the 'updated' title when any missing agreement had a prior grant", async () => {
|
|
88
|
+
mockAcceptCarried.mockResolvedValue({ accepted: false });
|
|
89
|
+
render(<AgreementsPrompt missing={[{ ...AGREEMENT, hadPriorGrant: true }]} labels={LABELS} />);
|
|
90
|
+
expect(await screen.findByText("We've updated our terms")).toBeInTheDocument();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("a rejected check still shows the card, rather than throwing", async () => {
|
|
94
|
+
mockAcceptCarried.mockRejectedValue(new Error("down"));
|
|
95
|
+
render(<AgreementsPrompt missing={[AGREEMENT]} labels={LABELS} />);
|
|
96
|
+
expect(await screen.findByTestId("account-agreements-prompt")).toBeInTheDocument();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("AgreementsPrompt — the explicit Accept button", () => {
|
|
101
|
+
async function showCard(): Promise<void> {
|
|
102
|
+
mockAcceptCarried.mockResolvedValue({ accepted: false });
|
|
103
|
+
render(<AgreementsPrompt missing={[AGREEMENT]} labels={LABELS} />);
|
|
104
|
+
await screen.findByTestId("account-agreements-prompt");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it("posts every missing id through setConsentAction, then refreshes", async () => {
|
|
108
|
+
mockSetConsent.mockResolvedValue({ ok: true, dateLine: "Given Oct 12, 2026" });
|
|
109
|
+
await showCard();
|
|
110
|
+
fireEvent.click(screen.getByTestId("account-agreements-prompt-accept"));
|
|
111
|
+
await waitFor(() => expect(mockSetConsent).toHaveBeenCalledWith("account_terms", true));
|
|
112
|
+
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("a failed answer shows the error and keeps the card", async () => {
|
|
116
|
+
mockSetConsent.mockResolvedValue({ ok: false, message: "nope" });
|
|
117
|
+
await showCard();
|
|
118
|
+
fireEvent.click(screen.getByTestId("account-agreements-prompt-accept"));
|
|
119
|
+
expect(await screen.findByTestId("account-agreements-prompt-error")).toHaveTextContent(
|
|
120
|
+
"We couldn't save that. Try again.",
|
|
121
|
+
);
|
|
122
|
+
expect(screen.getByTestId("account-agreements-prompt")).toBeInTheDocument();
|
|
123
|
+
expect(mockRefresh).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── acceptCarriedAgreementsAction's own cookie checks (D13 C6) ────────────────
|
|
128
|
+
//
|
|
129
|
+
// The real action, loaded past the mock above — exercising the HMAC, the
|
|
130
|
+
// one-hour window and the email-hash binding with real `node:crypto`.
|
|
131
|
+
|
|
132
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426 D5), not a runtime input; the seam IS the boundary between a hub and a spoke build, and there is no env or HTTP surface that selects it.
|
|
133
|
+
jest.mock("../../../../../lib/topology.generated", () => ({
|
|
134
|
+
__esModule: true,
|
|
135
|
+
readSession: jest.fn(),
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
const mockMemberSetConsent = jest.fn();
|
|
139
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
140
|
+
__esModule: true,
|
|
141
|
+
member: { setConsent: (...args: unknown[]) => mockMemberSetConsent(...args) },
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
const mockCookieGet = jest.fn();
|
|
145
|
+
const mockCookieDelete = jest.fn();
|
|
146
|
+
jest.mock("next/headers", () => ({
|
|
147
|
+
__esModule: true,
|
|
148
|
+
cookies: jest.fn().mockResolvedValue({
|
|
149
|
+
get: (...args: unknown[]) => mockCookieGet(...args),
|
|
150
|
+
delete: (...args: unknown[]) => mockCookieDelete(...args),
|
|
151
|
+
toString: () => "",
|
|
152
|
+
}),
|
|
153
|
+
headers: jest.fn().mockResolvedValue(new Headers()),
|
|
154
|
+
}));
|
|
155
|
+
|
|
156
|
+
describe("acceptCarriedAgreementsAction (the real module, D13 C6)", () => {
|
|
157
|
+
const COOKIE_NAME = "ailk-signup-agreements"; // NODE_ENV !== "production" in jest
|
|
158
|
+
const EMAIL = "ada@example.com";
|
|
159
|
+
|
|
160
|
+
async function remember(email: string, purposeIds: string[]): Promise<string | undefined> {
|
|
161
|
+
const { rememberSignUpAgreementsAction } = jest.requireActual(
|
|
162
|
+
"../../../(auth)/sign-up/sign-up.actions",
|
|
163
|
+
) as typeof import("../../../(auth)/sign-up/sign-up.actions");
|
|
164
|
+
await rememberSignUpAgreementsAction(email, purposeIds);
|
|
165
|
+
expect(mockCookieSet).toHaveBeenCalledTimes(1);
|
|
166
|
+
return mockCookieSet.mock.calls[0]?.[1] as string | undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const mockCookieSet = jest.fn();
|
|
170
|
+
|
|
171
|
+
beforeEach(() => {
|
|
172
|
+
mockCookieGet.mockReset();
|
|
173
|
+
mockCookieDelete.mockReset();
|
|
174
|
+
mockCookieSet.mockReset();
|
|
175
|
+
mockMemberSetConsent.mockReset().mockResolvedValue({ ok: true, body: { purpose: {} } });
|
|
176
|
+
(jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockReset();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Minting goes through `cookies().set`, which the outer `cookies()` mock
|
|
180
|
+
// does not define — reassign it per test so `remember()` can capture the
|
|
181
|
+
// value, without disturbing the delete-tracking mock the "real action"
|
|
182
|
+
// suite shares.
|
|
183
|
+
beforeEach(() => {
|
|
184
|
+
const headersModule = jest.requireMock("next/headers") as {
|
|
185
|
+
cookies: jest.Mock;
|
|
186
|
+
};
|
|
187
|
+
headersModule.cookies.mockResolvedValue({
|
|
188
|
+
get: (...args: unknown[]) => mockCookieGet(...args),
|
|
189
|
+
set: (...args: unknown[]) => mockCookieSet(...args),
|
|
190
|
+
delete: (...args: unknown[]) => mockCookieDelete(...args),
|
|
191
|
+
toString: () => "",
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
async function accept(missingPurposeIds: string[]) {
|
|
196
|
+
const { acceptCarriedAgreementsAction } = jest.requireActual(
|
|
197
|
+
"../agreements-actions",
|
|
198
|
+
) as typeof import("../agreements-actions");
|
|
199
|
+
return acceptCarriedAgreementsAction(missingPurposeIds);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
it("a valid, fresh cookie whose email matches writes every missing purpose with via sign_up, then deletes the cookie", async () => {
|
|
203
|
+
const value = await remember(EMAIL, ["account_terms"]);
|
|
204
|
+
mockCookieGet.mockReturnValue({ value });
|
|
205
|
+
(jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockResolvedValue({
|
|
206
|
+
user: { email: EMAIL, emailVerified: true },
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const result = await accept(["account_terms"]);
|
|
210
|
+
expect(result).toEqual({ accepted: true });
|
|
211
|
+
expect(mockMemberSetConsent).toHaveBeenCalledWith(
|
|
212
|
+
{ purposeId: "account_terms", granted: true, via: "sign_up" },
|
|
213
|
+
expect.anything(),
|
|
214
|
+
);
|
|
215
|
+
expect(mockCookieDelete).toHaveBeenCalledWith(COOKIE_NAME);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("a flipped character in the cookie fails the HMAC — posts nothing, deletes it, not accepted", async () => {
|
|
219
|
+
const value = await remember(EMAIL, ["account_terms"]);
|
|
220
|
+
const tampered = `${value?.slice(0, -1)}${value?.slice(-1) === "0" ? "1" : "0"}`;
|
|
221
|
+
mockCookieGet.mockReturnValue({ value: tampered });
|
|
222
|
+
|
|
223
|
+
const result = await accept(["account_terms"]);
|
|
224
|
+
expect(result).toEqual({ accepted: false });
|
|
225
|
+
expect(mockMemberSetConsent).not.toHaveBeenCalled();
|
|
226
|
+
expect(mockCookieDelete).toHaveBeenCalledWith(COOKIE_NAME);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("another email's hash is refused, even with a verified session", async () => {
|
|
230
|
+
const value = await remember("someone-else@example.com", ["account_terms"]);
|
|
231
|
+
mockCookieGet.mockReturnValue({ value });
|
|
232
|
+
(jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockResolvedValue({
|
|
233
|
+
user: { email: EMAIL, emailVerified: true },
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const result = await accept(["account_terms"]);
|
|
237
|
+
expect(result).toEqual({ accepted: false });
|
|
238
|
+
expect(mockMemberSetConsent).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("an unverified session email is refused even when the hash would match", async () => {
|
|
242
|
+
const value = await remember(EMAIL, ["account_terms"]);
|
|
243
|
+
mockCookieGet.mockReturnValue({ value });
|
|
244
|
+
(jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockResolvedValue({
|
|
245
|
+
user: { email: EMAIL, emailVerified: false },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const result = await accept(["account_terms"]);
|
|
249
|
+
expect(result).toEqual({ accepted: false });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("a cookie that does not cover every missing id is refused", async () => {
|
|
253
|
+
const value = await remember(EMAIL, ["account_terms"]);
|
|
254
|
+
mockCookieGet.mockReturnValue({ value });
|
|
255
|
+
(jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockResolvedValue({
|
|
256
|
+
user: { email: EMAIL, emailVerified: true },
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const result = await accept(["account_terms", "another_agreement"]);
|
|
260
|
+
expect(result).toEqual({ accepted: false });
|
|
261
|
+
expect(mockMemberSetConsent).not.toHaveBeenCalled();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("an issuedAt more than an hour old is refused", async () => {
|
|
265
|
+
jest.useFakeTimers().setSystemTime(new Date("2026-10-12T00:00:00.000Z"));
|
|
266
|
+
const value = await remember(EMAIL, ["account_terms"]);
|
|
267
|
+
jest.setSystemTime(new Date("2026-10-12T01:00:01.000Z"));
|
|
268
|
+
mockCookieGet.mockReturnValue({ value });
|
|
269
|
+
|
|
270
|
+
const result = await accept(["account_terms"]);
|
|
271
|
+
expect(result).toEqual({ accepted: false });
|
|
272
|
+
jest.useRealTimers();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("no cookie present is refused, with no delete call", async () => {
|
|
276
|
+
mockCookieGet.mockReturnValue(undefined);
|
|
277
|
+
const result = await accept(["account_terms"]);
|
|
278
|
+
expect(result).toEqual({ accepted: false });
|
|
279
|
+
expect(mockCookieDelete).not.toHaveBeenCalled();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -131,8 +131,22 @@ describe("the frame (D7)", () => {
|
|
|
131
131
|
});
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
describe("the standalone shape (#8430)", () => {
|
|
135
|
+
it("takes the screen as the Admin's settings do: the rail column and the pane are the page's own landmarks", async () => {
|
|
136
|
+
await renderPage();
|
|
137
|
+
const settings = screen.getByTestId("account-settings");
|
|
138
|
+
expect(settings).toHaveClass("min-h-screen");
|
|
139
|
+
const column = within(settings).getByRole("complementary");
|
|
140
|
+
expect(column.firstElementChild).toBe(screen.getByTestId("account-settings-back"));
|
|
141
|
+
expect(column).toContainElement(screen.getByTestId("account-settings-rail"));
|
|
142
|
+
expect(within(settings).getByRole("main")).toContainElement(
|
|
143
|
+
screen.getByRole("heading", { level: 1 }),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
134
148
|
describe("AC-5 — Billing as the current-plan card (D8)", () => {
|
|
135
|
-
it("(a) shows the plan, the status, the card and Manage
|
|
149
|
+
it("(a) shows the plan, the status, the card and Manage billing under the Current plan heading", async () => {
|
|
136
150
|
await renderPage();
|
|
137
151
|
expect(screen.getByText("Current plan")).toBeInTheDocument();
|
|
138
152
|
const card = screen.getByTestId("account-billing-card");
|
|
@@ -141,7 +155,7 @@ describe("AC-5 — Billing as the current-plan card (D8)", () => {
|
|
|
141
155
|
expect(within(card).getByTestId("account-billing-payment")).toHaveTextContent(
|
|
142
156
|
"visa ending in 4242, expires 04/2027",
|
|
143
157
|
);
|
|
144
|
-
expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage
|
|
158
|
+
expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage billing");
|
|
145
159
|
expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
|
|
146
160
|
expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
|
|
147
161
|
});
|
|
@@ -262,7 +276,7 @@ describe("AC-2 (e) — contributed billing sections render after the base conten
|
|
|
262
276
|
});
|
|
263
277
|
});
|
|
264
278
|
|
|
265
|
-
describe("#8217 AC-1 — the single site's Manage
|
|
279
|
+
describe("#8217 AC-1 — the single site's Manage billing (D10, D11)", () => {
|
|
266
280
|
const PORTAL_URL = "https://billing.stripe.com/p/session/test_single_site";
|
|
267
281
|
|
|
268
282
|
it("mints the portal once through the hub's route with the cookie init, then redirects there", async () => {
|
|
@@ -71,6 +71,8 @@ jest.mock("../../../../../auth", () => ({
|
|
|
71
71
|
const mockPieces = jest.fn();
|
|
72
72
|
const mockUpdateProfile = jest.fn();
|
|
73
73
|
const mockRequestDeletion = jest.fn();
|
|
74
|
+
const mockContact = jest.fn();
|
|
75
|
+
const mockUpdateContact = jest.fn();
|
|
74
76
|
const mockHubMe = jest.fn();
|
|
75
77
|
const mockLeadsSubmit = jest.fn();
|
|
76
78
|
jest.mock("@working-theory/api-client", () => ({
|
|
@@ -83,6 +85,8 @@ jest.mock("@working-theory/api-client", () => ({
|
|
|
83
85
|
pieces: (...args: unknown[]) => mockPieces(...args),
|
|
84
86
|
updateProfile: (...args: unknown[]) => mockUpdateProfile(...args),
|
|
85
87
|
requestDeletion: (...args: unknown[]) => mockRequestDeletion(...args),
|
|
88
|
+
contact: (...args: unknown[]) => mockContact(...args),
|
|
89
|
+
updateContact: (...args: unknown[]) => mockUpdateContact(...args),
|
|
86
90
|
},
|
|
87
91
|
hubSignIn: { me: (...args: unknown[]) => mockHubMe(...args) },
|
|
88
92
|
visitorForwardHeaders: () => null,
|
|
@@ -120,8 +124,9 @@ import type { AccountSettingsSection } from "@working-theory/templates/account";
|
|
|
120
124
|
import { accountSettingsSections } from "../../../../../lib/account-config";
|
|
121
125
|
import { seal } from "../../../../../lib/hub-session/seal";
|
|
122
126
|
import { BillingAnchor } from "../general/billing-anchor";
|
|
127
|
+
import { saveContact } from "../general/contact-actions";
|
|
123
128
|
import Page from "../general/page";
|
|
124
|
-
import {
|
|
129
|
+
import { saveProfile } from "../profile-actions";
|
|
125
130
|
|
|
126
131
|
const PHOTO = "https://cdn.example.com/ada.png";
|
|
127
132
|
const SESSION = {
|
|
@@ -179,6 +184,8 @@ beforeEach(() => {
|
|
|
179
184
|
mockPieces.mockReset().mockResolvedValue(ok([]));
|
|
180
185
|
mockUpdateProfile.mockReset().mockResolvedValue(ok({ name: "Ada King", image: PHOTO }));
|
|
181
186
|
mockRequestDeletion.mockReset().mockResolvedValue(ok({ sentTo: "site-operator" }));
|
|
187
|
+
mockContact.mockReset().mockResolvedValue(ok({ contact: null }));
|
|
188
|
+
mockUpdateContact.mockReset().mockResolvedValue(ok({ contact: null }));
|
|
182
189
|
mockHubMe.mockReset();
|
|
183
190
|
mockLeadsSubmit.mockReset();
|
|
184
191
|
mockUpdateUser.mockReset().mockResolvedValue({ data: { status: true }, error: null });
|
|
@@ -204,7 +211,7 @@ async function clickSave(): Promise<void> {
|
|
|
204
211
|
// ── 1. The frame (AC-1, AC-4) ──────────────────────────────────────────────────
|
|
205
212
|
|
|
206
213
|
describe("AC-1 — the frame (D2, D7)", () => {
|
|
207
|
-
it("renders in the settings frame: Back to the Inbox, and the
|
|
214
|
+
it("renders in the settings frame: Back to the Inbox, and the five tabs with General current (#8438 D14 adds Privacy)", async () => {
|
|
208
215
|
await renderPage();
|
|
209
216
|
expect(screen.getByTestId("account-settings")).toContainElement(
|
|
210
217
|
screen.getByRole("heading", { level: 1, name: "General" }),
|
|
@@ -217,6 +224,7 @@ describe("AC-1 — the frame (D2, D7)", () => {
|
|
|
217
224
|
["General", "/account/general"],
|
|
218
225
|
["Security", "/account/security"],
|
|
219
226
|
["Notifications", "/account/notifications"],
|
|
227
|
+
["Privacy", "/account/privacy"],
|
|
220
228
|
["Billing", "/account/billing"],
|
|
221
229
|
]);
|
|
222
230
|
expect(links[0]).toHaveAttribute("aria-current", "page");
|
|
@@ -232,6 +240,7 @@ describe("AC-1 — the frame (D2, D7)", () => {
|
|
|
232
240
|
"href",
|
|
233
241
|
"/fr/account/notifications",
|
|
234
242
|
);
|
|
243
|
+
expect(screen.getByTestId("account-settings-rail-privacy")).toHaveAttribute("href", "/fr/account/privacy");
|
|
235
244
|
expect(screen.getByTestId("account-settings-rail-billing")).toHaveAttribute("href", "/fr/account/billing");
|
|
236
245
|
});
|
|
237
246
|
|
|
@@ -245,19 +254,19 @@ describe("AC-1 — the frame (D2, D7)", () => {
|
|
|
245
254
|
}
|
|
246
255
|
});
|
|
247
256
|
|
|
248
|
-
it("the pane holds the email read-only, the name and photo inputs
|
|
257
|
+
it("the pane holds the email read-only, the name and photo inputs", async () => {
|
|
249
258
|
await renderPage();
|
|
250
259
|
const pane = screen.getByTestId("account-settings-general");
|
|
251
260
|
expect(within(pane).getByTestId("account-general-email")).toHaveTextContent("ada@example.com");
|
|
252
261
|
expect(within(pane).queryByRole("textbox", { name: "Email" })).not.toBeInTheDocument();
|
|
253
262
|
expect(within(pane).getByLabelText("Name")).toHaveValue("Ada Lovelace");
|
|
254
263
|
expect(within(pane).getByLabelText("Photo")).toHaveValue(PHOTO);
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("renders no deletion block — Request account deletion moved to Privacy (#8438 D16)", async () => {
|
|
267
|
+
await renderPage();
|
|
268
|
+
expect(screen.queryByTestId("account-deletion")).not.toBeInTheDocument();
|
|
269
|
+
expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
|
|
261
270
|
});
|
|
262
271
|
|
|
263
272
|
it("the photo is never rendered as an image", async () => {
|
|
@@ -277,7 +286,7 @@ describe("AC-1 — the frame (D2, D7)", () => {
|
|
|
277
286
|
expect(within(control).getAllByRole("radio").map((r) => r.textContent)).toEqual(["Light", "Auto", "Dark"]);
|
|
278
287
|
});
|
|
279
288
|
|
|
280
|
-
it("a contributed settings section renders after the
|
|
289
|
+
it("a contributed settings section renders after the general form (C8)", async () => {
|
|
281
290
|
const section: AccountSettingsSection = {
|
|
282
291
|
id: "extra",
|
|
283
292
|
title: "settings.general.title",
|
|
@@ -286,9 +295,9 @@ describe("AC-1 — the frame (D2, D7)", () => {
|
|
|
286
295
|
accountSettingsSections.push(section);
|
|
287
296
|
try {
|
|
288
297
|
await renderPage();
|
|
289
|
-
const
|
|
298
|
+
const form = screen.getByTestId("account-general-form");
|
|
290
299
|
const extra = screen.getByTestId("account-settings-extra");
|
|
291
|
-
expect(
|
|
300
|
+
expect(form.compareDocumentPosition(extra) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
292
301
|
expect(screen.getByTestId("extra-body")).toBeInTheDocument();
|
|
293
302
|
} finally {
|
|
294
303
|
accountSettingsSections.splice(accountSettingsSections.indexOf(section), 1);
|
|
@@ -296,6 +305,20 @@ describe("AC-1 — the frame (D2, D7)", () => {
|
|
|
296
305
|
});
|
|
297
306
|
});
|
|
298
307
|
|
|
308
|
+
describe("the standalone shape (#8430)", () => {
|
|
309
|
+
it("takes the screen as the Admin's settings do: the rail column and the pane are the page's own landmarks", async () => {
|
|
310
|
+
await renderPage();
|
|
311
|
+
const settings = screen.getByTestId("account-settings");
|
|
312
|
+
expect(settings).toHaveClass("min-h-screen");
|
|
313
|
+
const column = within(settings).getByRole("complementary");
|
|
314
|
+
expect(column.firstElementChild).toBe(screen.getByTestId("account-settings-back"));
|
|
315
|
+
expect(column).toContainElement(screen.getByTestId("account-settings-rail"));
|
|
316
|
+
expect(within(settings).getByRole("main")).toContainElement(
|
|
317
|
+
screen.getByRole("heading", { level: 1 }),
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
299
322
|
// ── 2. The profile form on the member path (AC-3) ─────────────────────────────
|
|
300
323
|
|
|
301
324
|
describe("AC-3 — name and photo through the member API (D4, D8)", () => {
|
|
@@ -366,7 +389,6 @@ describe("AC-3 — name and photo through the member API (D4, D8)", () => {
|
|
|
366
389
|
mockPieces.mockResolvedValue(refusal(503, "tenant_unresolved"));
|
|
367
390
|
await renderPage();
|
|
368
391
|
expect(screen.getByLabelText("Photo")).toBeInTheDocument();
|
|
369
|
-
expect(screen.getByTestId("account-deletion-request")).toBeInTheDocument();
|
|
370
392
|
});
|
|
371
393
|
});
|
|
372
394
|
|
|
@@ -473,67 +495,146 @@ describe("AC-3 — a single site with no member API (a 404 from the pieces read)
|
|
|
473
495
|
);
|
|
474
496
|
expect(screen.getByLabelText("Name")).toHaveValue("Ada King");
|
|
475
497
|
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// ── #8435 D13 (AC-6) — the contact details on this site ───────────────────────
|
|
501
|
+
|
|
502
|
+
describe("AC-6 — the contact details (#8435 D13)", () => {
|
|
503
|
+
const CONTACT = {
|
|
504
|
+
givenName: "Ada",
|
|
505
|
+
familyName: "Lovelace",
|
|
506
|
+
telephone: "+442071234567",
|
|
507
|
+
address: {
|
|
508
|
+
addressLine1: "1 Analytical Way",
|
|
509
|
+
addressLine2: null,
|
|
510
|
+
city: "London",
|
|
511
|
+
region: "LDN",
|
|
512
|
+
postalCode: "N1 1AA",
|
|
513
|
+
country: "GB",
|
|
514
|
+
},
|
|
515
|
+
companyName: "Engines Ltd",
|
|
516
|
+
jobTitle: "Countess",
|
|
517
|
+
};
|
|
476
518
|
|
|
477
|
-
it("
|
|
519
|
+
it("with the member API, renders the contact section seeded from member.contact", async () => {
|
|
520
|
+
mockContact.mockResolvedValue(ok({ contact: CONTACT }));
|
|
478
521
|
await renderPage();
|
|
479
|
-
|
|
480
|
-
expect(
|
|
481
|
-
expect(
|
|
522
|
+
const section = screen.getByTestId("account-contact-section");
|
|
523
|
+
expect(mockContact).toHaveBeenCalledWith(COOKIE_INIT);
|
|
524
|
+
expect(within(section).getByTestId("contact-given-name")).toHaveValue("Ada");
|
|
525
|
+
expect(within(section).getByTestId("contact-company")).toHaveValue("Engines Ltd");
|
|
526
|
+
expect(within(section).getByTestId("field-addressLine1")).toHaveValue("1 Analytical Way");
|
|
527
|
+
// The profile form's single "Save changes" button stays unique.
|
|
528
|
+
expect(screen.getAllByRole("button", { name: "Save changes" })).toHaveLength(1);
|
|
529
|
+
expect(within(section).getByTestId("contact-details-save")).toHaveTextContent("Save contact details");
|
|
482
530
|
});
|
|
483
|
-
});
|
|
484
531
|
|
|
485
|
-
|
|
532
|
+
it("with no confirmed contact yet, the form starts blank", async () => {
|
|
533
|
+
await renderPage();
|
|
534
|
+
expect(screen.getByTestId("contact-given-name")).toHaveValue("");
|
|
535
|
+
});
|
|
486
536
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
await
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
537
|
+
it("a failed read renders the section's unavailable line, not the form", async () => {
|
|
538
|
+
mockContact.mockResolvedValue(refusal(503, "tenant_unresolved"));
|
|
539
|
+
await renderPage();
|
|
540
|
+
expect(screen.queryByTestId("contact-details-form")).not.toBeInTheDocument();
|
|
541
|
+
expect(screen.getByTestId("account-contact-unavailable")).toHaveTextContent(
|
|
542
|
+
"Contact details aren't available on this site right now.",
|
|
543
|
+
);
|
|
544
|
+
});
|
|
493
545
|
|
|
494
|
-
it
|
|
495
|
-
|
|
496
|
-
["hub-operator", "Sent to the hub operator."],
|
|
497
|
-
])("an answer naming %s shows %s — the answer picks the line", async (sentTo, line) => {
|
|
498
|
-
mockRequestDeletion.mockResolvedValue(ok({ sentTo }));
|
|
546
|
+
it("a read that throws is the same unavailable line", async () => {
|
|
547
|
+
mockContact.mockRejectedValue(new Error("down"));
|
|
499
548
|
await renderPage();
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
549
|
+
expect(screen.getByTestId("account-contact-unavailable")).toBeInTheDocument();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("with no member API (a 404 from the pieces read) the section does not render", async () => {
|
|
553
|
+
mockPieces.mockResolvedValue(refusal(404, "not_found"));
|
|
554
|
+
await renderPage();
|
|
555
|
+
expect(screen.queryByTestId("account-contact-section")).not.toBeInTheDocument();
|
|
556
|
+
expect(mockContact).not.toHaveBeenCalled();
|
|
505
557
|
});
|
|
506
558
|
|
|
507
|
-
it("
|
|
508
|
-
|
|
559
|
+
it("saving posts all six keys through saveContact and answers Saved", async () => {
|
|
560
|
+
mockContact.mockResolvedValue(ok({ contact: CONTACT }));
|
|
509
561
|
await renderPage();
|
|
510
|
-
|
|
511
|
-
await
|
|
512
|
-
|
|
562
|
+
fireEvent.change(screen.getByTestId("contact-company"), { target: { value: "Acme" } });
|
|
563
|
+
await act(async () => {
|
|
564
|
+
fireEvent.submit(screen.getByTestId("contact-details-form"));
|
|
565
|
+
});
|
|
566
|
+
await waitFor(() => expect(mockUpdateContact).toHaveBeenCalledTimes(1));
|
|
567
|
+
expect(mockUpdateContact).toHaveBeenCalledWith(
|
|
568
|
+
{
|
|
569
|
+
givenName: "Ada",
|
|
570
|
+
familyName: "Lovelace",
|
|
571
|
+
telephone: "+442071234567",
|
|
572
|
+
address: CONTACT.address,
|
|
573
|
+
companyName: "Acme",
|
|
574
|
+
jobTitle: "Countess",
|
|
575
|
+
},
|
|
576
|
+
COOKIE_INIT,
|
|
513
577
|
);
|
|
514
|
-
expect(screen.
|
|
578
|
+
expect(await screen.findByTestId("contact-details-message")).toHaveTextContent("Saved.");
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("a refused telephone shows its own line and keeps the fields as typed", async () => {
|
|
582
|
+
mockUpdateContact.mockResolvedValue(refusal(400, "telephone_invalid"));
|
|
583
|
+
await renderPage();
|
|
584
|
+
fireEvent.change(screen.getByTestId("contact-company"), { target: { value: "Acme" } });
|
|
585
|
+
await act(async () => {
|
|
586
|
+
fireEvent.submit(screen.getByTestId("contact-details-form"));
|
|
587
|
+
});
|
|
588
|
+
expect(await screen.findByTestId("contact-details-message")).toHaveTextContent(
|
|
589
|
+
"Enter the phone number with its country code.",
|
|
590
|
+
);
|
|
591
|
+
expect(screen.getByTestId("contact-company")).toHaveValue("Acme");
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
describe("AC-6 — saveContact (#8435 D13)", () => {
|
|
596
|
+
const BODY = {
|
|
597
|
+
givenName: "Ada",
|
|
598
|
+
familyName: null,
|
|
599
|
+
telephone: null,
|
|
600
|
+
address: null,
|
|
601
|
+
companyName: null,
|
|
602
|
+
jobTitle: null,
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
it("calls member.updateContact with forwardedCookieInit()'s init", async () => {
|
|
606
|
+
mockHeaders.mockResolvedValue(new Headers({ origin: "https://site.example.com" }));
|
|
607
|
+
await expect(saveContact(BODY)).resolves.toEqual({ ok: true });
|
|
608
|
+
expect(mockUpdateContact).toHaveBeenCalledWith(BODY, {
|
|
609
|
+
headers: { cookie: "session=abc", origin: "https://site.example.com" },
|
|
610
|
+
});
|
|
515
611
|
});
|
|
516
612
|
|
|
517
613
|
it.each([
|
|
614
|
+
[refusal(400, "telephone_invalid"), "telephone"],
|
|
615
|
+
[refusal(400, "invalid_input"), "invalid"],
|
|
518
616
|
[refusal(403, "email_unverified"), "unverified"],
|
|
519
617
|
[refusal(404, "not_found"), "unavailable"],
|
|
520
|
-
[refusal(
|
|
521
|
-
[refusal(503, "
|
|
522
|
-
[refusal(500, "
|
|
523
|
-
])("
|
|
524
|
-
|
|
525
|
-
await expect(
|
|
526
|
-
expect(mockRequestDeletion).toHaveBeenCalledWith({}, COOKIE_INIT);
|
|
618
|
+
[refusal(409, "contact_unavailable"), "unavailable"],
|
|
619
|
+
[refusal(503, "tenant_unresolved"), "unavailable"],
|
|
620
|
+
[refusal(500, "internal_error"), "failed"],
|
|
621
|
+
])("maps %j to %s", async (res, reason) => {
|
|
622
|
+
mockUpdateContact.mockResolvedValue(res);
|
|
623
|
+
await expect(saveContact(BODY)).resolves.toEqual({ ok: false, reason });
|
|
527
624
|
});
|
|
528
625
|
|
|
529
|
-
it("
|
|
530
|
-
|
|
531
|
-
await expect(
|
|
532
|
-
|
|
533
|
-
await expect(
|
|
626
|
+
it("a throw is failed, and a non-object body makes no call", async () => {
|
|
627
|
+
mockUpdateContact.mockRejectedValue(new Error("down"));
|
|
628
|
+
await expect(saveContact(BODY)).resolves.toEqual({ ok: false, reason: "failed" });
|
|
629
|
+
mockUpdateContact.mockClear();
|
|
630
|
+
await expect(saveContact(null as never)).resolves.toEqual({ ok: false, reason: "invalid" });
|
|
631
|
+
expect(mockUpdateContact).not.toHaveBeenCalled();
|
|
534
632
|
});
|
|
535
633
|
});
|
|
536
634
|
|
|
635
|
+
// The deletion-request cases moved to privacy.test.tsx — Request account
|
|
636
|
+
// deletion now renders on Privacy, not General (#8438 D16).
|
|
637
|
+
|
|
537
638
|
// ── 5. #billing (AC-6) ────────────────────────────────────────────────────────
|
|
538
639
|
|
|
539
640
|
describe("AC-6 — BillingAnchor forwards #billing to Billing (D8)", () => {
|