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
|
@@ -19,7 +19,14 @@
|
|
|
19
19
|
* https; no deliverer is shown;
|
|
20
20
|
* - the review form shows on the latest version only, only while the
|
|
21
21
|
* artifact is not approved and that version has no review; after a
|
|
22
|
-
* Request changes the page says the next version will appear there
|
|
22
|
+
* Request changes the page says the next version will appear there;
|
|
23
|
+
* - a proposal (#8516, spec
|
|
24
|
+
* docs/specs/8516-proposals-as-an-artifact-kind-spec.md D19, AC-3, AC-4)
|
|
25
|
+
* reads "Proposal" and its price, answers "Your decision on version <n>"
|
|
26
|
+
* with Approve proposal / Request changes, and in the latest version's
|
|
27
|
+
* place shows Pay now while payment is due, "Paid <date>" once paid, and
|
|
28
|
+
* the pending line on `?paid=1` before the webhook lands — while a
|
|
29
|
+
* deliverable renders exactly as before.
|
|
23
30
|
*/
|
|
24
31
|
|
|
25
32
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
@@ -205,9 +212,15 @@ async function renderList(): Promise<void> {
|
|
|
205
212
|
render(await ListPage({ params: Promise.resolve({ locale: "en" }) }));
|
|
206
213
|
}
|
|
207
214
|
|
|
208
|
-
async function renderArtifact(
|
|
215
|
+
async function renderArtifact(
|
|
216
|
+
pieceId: string,
|
|
217
|
+
searchParams?: Record<string, string>,
|
|
218
|
+
): Promise<void> {
|
|
209
219
|
render(
|
|
210
|
-
await ArtifactPage({
|
|
220
|
+
await ArtifactPage({
|
|
221
|
+
params: Promise.resolve({ locale: "en", pieceId }),
|
|
222
|
+
...(searchParams ? { searchParams: Promise.resolve(searchParams) } : {}),
|
|
223
|
+
}),
|
|
211
224
|
);
|
|
212
225
|
}
|
|
213
226
|
|
|
@@ -512,3 +525,163 @@ describe("the review form's place (D4, C3, AC-4)", () => {
|
|
|
512
525
|
);
|
|
513
526
|
});
|
|
514
527
|
});
|
|
528
|
+
|
|
529
|
+
// #8516 D19 — a priced proposal, unreviewed, as apps/api serves it.
|
|
530
|
+
const PROPOSAL_ID = "piece_01920000-0000-7000-8000-00000000000d";
|
|
531
|
+
const PROPOSAL = {
|
|
532
|
+
id: PROPOSAL_ID,
|
|
533
|
+
title: "Site redesign",
|
|
534
|
+
approvedAt: null,
|
|
535
|
+
kind: "proposal",
|
|
536
|
+
price: { amount: 120000, currency: "usd" },
|
|
537
|
+
payment: null,
|
|
538
|
+
versions: [version({ version: 1, deliveredAt: "2026-10-01T12:00:00.000Z" })],
|
|
539
|
+
};
|
|
540
|
+
const APPROVED_AT = "2026-10-05T09:00:00.000Z";
|
|
541
|
+
const PAID_AT = "2026-10-06T09:30:00.000Z";
|
|
542
|
+
|
|
543
|
+
function approvedProposal(payment: unknown) {
|
|
544
|
+
const [latest] = PROPOSAL.versions;
|
|
545
|
+
return {
|
|
546
|
+
...PROPOSAL,
|
|
547
|
+
approvedAt: APPROVED_AT,
|
|
548
|
+
payment,
|
|
549
|
+
versions: [{ ...latest!, review: "approved", reviewedAt: APPROVED_AT }],
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// An older hub serves no `kind`, `price` or `payment` (#8516 D4: optional).
|
|
554
|
+
const ALPHA_WITHOUT_KIND = {
|
|
555
|
+
id: ID.mango,
|
|
556
|
+
title: "Mango brief",
|
|
557
|
+
approvedAt: null,
|
|
558
|
+
versions: [version({ version: 1 })],
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const PAY_IDS = ["artifact-pay-form", "artifact-pay", "artifact-paid-pending"];
|
|
562
|
+
|
|
563
|
+
describe("a proposal (#8516 D19, AC-3, AC-4)", () => {
|
|
564
|
+
it("renders Proposal, its price, the decision heading and Approve proposal / Request changes", async () => {
|
|
565
|
+
mockArtifacts.mockResolvedValue(ok([PROPOSAL]));
|
|
566
|
+
await renderArtifact(PROPOSAL_ID);
|
|
567
|
+
const page = screen.getByTestId("artifact");
|
|
568
|
+
expect(within(page).getByText("Proposal")).toBeInTheDocument();
|
|
569
|
+
expect(within(page).queryByText("Artifact")).toBeNull();
|
|
570
|
+
expect(screen.getByTestId("artifact-price")).toHaveTextContent(
|
|
571
|
+
"Price $1,200.00",
|
|
572
|
+
);
|
|
573
|
+
const v1 = screen.getByTestId("artifact-version-1");
|
|
574
|
+
expect(within(v1).getByRole("heading", { level: 3 })).toHaveTextContent(
|
|
575
|
+
"Your decision on version 1",
|
|
576
|
+
);
|
|
577
|
+
expect(within(v1).getByTestId("artifact-review-approve")).toHaveTextContent(
|
|
578
|
+
"Approve proposal",
|
|
579
|
+
);
|
|
580
|
+
expect(
|
|
581
|
+
within(v1).getByTestId("artifact-review-request-changes"),
|
|
582
|
+
).toHaveTextContent("Request changes");
|
|
583
|
+
for (const id of PAY_IDS) expect(screen.queryByTestId(id)).toBeNull();
|
|
584
|
+
expect(screen.queryByTestId("artifact-paid")).toBeNull();
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("a currency Intl cannot format falls back to the amount and code instead of erroring the page", async () => {
|
|
588
|
+
mockArtifacts.mockResolvedValue(
|
|
589
|
+
ok([{ ...PROPOSAL, price: { amount: 120000, currency: "u$" } }]),
|
|
590
|
+
);
|
|
591
|
+
await renderArtifact(PROPOSAL_ID);
|
|
592
|
+
expect(screen.getByTestId("artifact-price")).toHaveTextContent("Price 1200 U$");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("a deliverable still renders Artifact, no price and Approve", async () => {
|
|
596
|
+
mockArtifacts.mockResolvedValue(
|
|
597
|
+
ok([{ ...VERSIONED, kind: "deliverable", price: null, payment: null }]),
|
|
598
|
+
);
|
|
599
|
+
await renderArtifact(ID.alpha);
|
|
600
|
+
const page = screen.getByTestId("artifact");
|
|
601
|
+
expect(within(page).getByText("Artifact")).toBeInTheDocument();
|
|
602
|
+
expect(within(page).queryByText("Proposal")).toBeNull();
|
|
603
|
+
expect(screen.queryByTestId("artifact-price")).toBeNull();
|
|
604
|
+
expect(page.textContent).not.toMatch(/Price/);
|
|
605
|
+
const v3 = screen.getByTestId("artifact-version-3");
|
|
606
|
+
expect(within(v3).getByRole("heading", { level: 3 })).toHaveTextContent(
|
|
607
|
+
"Review version 3",
|
|
608
|
+
);
|
|
609
|
+
expect(within(v3).getByTestId("artifact-review-approve")).toHaveTextContent(
|
|
610
|
+
/^Approve$/,
|
|
611
|
+
);
|
|
612
|
+
for (const id of PAY_IDS) expect(screen.queryByTestId(id)).toBeNull();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("an older hub's artifact with no kind renders as a deliverable", async () => {
|
|
616
|
+
mockArtifacts.mockResolvedValue(ok([ALPHA_WITHOUT_KIND]));
|
|
617
|
+
await renderArtifact(ID.mango);
|
|
618
|
+
expect(
|
|
619
|
+
within(screen.getByTestId("artifact")).getByText("Artifact"),
|
|
620
|
+
).toBeInTheDocument();
|
|
621
|
+
expect(screen.queryByTestId("artifact-price")).toBeNull();
|
|
622
|
+
expect(screen.getByTestId("artifact-review-approve")).toHaveTextContent(
|
|
623
|
+
/^Approve$/,
|
|
624
|
+
);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it("while payment is due, Pay now sits in the latest version's place and the review form is gone", async () => {
|
|
628
|
+
mockArtifacts.mockResolvedValue(ok([approvedProposal({ status: "due" })]));
|
|
629
|
+
await renderArtifact(PROPOSAL_ID);
|
|
630
|
+
const v1 = screen.getByTestId("artifact-version-1");
|
|
631
|
+
expect(within(v1).getByTestId("artifact-pay")).toHaveTextContent("Pay now");
|
|
632
|
+
expect(screen.getAllByTestId("artifact-pay")).toHaveLength(1);
|
|
633
|
+
expect(screen.queryByTestId("artifact-review-approve")).toBeNull();
|
|
634
|
+
expect(screen.queryByTestId("artifact-paid")).toBeNull();
|
|
635
|
+
expect(screen.queryByTestId("artifact-paid-pending")).toBeNull();
|
|
636
|
+
expect(screen.getByTestId("artifact-status")).toHaveTextContent("Approved");
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("once paid, the latest version says Paid <date> and offers no Pay now", async () => {
|
|
640
|
+
mockArtifacts.mockResolvedValue(
|
|
641
|
+
ok([approvedProposal({ status: "paid", paidAt: PAID_AT })]),
|
|
642
|
+
);
|
|
643
|
+
await renderArtifact(PROPOSAL_ID, { paid: "1" });
|
|
644
|
+
const v1 = screen.getByTestId("artifact-version-1");
|
|
645
|
+
expect(within(v1).getByTestId("artifact-paid")).toHaveTextContent(
|
|
646
|
+
`Paid ${medium(PAID_AT)}`,
|
|
647
|
+
);
|
|
648
|
+
for (const id of PAY_IDS) expect(screen.queryByTestId(id)).toBeNull();
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("back from Stripe (?paid=1) before the webhook lands, it says the payment was received instead of Pay now", async () => {
|
|
652
|
+
mockArtifacts.mockResolvedValue(ok([approvedProposal({ status: "due" })]));
|
|
653
|
+
await renderArtifact(PROPOSAL_ID, { paid: "1" });
|
|
654
|
+
const v1 = screen.getByTestId("artifact-version-1");
|
|
655
|
+
expect(within(v1).getByTestId("artifact-paid-pending")).toHaveTextContent(
|
|
656
|
+
"Payment received — it may take a minute to show here.",
|
|
657
|
+
);
|
|
658
|
+
expect(screen.queryByTestId("artifact-pay")).toBeNull();
|
|
659
|
+
expect(screen.queryByTestId("artifact-paid")).toBeNull();
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("an approved proposal with no payment surface (payment null) shows neither Pay now nor Paid", async () => {
|
|
663
|
+
mockArtifacts.mockResolvedValue(ok([approvedProposal(null)]));
|
|
664
|
+
await renderArtifact(PROPOSAL_ID, { paid: "1" });
|
|
665
|
+
for (const id of PAY_IDS) expect(screen.queryByTestId(id)).toBeNull();
|
|
666
|
+
expect(screen.queryByTestId("artifact-paid")).toBeNull();
|
|
667
|
+
expect(screen.getByTestId("artifact-price")).toHaveTextContent(
|
|
668
|
+
"Price $1,200.00",
|
|
669
|
+
);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("the list reads Proposal · Version <n> in a proposal's caption and Latest cell, and a deliverable's as before", async () => {
|
|
673
|
+
mockArtifacts.mockResolvedValue(ok([PROPOSAL, ALPHA]));
|
|
674
|
+
await renderList();
|
|
675
|
+
const captions = screen.getAllByTestId("artifacts-row");
|
|
676
|
+
expect(captions.map((c) => c.textContent)).toEqual([
|
|
677
|
+
expect.stringContaining("Proposal · Version 1 · Ready for review"),
|
|
678
|
+
expect.stringContaining("Version 1 · Ready for review"),
|
|
679
|
+
]);
|
|
680
|
+
expect(captions[1]!.textContent).not.toContain("Proposal");
|
|
681
|
+
const row = screen.getByTestId(`artifacts-table-row-${PROPOSAL_ID}`);
|
|
682
|
+
expect(row).toHaveTextContent("Proposal · Version 1");
|
|
683
|
+
expect(
|
|
684
|
+
screen.getByTestId(`artifacts-table-row-${ID.alpha}`),
|
|
685
|
+
).not.toHaveTextContent("Proposal");
|
|
686
|
+
});
|
|
687
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pay now on an approved proposal (#8516, spec
|
|
3
|
+
* docs/specs/8516-proposals-as-an-artifact-kind-spec.md D19, AC-4, AC-7).
|
|
4
|
+
*
|
|
5
|
+
* Two layers:
|
|
6
|
+
* - `payForProposal` (the Server Action), with the api-client's
|
|
7
|
+
* `member.payArtifact` as the one mocked boundary: it asks once with the
|
|
8
|
+
* caller's init and redirects only to a Stripe Checkout URL; a refusal, a
|
|
9
|
+
* throw or a URL on any other host is `payment_failed` with no redirect,
|
|
10
|
+
* and a log line carries `err.name` only — never the URL or the id.
|
|
11
|
+
* - `PayForm` (the client form): one button and no input of any kind — no
|
|
12
|
+
* card, number or expiry field; payment happens on Stripe's page.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
act,
|
|
17
|
+
fireEvent,
|
|
18
|
+
render,
|
|
19
|
+
screen,
|
|
20
|
+
waitFor,
|
|
21
|
+
} from "@testing-library/react";
|
|
22
|
+
|
|
23
|
+
const mockPayArtifact = jest.fn();
|
|
24
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
25
|
+
__esModule: true,
|
|
26
|
+
member: {
|
|
27
|
+
reviewArtifact: jest.fn(),
|
|
28
|
+
payArtifact: (...args: unknown[]) => mockPayArtifact(...args),
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
jest.mock("next/headers", () => ({
|
|
33
|
+
__esModule: true,
|
|
34
|
+
headers: jest
|
|
35
|
+
.fn()
|
|
36
|
+
.mockResolvedValue(new Headers({ origin: "https://site.example.com" })),
|
|
37
|
+
cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
// Next's `redirect()` throws NEXT_REDIRECT; the mock does too, so a catch
|
|
41
|
+
// that swallowed it would show up as a resolved action.
|
|
42
|
+
const mockRedirect = jest.fn((url: string): never => {
|
|
43
|
+
throw new Error(`NEXT_REDIRECT ${url}`);
|
|
44
|
+
});
|
|
45
|
+
jest.mock("next/navigation", () => ({
|
|
46
|
+
__esModule: true,
|
|
47
|
+
useRouter: () => ({ push: jest.fn(), refresh: jest.fn() }),
|
|
48
|
+
redirect: (url: string) => mockRedirect(url),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
import messages from "../../../../../../messages/en.json";
|
|
52
|
+
import { PayForm, type PayFormLabels } from "../[pieceId]/pay-form";
|
|
53
|
+
import * as actions from "../artifact-actions";
|
|
54
|
+
import { payForProposal } from "../artifact-actions";
|
|
55
|
+
|
|
56
|
+
const PIECE = "piece_01920000-0000-7000-8000-00000000000d";
|
|
57
|
+
const INIT = {
|
|
58
|
+
headers: { cookie: "session=abc", origin: "https://site.example.com" },
|
|
59
|
+
};
|
|
60
|
+
const CHECKOUT = "https://checkout.stripe.com/c/pay/cs_test_x";
|
|
61
|
+
const p = messages.account.artifacts.proposal;
|
|
62
|
+
const LABELS: PayFormLabels = {
|
|
63
|
+
pay: p.pay,
|
|
64
|
+
paying: p.paying,
|
|
65
|
+
error: p.payError,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function pay(pieceId = PIECE) {
|
|
69
|
+
const data = new FormData();
|
|
70
|
+
data.set("pieceId", pieceId);
|
|
71
|
+
return payForProposal({ kind: "idle" }, data);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function refusal(status: number, code: string) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
status,
|
|
78
|
+
body: { ok: false, error: { code, message: "x" } },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let errorSpy: jest.SpyInstance;
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
mockPayArtifact
|
|
86
|
+
.mockReset()
|
|
87
|
+
.mockResolvedValue({ ok: true, body: { url: CHECKOUT } });
|
|
88
|
+
mockRedirect.mockClear();
|
|
89
|
+
errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
errorSpy.mockRestore();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("payForProposal (D19, AC-4, AC-7)", () => {
|
|
97
|
+
it("calls member.payArtifact once with the caller's init and redirects to the Checkout URL", async () => {
|
|
98
|
+
await expect(pay()).rejects.toThrow("NEXT_REDIRECT");
|
|
99
|
+
expect(mockPayArtifact).toHaveBeenCalledTimes(1);
|
|
100
|
+
expect(mockPayArtifact).toHaveBeenCalledWith(PIECE, INIT);
|
|
101
|
+
expect(mockRedirect).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(mockRedirect).toHaveBeenCalledWith(CHECKOUT);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it.each([
|
|
106
|
+
["a 409 proposal_paid refusal", refusal(409, "proposal_paid")],
|
|
107
|
+
["a 404 (no payment surface)", refusal(404, "not_found")],
|
|
108
|
+
["a 502 payment_unavailable", refusal(502, "payment_unavailable")],
|
|
109
|
+
[
|
|
110
|
+
"a URL on another host",
|
|
111
|
+
{ ok: true, body: { url: "https://evil.example/x" } },
|
|
112
|
+
],
|
|
113
|
+
[
|
|
114
|
+
"a look-alike Stripe host",
|
|
115
|
+
{ ok: true, body: { url: "https://checkout.stripe.com.evil.example/x" } },
|
|
116
|
+
],
|
|
117
|
+
[
|
|
118
|
+
"a URL with userinfo",
|
|
119
|
+
{ ok: true, body: { url: "https://user@checkout.stripe.com/x" } },
|
|
120
|
+
],
|
|
121
|
+
["a body with no URL", { ok: true, body: {} }],
|
|
122
|
+
])("%s is payment_failed with no redirect", async (_case, res) => {
|
|
123
|
+
mockPayArtifact.mockResolvedValue(res);
|
|
124
|
+
await expect(pay()).resolves.toEqual({
|
|
125
|
+
kind: "error",
|
|
126
|
+
reason: "payment_failed",
|
|
127
|
+
});
|
|
128
|
+
expect(mockPayArtifact).toHaveBeenCalledTimes(1);
|
|
129
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("a malformed piece id is payment_failed with no call", async () => {
|
|
133
|
+
await expect(pay("x")).resolves.toEqual({
|
|
134
|
+
kind: "error",
|
|
135
|
+
reason: "payment_failed",
|
|
136
|
+
});
|
|
137
|
+
expect(mockPayArtifact).not.toHaveBeenCalled();
|
|
138
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("a throw is payment_failed, logged by err.name only — never the URL or the piece id", async () => {
|
|
142
|
+
mockPayArtifact.mockRejectedValue(
|
|
143
|
+
new TypeError(`fetch failed ${CHECKOUT} ${PIECE}`),
|
|
144
|
+
);
|
|
145
|
+
await expect(pay()).resolves.toEqual({
|
|
146
|
+
kind: "error",
|
|
147
|
+
reason: "payment_failed",
|
|
148
|
+
});
|
|
149
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
150
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
151
|
+
"[account] payment failed errName=TypeError",
|
|
152
|
+
);
|
|
153
|
+
const logged = JSON.stringify(errorSpy.mock.calls);
|
|
154
|
+
expect(logged).not.toContain(CHECKOUT);
|
|
155
|
+
expect(logged).not.toContain(PIECE);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("PayForm (D19, AC-7)", () => {
|
|
160
|
+
it("renders one primary Pay now button with a 44px target and no input of any kind", () => {
|
|
161
|
+
const { container } = render(<PayForm pieceId={PIECE} labels={LABELS} />);
|
|
162
|
+
expect(screen.getAllByRole("button")).toHaveLength(1);
|
|
163
|
+
const button = screen.getByTestId("artifact-pay");
|
|
164
|
+
expect(button).toHaveTextContent("Pay now");
|
|
165
|
+
expect(button).toHaveClass("min-h-(--control-height-big)");
|
|
166
|
+
expect(container.querySelectorAll("input, select, textarea")).toHaveLength(
|
|
167
|
+
0,
|
|
168
|
+
);
|
|
169
|
+
expect(screen.queryByRole("textbox")).toBeNull();
|
|
170
|
+
expect(screen.queryByRole("spinbutton")).toBeNull();
|
|
171
|
+
expect(container.textContent).not.toMatch(/card|expiry|cvc/i);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("Pay now sends the piece to payForProposal; a failure shows the error line", async () => {
|
|
175
|
+
const paySpy = jest.spyOn(actions, "payForProposal");
|
|
176
|
+
mockPayArtifact.mockResolvedValue(refusal(502, "payment_unavailable"));
|
|
177
|
+
render(<PayForm pieceId={PIECE} labels={LABELS} />);
|
|
178
|
+
await act(async () => {
|
|
179
|
+
fireEvent.click(screen.getByTestId("artifact-pay"));
|
|
180
|
+
});
|
|
181
|
+
expect(paySpy).toHaveBeenCalledTimes(1);
|
|
182
|
+
expect((paySpy.mock.calls[0]![1] as FormData).get("pieceId")).toBe(PIECE);
|
|
183
|
+
expect(mockPayArtifact).toHaveBeenCalledTimes(1);
|
|
184
|
+
expect(mockPayArtifact).toHaveBeenCalledWith(PIECE, INIT);
|
|
185
|
+
await waitFor(() =>
|
|
186
|
+
expect(screen.getByTestId("artifact-pay-error")).toHaveTextContent(
|
|
187
|
+
p.payError,
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
191
|
+
paySpy.mockRestore();
|
|
192
|
+
});
|
|
193
|
+
});
|
package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* the `FormData` each button sends: the verdict and the comment, a
|
|
11
11
|
* refusal that keeps the typed comment, and a sent review that refreshes
|
|
12
12
|
* the page once.
|
|
13
|
+
* - a proposal (#8516, spec
|
|
14
|
+
* docs/specs/8516-proposals-as-an-artifact-kind-spec.md D19, AC-3, AC-4):
|
|
15
|
+
* Approve proposal posts exactly what Approve posts; an approval whose
|
|
16
|
+
* answered artifact owes a payment opens it through `member.payArtifact`
|
|
17
|
+
* and redirects only to a Stripe Checkout URL — any other outcome is
|
|
18
|
+
* `payment_failed`, with no redirect, and the form then offers Pay now.
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import {
|
|
@@ -21,10 +27,12 @@ import {
|
|
|
21
27
|
} from "@testing-library/react";
|
|
22
28
|
|
|
23
29
|
const mockReviewArtifact = jest.fn();
|
|
30
|
+
const mockPayArtifact = jest.fn();
|
|
24
31
|
jest.mock("@working-theory/api-client", () => ({
|
|
25
32
|
__esModule: true,
|
|
26
33
|
member: {
|
|
27
34
|
reviewArtifact: (...args: unknown[]) => mockReviewArtifact(...args),
|
|
35
|
+
payArtifact: (...args: unknown[]) => mockPayArtifact(...args),
|
|
28
36
|
},
|
|
29
37
|
}));
|
|
30
38
|
|
|
@@ -37,12 +45,19 @@ jest.mock("next/headers", () => ({
|
|
|
37
45
|
}));
|
|
38
46
|
|
|
39
47
|
const mockRefresh = jest.fn();
|
|
48
|
+
// Next's `redirect()` throws NEXT_REDIRECT; the mock does too, so a catch
|
|
49
|
+
// that swallowed it would show up as a resolved action.
|
|
50
|
+
const mockRedirect = jest.fn((url: string): never => {
|
|
51
|
+
throw new Error(`NEXT_REDIRECT ${url}`);
|
|
52
|
+
});
|
|
40
53
|
jest.mock("next/navigation", () => ({
|
|
41
54
|
__esModule: true,
|
|
42
55
|
useRouter: () => ({ push: jest.fn(), refresh: mockRefresh }),
|
|
56
|
+
redirect: (url: string) => mockRedirect(url),
|
|
43
57
|
}));
|
|
44
58
|
|
|
45
59
|
import messages from "../../../../../../messages/en.json";
|
|
60
|
+
import { type PayFormLabels } from "../[pieceId]/pay-form";
|
|
46
61
|
import { ReviewForm, type ReviewFormLabels } from "../[pieceId]/review-form";
|
|
47
62
|
import * as actions from "../artifact-actions";
|
|
48
63
|
import { submitArtifactReview } from "../artifact-actions";
|
|
@@ -52,6 +67,7 @@ const INIT = {
|
|
|
52
67
|
headers: { cookie: "session=abc", origin: "https://site.example.com" },
|
|
53
68
|
};
|
|
54
69
|
const COMMENT = "Tighten the logo";
|
|
70
|
+
const CHECKOUT = "https://checkout.stripe.com/c/pay/cs_test_x";
|
|
55
71
|
|
|
56
72
|
function form(fields: Record<string, string>): FormData {
|
|
57
73
|
const data = new FormData();
|
|
@@ -88,6 +104,11 @@ beforeEach(() => {
|
|
|
88
104
|
mockReviewArtifact
|
|
89
105
|
.mockReset()
|
|
90
106
|
.mockResolvedValue({ ok: true, body: { artifact: {} } });
|
|
107
|
+
mockPayArtifact.mockReset().mockResolvedValue({
|
|
108
|
+
ok: true,
|
|
109
|
+
body: { url: CHECKOUT },
|
|
110
|
+
});
|
|
111
|
+
mockRedirect.mockClear();
|
|
91
112
|
mockRefresh.mockReset();
|
|
92
113
|
errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
|
|
93
114
|
});
|
|
@@ -198,7 +219,116 @@ describe("submitArtifactReview (D6, AC-5)", () => {
|
|
|
198
219
|
});
|
|
199
220
|
});
|
|
200
221
|
|
|
222
|
+
/** The review's answer: the artifact as reviewed, with its payment state. */
|
|
223
|
+
function answered(payment: unknown) {
|
|
224
|
+
return {
|
|
225
|
+
ok: true,
|
|
226
|
+
body: {
|
|
227
|
+
artifact: {
|
|
228
|
+
id: PIECE,
|
|
229
|
+
title: "Site redesign",
|
|
230
|
+
approvedAt: "2026-10-05T09:00:00.000Z",
|
|
231
|
+
kind: "proposal",
|
|
232
|
+
price: { amount: 120000, currency: "usd" },
|
|
233
|
+
payment,
|
|
234
|
+
versions: [],
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
describe("submitArtifactReview on a proposal (#8516 D19, AC-3, AC-4)", () => {
|
|
241
|
+
it("Approve proposal posts { version, review: approved } once through member.reviewArtifact, exactly as Approve does", async () => {
|
|
242
|
+
mockReviewArtifact.mockResolvedValue(answered(null));
|
|
243
|
+
await expect(review({})).resolves.toEqual({ kind: "reviewed" });
|
|
244
|
+
expect(mockReviewArtifact).toHaveBeenCalledTimes(1);
|
|
245
|
+
expect(mockReviewArtifact).toHaveBeenCalledWith(
|
|
246
|
+
PIECE,
|
|
247
|
+
{ version: 3, review: "approved" },
|
|
248
|
+
INIT,
|
|
249
|
+
);
|
|
250
|
+
expect(mockPayArtifact).not.toHaveBeenCalled();
|
|
251
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("an approval whose answered artifact is due calls member.payArtifact once and redirects to the Checkout URL", async () => {
|
|
255
|
+
mockReviewArtifact.mockResolvedValue(answered({ status: "due" }));
|
|
256
|
+
await expect(review({})).rejects.toThrow("NEXT_REDIRECT");
|
|
257
|
+
expect(mockReviewArtifact).toHaveBeenCalledTimes(1);
|
|
258
|
+
expect(mockPayArtifact).toHaveBeenCalledTimes(1);
|
|
259
|
+
expect(mockPayArtifact).toHaveBeenCalledWith(PIECE, INIT);
|
|
260
|
+
expect(mockRedirect).toHaveBeenCalledTimes(1);
|
|
261
|
+
expect(mockRedirect).toHaveBeenCalledWith(CHECKOUT);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it.each([
|
|
265
|
+
["a payArtifact refusal", refusal(409, "proposal_not_approved")],
|
|
266
|
+
["a 404 (no payment surface)", refusal(404, "not_found")],
|
|
267
|
+
[
|
|
268
|
+
"a URL on another host",
|
|
269
|
+
{ ok: true, body: { url: "https://evil.example/x" } },
|
|
270
|
+
],
|
|
271
|
+
[
|
|
272
|
+
"a look-alike Stripe host",
|
|
273
|
+
{ ok: true, body: { url: "https://checkout.stripe.com.evil.example/x" } },
|
|
274
|
+
],
|
|
275
|
+
[
|
|
276
|
+
"a plain-http Checkout URL",
|
|
277
|
+
{ ok: true, body: { url: "http://checkout.stripe.com/x" } },
|
|
278
|
+
],
|
|
279
|
+
])(
|
|
280
|
+
"%s after a due approval is payment_failed with no redirect",
|
|
281
|
+
async (_case, res) => {
|
|
282
|
+
mockReviewArtifact.mockResolvedValue(answered({ status: "due" }));
|
|
283
|
+
mockPayArtifact.mockResolvedValue(res);
|
|
284
|
+
await expect(review({})).resolves.toEqual({
|
|
285
|
+
kind: "error",
|
|
286
|
+
reason: "payment_failed",
|
|
287
|
+
});
|
|
288
|
+
expect(mockReviewArtifact).toHaveBeenCalledTimes(1);
|
|
289
|
+
expect(mockPayArtifact).toHaveBeenCalledTimes(1);
|
|
290
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
it("a payArtifact throw is payment_failed, logged by err.name only — never the URL", async () => {
|
|
295
|
+
mockReviewArtifact.mockResolvedValue(answered({ status: "due" }));
|
|
296
|
+
mockPayArtifact.mockRejectedValue(
|
|
297
|
+
new TypeError(`fetch failed ${CHECKOUT}`),
|
|
298
|
+
);
|
|
299
|
+
await expect(review({})).resolves.toEqual({
|
|
300
|
+
kind: "error",
|
|
301
|
+
reason: "payment_failed",
|
|
302
|
+
});
|
|
303
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
304
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
305
|
+
"[account] payment failed errName=TypeError",
|
|
306
|
+
);
|
|
307
|
+
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(CHECKOUT);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it.each([
|
|
311
|
+
["paid", { status: "paid", paidAt: "2026-10-06T09:30:00.000Z" }],
|
|
312
|
+
["no payment surface", null],
|
|
313
|
+
])("an approval answered %s opens no payment", async (_case, payment) => {
|
|
314
|
+
mockReviewArtifact.mockResolvedValue(answered(payment));
|
|
315
|
+
await expect(review({})).resolves.toEqual({ kind: "reviewed" });
|
|
316
|
+
expect(mockPayArtifact).not.toHaveBeenCalled();
|
|
317
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("a changes-requested review never opens a payment", async () => {
|
|
321
|
+
mockReviewArtifact.mockResolvedValue(answered({ status: "due" }));
|
|
322
|
+
await expect(
|
|
323
|
+
review({ review: "changes-requested", comment: COMMENT }),
|
|
324
|
+
).resolves.toEqual({ kind: "reviewed" });
|
|
325
|
+
expect(mockPayArtifact).not.toHaveBeenCalled();
|
|
326
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
201
330
|
const r = messages.account.artifacts.review;
|
|
331
|
+
const p = messages.account.artifacts.proposal;
|
|
202
332
|
const LABELS: ReviewFormLabels = {
|
|
203
333
|
heading: r.heading.replace("{version}", "3"),
|
|
204
334
|
comment: r.comment,
|
|
@@ -213,8 +343,14 @@ const LABELS: ReviewFormLabels = {
|
|
|
213
343
|
not_latest: r.errors.notLatest,
|
|
214
344
|
rate_limited: r.errors.rateLimited,
|
|
215
345
|
failed: r.errors.failed,
|
|
346
|
+
payment_failed: p.payError,
|
|
216
347
|
},
|
|
217
348
|
};
|
|
349
|
+
const PAY_LABELS: PayFormLabels = {
|
|
350
|
+
pay: p.pay,
|
|
351
|
+
paying: p.paying,
|
|
352
|
+
error: p.payError,
|
|
353
|
+
};
|
|
218
354
|
|
|
219
355
|
describe("ReviewForm (D7, AC-6)", () => {
|
|
220
356
|
// A pass-through spy: the real action runs; the spy reads what it was sent.
|
|
@@ -286,3 +422,55 @@ describe("ReviewForm (D7, AC-6)", () => {
|
|
|
286
422
|
);
|
|
287
423
|
});
|
|
288
424
|
});
|
|
425
|
+
|
|
426
|
+
describe("ReviewForm on a proposal (#8516 D19, AC-3)", () => {
|
|
427
|
+
const PROPOSAL_LABELS: ReviewFormLabels = {
|
|
428
|
+
...LABELS,
|
|
429
|
+
heading: p.heading.replace("{version}", "1"),
|
|
430
|
+
approve: p.approve,
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
it("labels the decision by prop: Your decision on version <n>, Approve proposal, Request changes", () => {
|
|
434
|
+
render(
|
|
435
|
+
<ReviewForm
|
|
436
|
+
pieceId={PIECE}
|
|
437
|
+
version={1}
|
|
438
|
+
labels={PROPOSAL_LABELS}
|
|
439
|
+
pay={PAY_LABELS}
|
|
440
|
+
/>,
|
|
441
|
+
);
|
|
442
|
+
expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent(
|
|
443
|
+
"Your decision on version 1",
|
|
444
|
+
);
|
|
445
|
+
expect(screen.getByTestId("artifact-review-approve")).toHaveTextContent(
|
|
446
|
+
"Approve proposal",
|
|
447
|
+
);
|
|
448
|
+
expect(
|
|
449
|
+
screen.getByTestId("artifact-review-request-changes"),
|
|
450
|
+
).toHaveTextContent("Request changes");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("an approval whose payment could not open shows the error and offers Pay now in the form's place", async () => {
|
|
454
|
+
mockReviewArtifact.mockResolvedValue(answered({ status: "due" }));
|
|
455
|
+
mockPayArtifact.mockResolvedValue(refusal(502, "payment_unavailable"));
|
|
456
|
+
render(
|
|
457
|
+
<ReviewForm
|
|
458
|
+
pieceId={PIECE}
|
|
459
|
+
version={3}
|
|
460
|
+
labels={PROPOSAL_LABELS}
|
|
461
|
+
pay={PAY_LABELS}
|
|
462
|
+
/>,
|
|
463
|
+
);
|
|
464
|
+
await act(async () => {
|
|
465
|
+
fireEvent.click(screen.getByTestId("artifact-review-approve"));
|
|
466
|
+
});
|
|
467
|
+
await waitFor(() =>
|
|
468
|
+
expect(screen.getByTestId("artifact-review-error")).toHaveTextContent(
|
|
469
|
+
p.payError,
|
|
470
|
+
),
|
|
471
|
+
);
|
|
472
|
+
expect(screen.getByTestId("artifact-pay")).toHaveTextContent("Pay now");
|
|
473
|
+
expect(screen.queryByTestId("artifact-review-approve")).toBeNull();
|
|
474
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
475
|
+
});
|
|
476
|
+
});
|