// Render tests for the shared upsell CTAs (#346). No jsdom needed: // react-dom/server renders in vitest's node environment, and calling a // component as a function (FilterPanel.test.tsx pattern) exposes its props. import { describe, it, expect, vi, beforeEach } from "vitest"; import { isValidElement, type ReactElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router-dom"; const openUrl = vi.fn((_url: string) => Promise.resolve()); vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: (u: string) => openUrl(u) })); const logError = vi.fn(); vi.mock("../../services/logService", async (importOriginal) => ({ ...(await importOriginal()), logError: (...args: unknown[]) => logError(...args), })); let language = "fr"; vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string, o?: Record) => o ? `${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : k, i18n: { language }, }), })); // The locked screen and the Updates card read one thing from the license context: // whether the stored key was revoked (#365). let licenseRevoked = false; vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: () => ({ revoked: licenseRevoked }), })); vi.mock("../../hooks/useUpdater", () => ({ useUpdater: () => ({ state: { status: "notEntitled", version: null, body: null, progress: 0, contentLength: null, error: null }, checkForUpdate: vi.fn(), downloadAndInstall: vi.fn(), installAndRestart: vi.fn(), }), })); import UpsellGate from "./UpsellGate"; import PurchaseButton from "./PurchaseButton"; import DownloadLatestButton from "./DownloadLatestButton"; import HaveKeyButton from "./HaveKeyButton"; import UpdateCard from "../settings/UpdateCard"; function find(node: ReactNode, pred: (el: ReactElement) => boolean): ReactElement[] { if (Array.isArray(node)) return node.flatMap((n) => find(n, pred)); if (!isValidElement(node)) return []; const el = node as ReactElement<{ children?: ReactNode }>; return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)]; } beforeEach(() => { openUrl.mockClear(); logError.mockClear(); language = "fr"; licenseRevoked = false; }); describe("UpsellGate with a revoked key (#365)", () => { const gate = () => renderToStaticMarkup( , ); it("says nothing about a revocation for a key that is not revoked", () => { expect(gate()).not.toContain("upsell.revoked"); }); it("names the revocation between the feature description and the CTAs", () => { licenseRevoked = true; const html = gate(); const line = html.indexOf("upsell.revoked"); expect(line).toBeGreaterThan(html.indexOf("upsell.features.budget")); expect(line).toBeLessThan(html.indexOf("upsell.ctaGet")); expect(html.split("upsell.revoked")).toHaveLength(2); // The way out stays: buying, or entering another key. expect(html).toContain("upsell.ctaGet[tier=license.editions.base]"); expect(html).toContain("upsell.ctaHaveKey"); }); }); describe("UpsellGate wiring", () => { it("Base screen: active Get Base, no note, no disabled button", () => { const html = renderToStaticMarkup( , ); expect(html).toContain("upsell.ctaGet[tier=license.editions.base]"); expect(html).not.toContain("disabled"); expect(html).not.toContain("upsell.premiumNotForSale"); expect(html).toContain("upsell.ctaHaveKey"); }); it("Premium screen: disabled button with the not-for-sale note", () => { const html = renderToStaticMarkup( , ); expect(html).toContain('disabled=""'); expect(html).toContain("upsell.ctaGet[tier=license.editions.premium]"); expect(html).toContain("upsell.premiumNotForSale"); }); it("outside any router (ProfileSelectionPage, ErrorPage): renders, without I already have a key", () => { const html = renderToStaticMarkup(); expect(html).toContain("upsell.ctaGet"); expect(html).not.toContain("upsell.ctaHaveKey"); }); it("forwards onNavigate to I already have a key only", () => { const onNavigate = vi.fn(); const tree = UpsellGate({ feature: "budget", requiredTier: "base", onNavigate }); const haveKey = find(tree, (el) => el.type === HaveKeyButton); expect(haveKey).toHaveLength(1); expect((haveKey[0].props as { onNavigate?: () => void }).onNavigate).toBe(onNavigate); const purchase = find(tree, (el) => el.type === PurchaseButton); expect(purchase).toHaveLength(1); expect(purchase[0].props).not.toHaveProperty("onNavigate"); }); }); // Both buttons open the product page. A failure to open the browser must reach // the in-app logs, not vanish as an unhandled rejection. function productPageButtonTests(Button: (props: { className?: string }) => ReactNode) { it("opens the product page in the app language", () => { language = "en"; const button = Button({}) as ReactElement<{ onClick: () => void }>; button.props.onClick(); expect(openUrl).toHaveBeenCalledWith("https://lacompagniemaximus.com/en/produits/simpl-resultat"); }); it("logs a failure to open the browser", async () => { const failure = new Error("no browser"); openUrl.mockRejectedValueOnce(failure); const button = Button({}) as ReactElement<{ onClick: () => void }>; button.props.onClick(); await vi.waitFor(() => expect(logError).toHaveBeenCalledWith(expect.any(String), failure)); }); } describe("PurchaseButton", () => productPageButtonTests(PurchaseButton)); describe("DownloadLatestButton", () => productPageButtonTests(DownloadLatestButton)); describe("UpdateCard in the free edition", () => { it("offers Get Base, I already have a key and Download the latest version", () => { const html = renderToStaticMarkup( , ); expect(html).toContain("settings.updates.notEntitled"); expect(html).toContain("upsell.ctaGet[tier=license.editions.base]"); expect(html).toContain("upsell.ctaHaveKey"); expect(html).toContain("settings.updates.downloadLatest"); expect(html).not.toContain("upsell.revoked"); }); it("names a revoked key between the message and the CTAs (#365)", () => { licenseRevoked = true; const html = renderToStaticMarkup( , ); const line = html.indexOf("upsell.revoked"); expect(line).toBeGreaterThan(html.indexOf("settings.updates.notEntitled")); expect(line).toBeLessThan(html.indexOf("upsell.ctaGet")); expect(html.split("upsell.revoked")).toHaveLength(2); }); });