- 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>
179 lines
6.9 KiB
TypeScript
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);
|
|
});
|
|
});
|