@tribe-nest/forge 3.26.0 → 3.31.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/package.json +6 -3
- package/src/_tests/publishedResolvability.spec.ts +184 -0
- package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
- package/src/_tests/workspaceAliases.ts +40 -0
- package/src/client/_tests/localeHeader.spec.tsx +98 -0
- package/src/client/activeLocale.ts +34 -0
- package/src/client/createForgeClient.ts +13 -0
- package/src/contexts/CartContext.tsx +76 -0
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- package/src/data/queries/useWebsite.ts +7 -0
- package/src/i18n/CONVENTION.md +184 -0
- package/src/i18n/_tests/translationKeys.spec.ts +153 -0
- package/src/i18n/de.json +1019 -0
- package/src/i18n/en.json +1019 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +21 -0
- package/src/provider/ForgeAppProvider.tsx +2 -1
- package/src/provider/SiteConfigProvider.tsx +11 -0
- package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
- package/src/ui/format/_tests/pwyw.spec.ts +65 -8
- package/src/ui/format/membershipPwyw.ts +164 -0
- package/src/ui/format/pwyw.ts +37 -0
- package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
- package/src/ui/headless/broadcast/broadcastState.ts +158 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
- package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
- package/src/ui/headless/checkout/useCartResume.ts +340 -0
- package/src/ui/headless/checkout/useCheckout.ts +9 -2
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +32 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +38 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +542 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
- package/src/ui/media/_tests/callState.spec.ts +452 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +194 -0
- package/src/ui/media/callState.ts +341 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +289 -96
- package/src/ui/styled/AiAgentWidget.tsx +10 -6
- package/src/ui/styled/AudioPlayer.tsx +14 -6
- package/src/ui/styled/BlogCategory.tsx +4 -2
- package/src/ui/styled/BlogComments.tsx +27 -20
- package/src/ui/styled/BlogList.tsx +5 -3
- package/src/ui/styled/BlogPost.tsx +14 -11
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/BundleConfirmation.tsx +32 -23
- package/src/ui/styled/CancellationTerms.tsx +3 -1
- package/src/ui/styled/Cart.tsx +32 -14
- package/src/ui/styled/ChatRoom.tsx +65 -20
- package/src/ui/styled/Checkout.tsx +151 -89
- package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
- package/src/ui/styled/CoachingBooking.tsx +71 -44
- package/src/ui/styled/CoachingConfirmation.tsx +56 -19
- package/src/ui/styled/CoachingDetail.tsx +15 -11
- package/src/ui/styled/CohortPage.tsx +10 -8
- package/src/ui/styled/ConfirmSubscription.tsx +14 -8
- package/src/ui/styled/ContactForm.tsx +31 -12
- package/src/ui/styled/CookieConsent.tsx +19 -15
- package/src/ui/styled/CourseAccess.tsx +54 -47
- package/src/ui/styled/CourseCheckout.tsx +56 -38
- package/src/ui/styled/CourseConfirmation.tsx +39 -17
- package/src/ui/styled/CourseDetail.tsx +21 -15
- package/src/ui/styled/CoursesGrid.tsx +4 -2
- package/src/ui/styled/CurrencySwitcher.tsx +4 -1
- package/src/ui/styled/DiscountCode.tsx +22 -18
- package/src/ui/styled/DonationButton.tsx +52 -17
- package/src/ui/styled/DonationPage.tsx +4 -4
- package/src/ui/styled/EmailListForm.tsx +22 -9
- package/src/ui/styled/EventConfirmation.tsx +37 -14
- package/src/ui/styled/EventCountdown.tsx +10 -6
- package/src/ui/styled/EventDetail.tsx +8 -6
- package/src/ui/styled/EventSeriesDetail.tsx +18 -13
- package/src/ui/styled/EventTickets.tsx +232 -108
- package/src/ui/styled/EventWaitlist.tsx +42 -33
- package/src/ui/styled/EventsList.tsx +15 -13
- package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
- package/src/ui/styled/FormRenderer.tsx +12 -7
- package/src/ui/styled/InstallBanner.tsx +21 -18
- package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
- package/src/ui/styled/InvoicePayment.tsx +37 -16
- package/src/ui/styled/LeadMagnet.tsx +9 -7
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +32 -14
- package/src/ui/styled/MembershipCheckout.tsx +341 -49
- package/src/ui/styled/MembershipTiers.tsx +34 -18
- package/src/ui/styled/OfferButton.tsx +33 -13
- package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
- package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
- package/src/ui/styled/PodcastEpisode.tsx +20 -9
- package/src/ui/styled/PodcastList.tsx +9 -3
- package/src/ui/styled/PodcastShow.tsx +29 -7
- package/src/ui/styled/PostsFeed.tsx +10 -8
- package/src/ui/styled/PresaleCode.tsx +8 -11
- package/src/ui/styled/ProductBrowseNav.tsx +21 -11
- package/src/ui/styled/ProductDetail.tsx +58 -28
- package/src/ui/styled/ReactionBar.tsx +4 -2
- package/src/ui/styled/ReplayList.tsx +24 -13
- package/src/ui/styled/ResetPasswordForm.tsx +32 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +34 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
- package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
- package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
- package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
- package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
- package/src/ui/styled/community/CommunityComposer.tsx +182 -3
- package/src/ui/styled/community/CommunityFeed.tsx +36 -51
- package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
4
|
+
import { BroadcastPassValidation } from "../broadcast/BroadcastPassValidation";
|
|
5
|
+
import { EndedBroadcast } from "../broadcast/EndedBroadcast";
|
|
6
|
+
import type { ILiveBroadcast } from "../../../types/models";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The paywall in front of a live broadcast, and the screen that replaces it once
|
|
10
|
+
* the stream is over.
|
|
11
|
+
*
|
|
12
|
+
* Rendered through `react-dom/server`, which needs no DOM. Effects never fire
|
|
13
|
+
* under `renderToStaticMarkup`, so the props are the only data source and
|
|
14
|
+
* nothing reaches the network. Both components are presentational by design
|
|
15
|
+
* (their data hooks live one level up in `BroadcastWatch`), which is exactly
|
|
16
|
+
* what makes this possible.
|
|
17
|
+
*
|
|
18
|
+
* These are the REAL components a Forge code site renders, and the client app's
|
|
19
|
+
* copies are twins driven by the same `broadcastState` rules, so what is
|
|
20
|
+
* asserted here about WHAT IS SAID holds on both rendering stacks.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
|
|
24
|
+
|
|
25
|
+
function broadcast(overrides: Partial<ILiveBroadcast> = {}): ILiveBroadcast {
|
|
26
|
+
return {
|
|
27
|
+
id: "b1",
|
|
28
|
+
title: "Sunday Session",
|
|
29
|
+
createdAt: "2026-08-01T00:00:00.000Z",
|
|
30
|
+
updatedAt: "2026-08-01T00:00:00.000Z",
|
|
31
|
+
profileId: "p1",
|
|
32
|
+
streamTemplateId: "st1",
|
|
33
|
+
egressId: "e1",
|
|
34
|
+
events: [],
|
|
35
|
+
event: { id: "ev1", title: "Sunday Session", description: "", isPaid: true },
|
|
36
|
+
liveUrl: "https://cdn.example.com/secret-playlist.m3u8",
|
|
37
|
+
thumbnailUrl: "https://cdn.example.com/thumb.jpg",
|
|
38
|
+
...overrides,
|
|
39
|
+
} as ILiveBroadcast;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const render = (node: React.ReactNode) =>
|
|
43
|
+
renderToStaticMarkup(<ForgeThemeProvider theme={theme}>{node}</ForgeThemeProvider>);
|
|
44
|
+
|
|
45
|
+
describe("BroadcastPassValidation", () => {
|
|
46
|
+
const gate = (props: Partial<React.ComponentProps<typeof BroadcastPassValidation>> = {}) =>
|
|
47
|
+
render(<BroadcastPassValidation broadcast={broadcast()} onValidate={() => {}} {...props} />);
|
|
48
|
+
|
|
49
|
+
it("REGRESSION: leaks NO playback url while the viewer is still outside the paywall", () => {
|
|
50
|
+
// The whole point of the gate. A page that renders the ticket box and the
|
|
51
|
+
// stream url in the same document has not gated anything: the url is two
|
|
52
|
+
// keystrokes away in the page source.
|
|
53
|
+
const html = gate();
|
|
54
|
+
expect(html).not.toContain("secret-playlist.m3u8");
|
|
55
|
+
expect(html).not.toContain("<video");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("asks for the ticket id, and says what one looks like", () => {
|
|
59
|
+
const html = gate();
|
|
60
|
+
expect(html).toContain("Enter your ticket ID to join the broadcast (TN-XXXXXXX)");
|
|
61
|
+
expect(html).toContain('data-testid="broadcast-ticket-code"');
|
|
62
|
+
expect(html).toContain("Join Broadcast");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("still shows the title and thumbnail, which are already public on the list", () => {
|
|
66
|
+
const html = gate();
|
|
67
|
+
expect(html).toContain("Sunday Session");
|
|
68
|
+
expect(html).toContain("thumb.jpg");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("REGRESSION: keeps Join disabled until something is typed", () => {
|
|
72
|
+
// An enabled button on an empty box sends a blank code, which the server
|
|
73
|
+
// refuses, and the viewer reads the refusal as "my ticket is not valid".
|
|
74
|
+
expect(gate()).toContain("disabled");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("says the code is in flight rather than leaving the button looking dead", () => {
|
|
78
|
+
const html = gate({ isValidating: true });
|
|
79
|
+
expect(html).toContain("Validating");
|
|
80
|
+
expect(html).toContain("disabled");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("shows a refusal to the viewer, and names no other event", () => {
|
|
84
|
+
const html = gate({ error: "That ticket is not valid for this broadcast." });
|
|
85
|
+
expect(html).toContain("That ticket is not valid for this broadcast.");
|
|
86
|
+
expect(html).toContain('role="alert"');
|
|
87
|
+
expect(html).toContain('aria-invalid="true"');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("offers a way to BUY when the caller supplies one, so the gate is not a dead end", () => {
|
|
91
|
+
const html = gate({ buyTickets: <div data-testid="buy">Buy tickets</div> });
|
|
92
|
+
expect(html).toContain("OR");
|
|
93
|
+
expect(html).toContain('data-testid="buy"');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("shows no OR divider when there is nothing to buy", () => {
|
|
97
|
+
expect(gate()).not.toContain(">OR<");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("EndedBroadcast", () => {
|
|
102
|
+
const html = () => render(<EndedBroadcast broadcast={broadcast({ endedAt: "2026-08-18T11:00:00.000Z" })} />);
|
|
103
|
+
|
|
104
|
+
it("says the broadcast ended, over the thumbnail the viewer recognises", () => {
|
|
105
|
+
expect(html()).toContain("Broadcast Ended");
|
|
106
|
+
expect(html()).toContain("Sunday Session");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("REGRESSION: keeps the two INSTRUCTIONS visible, not behind the ? ", () => {
|
|
110
|
+
// A host on a bad line drops and comes back. A viewer who is told nothing
|
|
111
|
+
// assumes the show is over and leaves, so what to DO stays on the page.
|
|
112
|
+
const markup = html();
|
|
113
|
+
expect(markup).toContain("keep refreshing this page");
|
|
114
|
+
expect(markup).toContain("Close the broadcast page");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("puts the EXPLANATION behind the ?, per the house design law", () => {
|
|
118
|
+
const markup = html();
|
|
119
|
+
// The disclosure exists, named after what it explains rather than "help".
|
|
120
|
+
expect(markup).toContain('aria-label="Why did the broadcast end?"');
|
|
121
|
+
expect(markup).toContain('aria-expanded="false"');
|
|
122
|
+
// ...and its body is not on the page until it is opened.
|
|
123
|
+
expect(markup).not.toContain("unstable internet connection");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/react";
|
|
4
|
+
import axios, { type AxiosAdapter, type InternalAxiosRequestConfig } from "axios";
|
|
5
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
6
|
+
import { ForgeClientProvider } from "../../../provider/ForgeProvider";
|
|
7
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
8
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
9
|
+
import { Currency, type MembershipTier, type PublicAuthUser } from "../../../types/models";
|
|
10
|
+
import { MembershipCheckout } from "../MembershipCheckout";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Membership signup, end to end, over the REAL query/mutation layer.
|
|
14
|
+
*
|
|
15
|
+
* The two things asserted hardest are the two that cost money:
|
|
16
|
+
*
|
|
17
|
+
* 1. **What is SENT.** Every request is captured off the axios adapter, so
|
|
18
|
+
* `amount` is checked as a number on the wire rather than as a number in a
|
|
19
|
+
* component's state. A pay-what-you-want box that renders "25" and posts 0
|
|
20
|
+
* passes any assertion made on the screen alone, and that is exactly the
|
|
21
|
+
* defect this file exists for.
|
|
22
|
+
* 2. **Which currency that number is in.** The fan's chosen display currency
|
|
23
|
+
* converts the totals they READ and must never touch the figure they TYPE:
|
|
24
|
+
* the tier rows and the API are both in the tenant's settlement currency.
|
|
25
|
+
*
|
|
26
|
+
* jsdom, because the amount box is seeded by an effect and the whole flow is
|
|
27
|
+
* clicks. The adapter is swapped rather than the modules mocked, so
|
|
28
|
+
* `useCreateSubscription`, `useGetMembershipTiers`, `PublicAuthProvider` and the
|
|
29
|
+
* hook all stay in the path: a rename of an endpoint or a payload field fails
|
|
30
|
+
* here.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const PROFILE_ID = "profile-1";
|
|
34
|
+
|
|
35
|
+
const tier = (overrides: Partial<MembershipTier> = {}): MembershipTier => ({
|
|
36
|
+
id: "tier-1",
|
|
37
|
+
name: "Inner Circle",
|
|
38
|
+
description: "The good stuff",
|
|
39
|
+
payWhatYouWant: false,
|
|
40
|
+
benefits: [],
|
|
41
|
+
...overrides,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const member: PublicAuthUser = {
|
|
45
|
+
id: "account-1",
|
|
46
|
+
email: "fan@example.com",
|
|
47
|
+
firstName: "Fan",
|
|
48
|
+
lastName: "Person",
|
|
49
|
+
kind: "fan",
|
|
50
|
+
status: "active",
|
|
51
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
52
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Every request the component made, in order. */
|
|
56
|
+
let sent: { method: string; url: string; body: Record<string, unknown> | null }[] = [];
|
|
57
|
+
let realAdapter: AxiosAdapter | undefined;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The tenant settles in USD; this visitor reads prices in NGN at 1500 to the
|
|
61
|
+
* dollar. Any figure that has been through the rate is instantly recognisable.
|
|
62
|
+
*/
|
|
63
|
+
const currencies = {
|
|
64
|
+
supportedCurrencies: [Currency.USD, Currency.NGN],
|
|
65
|
+
userCurrency: Currency.NGN,
|
|
66
|
+
exchangeRates: { [Currency.USD]: { [Currency.NGN]: 1500 } },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function installAdapter(tiers: MembershipTier[]) {
|
|
70
|
+
sent = [];
|
|
71
|
+
const adapter: AxiosAdapter = async (config: InternalAxiosRequestConfig) => {
|
|
72
|
+
const url = config.url ?? "";
|
|
73
|
+
const body = config.data ? (JSON.parse(config.data as string) as Record<string, unknown>) : null;
|
|
74
|
+
sent.push({ method: (config.method ?? "get").toLowerCase(), url, body });
|
|
75
|
+
|
|
76
|
+
const reply = (data: unknown) =>
|
|
77
|
+
Promise.resolve({ data, status: 200, statusText: "OK", headers: {}, config } as never);
|
|
78
|
+
|
|
79
|
+
if (url.includes("/public/membership-tiers")) return reply(tiers);
|
|
80
|
+
if (url.includes("/public/websites/site-config")) return reply({ currency: "USD" });
|
|
81
|
+
if (url.includes("/public/currencies")) return reply(currencies);
|
|
82
|
+
if (url.includes("/public/payments/subscriptions/free")) return reply({ membershipId: "m-1" });
|
|
83
|
+
if (url.includes("/public/payments/subscriptions")) {
|
|
84
|
+
return reply({ membershipId: "m-1", subscriptionId: "s-1", clientSecret: "pi_secret", requiresPayment: true });
|
|
85
|
+
}
|
|
86
|
+
return reply({});
|
|
87
|
+
};
|
|
88
|
+
realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
|
|
89
|
+
axios.defaults.adapter = adapter;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function mount(opts: { tiers: MembershipTier[]; user?: PublicAuthUser; initialTierId?: string }) {
|
|
93
|
+
installAdapter(opts.tiers);
|
|
94
|
+
// `"initialTierId" in opts` rather than `??`, so a test can ask for NO
|
|
95
|
+
// preselected tier (the grid) without the default quietly putting one back.
|
|
96
|
+
const initialTierId = "initialTierId" in opts ? opts.initialTierId : "tier-1";
|
|
97
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
98
|
+
return render(
|
|
99
|
+
<QueryClientProvider client={queryClient}>
|
|
100
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
|
|
101
|
+
<PublicAuthContext.Provider
|
|
102
|
+
value={
|
|
103
|
+
{
|
|
104
|
+
user: opts.user ?? member,
|
|
105
|
+
isInitialized: true,
|
|
106
|
+
isAuthenticated: true,
|
|
107
|
+
currencies,
|
|
108
|
+
userSelectedCurrency: Currency.NGN,
|
|
109
|
+
} as never
|
|
110
|
+
}
|
|
111
|
+
>
|
|
112
|
+
<ForgeThemeProvider
|
|
113
|
+
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
114
|
+
>
|
|
115
|
+
<MembershipCheckout initialTierId={initialTierId} />
|
|
116
|
+
</ForgeThemeProvider>
|
|
117
|
+
</PublicAuthContext.Provider>
|
|
118
|
+
</ForgeClientProvider>
|
|
119
|
+
</QueryClientProvider>,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const amountBox = () => screen.getByRole("spinbutton") as HTMLInputElement;
|
|
124
|
+
const subscribeButton = () =>
|
|
125
|
+
screen.getByRole("button", { name: /Subscribe|Confirm change|Processing/i }) as HTMLButtonElement;
|
|
126
|
+
const subscriptionPosts = () =>
|
|
127
|
+
sent.filter((r) => r.method === "post" && r.url.includes("/public/payments/subscriptions"));
|
|
128
|
+
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
sent = [];
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
afterEach(() => {
|
|
134
|
+
cleanup();
|
|
135
|
+
if (realAdapter) axios.defaults.adapter = realAdapter;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("pay-what-you-want: the figure that is sent", () => {
|
|
139
|
+
it("REGRESSION: sends the tier's floor when the fan touches nothing, never 0", async () => {
|
|
140
|
+
// The defect: `customAmount` initialised to 0 and nothing seeded it from the
|
|
141
|
+
// tier, so the first press of Subscribe posted `amount: 0`. The server takes
|
|
142
|
+
// the amount as a positive number and refuses that outright, so a
|
|
143
|
+
// pay-what-you-want tier could not be bought at all.
|
|
144
|
+
mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
|
|
145
|
+
|
|
146
|
+
await waitFor(() => expect(amountBox()).toBeTruthy());
|
|
147
|
+
expect(amountBox().value).toBe("37500");
|
|
148
|
+
|
|
149
|
+
fireEvent.click(subscribeButton());
|
|
150
|
+
|
|
151
|
+
await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
|
|
152
|
+
expect(subscriptionPosts()[0].body).toMatchObject({
|
|
153
|
+
amount: 25,
|
|
154
|
+
billingCycle: "month",
|
|
155
|
+
membershipTierId: "tier-1",
|
|
156
|
+
profileId: PROFILE_ID,
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("sends a generous figure, converted from what was typed", async () => {
|
|
161
|
+
// The fan types naira because that is what the page is priced in; the API
|
|
162
|
+
// is charged in the settlement currency.
|
|
163
|
+
mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
|
|
164
|
+
|
|
165
|
+
await waitFor(() => expect(amountBox()).toBeTruthy());
|
|
166
|
+
fireEvent.change(amountBox(), { target: { value: "60000" } });
|
|
167
|
+
fireEvent.click(subscribeButton());
|
|
168
|
+
|
|
169
|
+
await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
|
|
170
|
+
// 60,000 naira at 1500 to the dollar.
|
|
171
|
+
expect(subscriptionPosts()[0].body).toMatchObject({ amount: 40 });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("REGRESSION: switching to yearly re-seeds the box to the YEARLY floor and sends it", async () => {
|
|
175
|
+
// The floor belongs to the cycle. Carrying the monthly minimum onto a yearly
|
|
176
|
+
// subscription sells a whole year for a month's money.
|
|
177
|
+
mount({
|
|
178
|
+
tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 5, payWhatYouWantYearlyMinimum: 50 })],
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await waitFor(() => expect(amountBox().value).toBe("7500"));
|
|
182
|
+
const [monthly, yearly] = screen.getAllByRole("radio") as HTMLInputElement[];
|
|
183
|
+
expect(monthly.checked).toBe(true);
|
|
184
|
+
fireEvent.click(yearly);
|
|
185
|
+
// 50 USD, shown in the currency the fan is reading.
|
|
186
|
+
expect(amountBox().value).toBe("75000");
|
|
187
|
+
|
|
188
|
+
fireEvent.click(subscribeButton());
|
|
189
|
+
|
|
190
|
+
await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
|
|
191
|
+
expect(subscriptionPosts()[0].body).toMatchObject({ amount: 50, billingCycle: "year" });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("refuses a figure under the floor beside the field, and sends nothing", async () => {
|
|
195
|
+
mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
|
|
196
|
+
|
|
197
|
+
await waitFor(() => expect(amountBox()).toBeTruthy());
|
|
198
|
+
fireEvent.change(amountBox(), { target: { value: "3" } });
|
|
199
|
+
fireEvent.click(subscribeButton());
|
|
200
|
+
|
|
201
|
+
expect(await screen.findByRole("alert")).toBeTruthy();
|
|
202
|
+
expect(subscriptionPosts()).toHaveLength(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("refuses a cleared box, which is the zero the server will not take", async () => {
|
|
206
|
+
mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
|
|
207
|
+
|
|
208
|
+
await waitFor(() => expect(amountBox()).toBeTruthy());
|
|
209
|
+
fireEvent.change(amountBox(), { target: { value: "" } });
|
|
210
|
+
fireEvent.click(subscribeButton());
|
|
211
|
+
|
|
212
|
+
expect(await screen.findByRole("alert")).toBeTruthy();
|
|
213
|
+
expect(subscriptionPosts()).toHaveLength(0);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("currency: what converts and what does not", () => {
|
|
218
|
+
it("prints the pay-what-you-want floor in the VISITOR's currency", async () => {
|
|
219
|
+
// The whole screen is priced in naira, so the box is too. Pricing a tier in
|
|
220
|
+
// one currency and asking for the amount in another makes a fan do the
|
|
221
|
+
// arithmetic to buy something, and they will get it wrong.
|
|
222
|
+
mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
|
|
223
|
+
|
|
224
|
+
const label = await screen.findByText(/How much would you like to pay/i);
|
|
225
|
+
// 25 USD at 1500 to the dollar.
|
|
226
|
+
expect(label.textContent).toContain("₦37,500");
|
|
227
|
+
expect(label.textContent).not.toContain("$25");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("seeds the box with the floor, in the currency the fan is reading", async () => {
|
|
231
|
+
mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
|
|
232
|
+
|
|
233
|
+
const input = (await screen.findByLabelText(/How much would you like to pay/i)) as HTMLInputElement;
|
|
234
|
+
expect(input.value).toBe("37500");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("🔴 REGRESSION: sends a WHOLE settlement unit, never a raw quotient", async () => {
|
|
238
|
+
// Every membership price column is an `integer`. A raw quotient reached
|
|
239
|
+
// `ProfilePaymentPrice.findOne({ amount })` and Postgres answered
|
|
240
|
+
// `invalid input syntax for type integer: "426.76681461249575"` - a 500 on
|
|
241
|
+
// the subscribe button, from a number the fan never saw.
|
|
242
|
+
mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
|
|
243
|
+
|
|
244
|
+
const input = (await screen.findByLabelText(/How much would you like to pay/i)) as HTMLInputElement;
|
|
245
|
+
// 40,001 naira at 1500 is 26.667 dollars, which does not divide evenly.
|
|
246
|
+
fireEvent.change(input, { target: { value: "40001" } });
|
|
247
|
+
|
|
248
|
+
await waitFor(() => expect(subscribeButton()).toBeTruthy());
|
|
249
|
+
fireEvent.click(subscribeButton());
|
|
250
|
+
|
|
251
|
+
await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
|
|
252
|
+
const sent = (subscriptionPosts()[0].body as { amount: number }).amount;
|
|
253
|
+
expect(Number.isInteger(sent)).toBe(true);
|
|
254
|
+
expect(sent).toBe(27);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("🔴 REGRESSION: converts what the fan TYPES back to the settlement currency", async () => {
|
|
258
|
+
// The half that stops the disaster. The API charges in USD, so a fan typing
|
|
259
|
+
// 37,500 meaning naira must be billed 25 dollars and not 37,500 of them.
|
|
260
|
+
mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
|
|
261
|
+
|
|
262
|
+
const input = (await screen.findByLabelText(/How much would you like to pay/i)) as HTMLInputElement;
|
|
263
|
+
fireEvent.change(input, { target: { value: "75000" } });
|
|
264
|
+
|
|
265
|
+
await waitFor(() => expect(subscribeButton()).toBeTruthy());
|
|
266
|
+
fireEvent.click(subscribeButton());
|
|
267
|
+
|
|
268
|
+
await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
|
|
269
|
+
// 75,000 naira at 1500 to the dollar is 50 dollars.
|
|
270
|
+
expect(subscriptionPosts()[0].body).toMatchObject({ amount: 50 });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("converts the total the fan only reads", async () => {
|
|
274
|
+
mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
|
|
275
|
+
|
|
276
|
+
// 25 USD at 1500 to the dollar.
|
|
277
|
+
expect(await screen.findByText("₦37,500")).toBeTruthy();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("sends the settlement figure even while the screen reads naira", async () => {
|
|
281
|
+
mount({ tiers: [tier({ priceMonthly: 10 })] });
|
|
282
|
+
|
|
283
|
+
await waitFor(() => expect(subscribeButton()).toBeTruthy());
|
|
284
|
+
fireEvent.click(subscribeButton());
|
|
285
|
+
|
|
286
|
+
await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
|
|
287
|
+
expect(subscriptionPosts()[0].body).toMatchObject({ amount: 10 });
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("billing cycles a tier actually sells", () => {
|
|
292
|
+
it("REGRESSION: buys a YEARLY-ONLY tier as a paid subscription, not as a free one", async () => {
|
|
293
|
+
// The defect: the cycle defaulted to the literal "month", the tier had no
|
|
294
|
+
// monthly price, so it was read as free and activated through
|
|
295
|
+
// `/subscriptions/free`. The fan got the tier and the artist got nothing.
|
|
296
|
+
mount({ tiers: [tier({ priceYearly: 100 })] });
|
|
297
|
+
|
|
298
|
+
await waitFor(() => expect(subscribeButton()).toBeTruthy());
|
|
299
|
+
fireEvent.click(subscribeButton());
|
|
300
|
+
|
|
301
|
+
await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
|
|
302
|
+
const post = subscriptionPosts()[0];
|
|
303
|
+
expect(post.url).not.toContain("/free");
|
|
304
|
+
expect(post.body).toMatchObject({ amount: 100, billingCycle: "year" });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("offers no cycle choice when the tier sells only one", async () => {
|
|
308
|
+
mount({ tiers: [tier({ priceMonthly: 10 })] });
|
|
309
|
+
|
|
310
|
+
await waitFor(() => expect(subscribeButton()).toBeTruthy());
|
|
311
|
+
expect(screen.queryByRole("radio")).toBeNull();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("activates a genuinely free tier through the free endpoint, with no amount", async () => {
|
|
315
|
+
mount({ tiers: [tier({ name: "Free Circle" })] });
|
|
316
|
+
|
|
317
|
+
await waitFor(() => expect(subscribeButton()).toBeTruthy());
|
|
318
|
+
fireEvent.click(subscribeButton());
|
|
319
|
+
|
|
320
|
+
await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
|
|
321
|
+
const post = subscriptionPosts()[0];
|
|
322
|
+
expect(post.url).toContain("/subscriptions/free");
|
|
323
|
+
expect(post.body).not.toHaveProperty("amount");
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe("the tier grid, when no tier was chosen up front", () => {
|
|
328
|
+
it("does not offer the tier the member is already on", async () => {
|
|
329
|
+
const onTier = { ...member, membership: { membershipTierId: "tier-1" } } as unknown as PublicAuthUser;
|
|
330
|
+
mount({
|
|
331
|
+
tiers: [tier({ id: "tier-1", name: "Inner Circle", priceMonthly: 10 }), tier({ id: "tier-2", name: "Backstage", priceMonthly: 20 })],
|
|
332
|
+
user: onTier,
|
|
333
|
+
initialTierId: undefined,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
expect(await screen.findByText("Current plan")).toBeTruthy();
|
|
337
|
+
// One button per selectable tier: the current one offers a sentence instead.
|
|
338
|
+
expect(screen.getAllByRole("button", { name: "Select this tier" })).toHaveLength(1);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("REGRESSION: advertises a yearly-only pay-what-you-want tier on its yearly floor", async () => {
|
|
342
|
+
// Reading the monthly minimum unconditionally printed "from ₦0/mo", which
|
|
343
|
+
// reads as free and is the opposite of what the tier costs.
|
|
344
|
+
mount({
|
|
345
|
+
tiers: [tier({ payWhatYouWant: true, payWhatYouWantYearlyMinimum: 60 })],
|
|
346
|
+
initialTierId: undefined,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const label = await screen.findByText(/Pay what you want from/i);
|
|
350
|
+
// 60 USD at 1500 to the dollar, advertised per year.
|
|
351
|
+
expect(label.textContent).toContain("₦90,000");
|
|
352
|
+
expect(label.textContent).toContain("/yr");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("calls a change a change for a member with a live subscription", async () => {
|
|
356
|
+
const paying = {
|
|
357
|
+
...member,
|
|
358
|
+
membership: { membershipTierId: "tier-1", status: "active", paymentProviderSubscriptionId: "sub_123" },
|
|
359
|
+
} as unknown as PublicAuthUser;
|
|
360
|
+
mount({ tiers: [tier({ id: "tier-2", name: "Backstage", priceMonthly: 20 })], user: paying, initialTierId: "tier-2" });
|
|
361
|
+
|
|
362
|
+
expect(await screen.findByRole("button", { name: "Confirm change" })).toBeTruthy();
|
|
363
|
+
});
|
|
364
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { ForgeClientProvider } from "../../../provider/ForgeProvider";
|
|
5
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
6
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
7
|
+
import type { ResumeLine } from "../../../data/queries/useCheckouts";
|
|
8
|
+
import type { UseCartResumeResult } from "../../headless/checkout/useCartResume";
|
|
9
|
+
import { ProductDeliveryType } from "../../../types/models";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The page a cart-recovery link lands on, as a Forge code website draws it.
|
|
13
|
+
*
|
|
14
|
+
* `useCartResume` is stubbed at the module boundary: what the hook DECIDES is
|
|
15
|
+
* asserted in `headless/checkout/_tests`, and what is asserted here is the half
|
|
16
|
+
* that can only go wrong in the markup. A bucket the hook fills and the page
|
|
17
|
+
* never draws is invisible to every hook test, and it is the exact shape of the
|
|
18
|
+
* defect this page had: an add-on left out of the basket with nothing on screen
|
|
19
|
+
* saying so.
|
|
20
|
+
*
|
|
21
|
+
* `apps/client` draws the same state in Tailwind and has its own twin of this
|
|
22
|
+
* spec. Both must move together, so both assert the same sentences.
|
|
23
|
+
*
|
|
24
|
+
* Rendered through `react-dom/server`, so no DOM and no effects.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const state = vi.hoisted(() => ({ current: null as UseCartResumeResult | null }));
|
|
28
|
+
|
|
29
|
+
vi.mock("../../headless/checkout/useCartResume", async (importOriginal) => ({
|
|
30
|
+
...(await importOriginal<typeof import("../../headless/checkout/useCartResume")>()),
|
|
31
|
+
useCartResume: () => state.current,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const { ResumeCart } = await import("../ResumeCart");
|
|
35
|
+
|
|
36
|
+
const line = (overrides: Partial<ResumeLine> = {}): ResumeLine => ({
|
|
37
|
+
type: "product",
|
|
38
|
+
quantity: 1,
|
|
39
|
+
title: "Tour Tee",
|
|
40
|
+
coverImage: null,
|
|
41
|
+
price: 25,
|
|
42
|
+
unitPriceCents: 2500,
|
|
43
|
+
quotedUnitPriceCents: 2500,
|
|
44
|
+
priceChanged: false,
|
|
45
|
+
available: true,
|
|
46
|
+
unavailableReason: null,
|
|
47
|
+
productId: "prod-1",
|
|
48
|
+
productVariantId: "var-1",
|
|
49
|
+
deliveryType: ProductDeliveryType.Physical,
|
|
50
|
+
isGift: false,
|
|
51
|
+
...overrides,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const result = (overrides: Partial<UseCartResumeResult> = {}): UseCartResumeResult => ({
|
|
55
|
+
status: "restored",
|
|
56
|
+
resumed: null,
|
|
57
|
+
restoredLines: [],
|
|
58
|
+
unavailableLines: [],
|
|
59
|
+
continueLines: [],
|
|
60
|
+
droppedLines: [],
|
|
61
|
+
repricedLines: [],
|
|
62
|
+
itemCount: 0,
|
|
63
|
+
subtotalCents: 0,
|
|
64
|
+
currency: "USD",
|
|
65
|
+
couponCode: null,
|
|
66
|
+
error: null,
|
|
67
|
+
restore: () => {},
|
|
68
|
+
...overrides,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function render(resume: UseCartResumeResult): string {
|
|
72
|
+
state.current = resume;
|
|
73
|
+
return renderToStaticMarkup(
|
|
74
|
+
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
|
75
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId="profile-1">
|
|
76
|
+
<PublicAuthContext.Provider value={{ currencies: null, userSelectedCurrency: "USD" } as never}>
|
|
77
|
+
<ForgeThemeProvider
|
|
78
|
+
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
79
|
+
>
|
|
80
|
+
<ResumeCart token="tok-1" formatAmount={(n) => `$${n.toFixed(2)}`} />
|
|
81
|
+
</ForgeThemeProvider>
|
|
82
|
+
</PublicAuthContext.Provider>
|
|
83
|
+
</ForgeClientProvider>
|
|
84
|
+
</QueryClientProvider>,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("ResumeCart", () => {
|
|
89
|
+
it("REGRESSION: names the add-on that was left out, and why", () => {
|
|
90
|
+
// Silence here is the worst outcome the whole page exists to prevent: the
|
|
91
|
+
// buyer came back for the poster, the basket is one line lighter, and
|
|
92
|
+
// nothing says so.
|
|
93
|
+
const html = render(
|
|
94
|
+
result({
|
|
95
|
+
droppedLines: [line({ title: "Tour poster" })],
|
|
96
|
+
unavailableLines: [line({ title: "General Admission", available: false, unavailableReason: "sold_out" })],
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(html).toContain("Tour poster");
|
|
101
|
+
expect(html).toContain("Left out, because the ticket it goes with could not be put back");
|
|
102
|
+
expect(html).toContain("Sold only with its ticket");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("keeps the left-out add-on apart from what the shop refused", () => {
|
|
106
|
+
// Two different sentences because the buyer's next move differs: a sold-out
|
|
107
|
+
// tier may free up, and the add-on is only waiting on that tier.
|
|
108
|
+
const html = render(
|
|
109
|
+
result({
|
|
110
|
+
droppedLines: [line({ title: "Tour poster" })],
|
|
111
|
+
unavailableLines: [line({ title: "Vinyl", available: false, unavailableReason: "removed" })],
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(html).toContain("No longer sold");
|
|
116
|
+
expect(html).toContain("Sold only with its ticket");
|
|
117
|
+
expect(html.indexOf("Could not be put back")).toBeLessThan(html.indexOf("Left out, because"));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("says nothing about add-ons when none were left out", () => {
|
|
121
|
+
const html = render(result({ restoredLines: [line()], itemCount: 1, subtotalCents: 2500 }));
|
|
122
|
+
expect(html).not.toContain("Left out, because");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("draws the restored lines with today's price and the total", () => {
|
|
126
|
+
const html = render(
|
|
127
|
+
result({ restoredLines: [line({ quantity: 2 })], itemCount: 2, subtotalCents: 5000 }),
|
|
128
|
+
);
|
|
129
|
+
expect(html).toContain("Tour Tee");
|
|
130
|
+
expect(html).toContain("2 item(s) are back in your basket.");
|
|
131
|
+
expect(html).toContain("Subtotal");
|
|
132
|
+
expect(html).toContain("$50.00");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("shows the old price struck through when the shop re-priced a line", () => {
|
|
136
|
+
const html = render(
|
|
137
|
+
result({
|
|
138
|
+
restoredLines: [line({ priceChanged: true, quotedUnitPriceCents: 2000 })],
|
|
139
|
+
repricedLines: [line({ priceChanged: true })],
|
|
140
|
+
itemCount: 1,
|
|
141
|
+
subtotalCents: 2500,
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
expect(html).toContain("line-through");
|
|
145
|
+
expect(html).toContain("$20.00");
|
|
146
|
+
expect(html).toContain("has changed since you were last here");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("offers checkout only when something is actually in the basket", () => {
|
|
150
|
+
const empty = render(
|
|
151
|
+
result({
|
|
152
|
+
status: "empty",
|
|
153
|
+
unavailableLines: [line({ available: false, unavailableReason: "removed" })],
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
expect(empty).not.toContain("Go to checkout");
|
|
157
|
+
expect(empty).toContain("That basket has moved on");
|
|
158
|
+
|
|
159
|
+
const full = render(result({ restoredLines: [line()], itemCount: 1, subtotalCents: 2500 }));
|
|
160
|
+
expect(full).toContain("Go to checkout");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("links a course onward instead of pretending it is in the basket", () => {
|
|
164
|
+
const html = render(
|
|
165
|
+
result({
|
|
166
|
+
continueLines: [line({ type: "course", title: "Songwriting", courseSlug: "songwriting" })],
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
expect(html).toContain("/i/courses/songwriting");
|
|
170
|
+
expect(html).toContain("Pick up where you left off");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("repeats the code the buyer had, since checkout will ask for it again", () => {
|
|
174
|
+
const html = render(result({ couponCode: "SUMMER10", restoredLines: [line()], itemCount: 1 }));
|
|
175
|
+
expect(html).toContain("SUMMER10");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("says the link is dead without hinting at what it might have been", () => {
|
|
179
|
+
const html = render(result({ status: "error", error: "invalid_link" }));
|
|
180
|
+
expect(html).toContain("This link no longer works");
|
|
181
|
+
expect(html).not.toContain("Subtotal");
|
|
182
|
+
});
|
|
183
|
+
});
|