Simpl-Resultat/src/components/shared/UpsellCtas.render.test.tsx
le king fu b107cf42ce
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m45s
PR Check — Rust / rust (pull_request) Successful in 8m54s
fix(license): apply the review of #368
- A state file that cannot be written now has a test on both of its paths:
  the attempt (nothing is sent, or a read-only data directory would send a
  request at every start) and the verdict (the key is not reported revoked
  when nothing on disk says so). The first path was correct but unguarded:
  the review's mutation left all 185 tests green.
- The storage outcome no longer claims that nothing was sent: it also covers
  a verdict that could not be written. Reworded in licenseService.ts, the
  in-app log line and docs/architecture.md.
- The Updates card names a revoked key in its Free branch, between the
  message and the CTAs: a refunded Base buyer first meets the loss of
  automatic updates there (Max, 2026-10-03). The error page stays as it is.
- A revoked key keeps being checked once per 24 h while it stays stored, so
  that a correction made on the license server is picked up (Max,
  2026-10-03). Declared in the user guide, the in-app docs, the changelogs
  and the ADR 0017 amendment.

Refs #365

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:29:47 -04:00

179 lines
6.9 KiB
TypeScript

// 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<typeof import("../../services/logService")>()),
logError: (...args: unknown[]) => logError(...args),
}));
let language = "fr";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
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(
<MemoryRouter>
<UpsellGate feature="budget" requiredTier="base" />
</MemoryRouter>,
);
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(
<MemoryRouter>
<UpsellGate feature="budget" requiredTier="base" />
</MemoryRouter>,
);
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(
<MemoryRouter>
<UpsellGate feature="balance" requiredTier="premium" />
</MemoryRouter>,
);
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(<UpsellGate feature="multi-profile" requiredTier="base" />);
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(
<MemoryRouter>
<UpdateCard />
</MemoryRouter>,
);
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(
<MemoryRouter>
<UpdateCard />
</MemoryRouter>,
);
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);
});
});