Files
community-rule/tests/unit/hooks/useCompletedRuleShareExport.test.tsx
2026-04-29 22:27:46 -06:00

352 lines
11 KiB
TypeScript

import React from "react";
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { MessagesProvider } from "../../../app/contexts/MessagesContext";
import messages from "../../../messages/en/index";
import { useCompletedRuleShareExport } from "../../../app/(app)/create/hooks/useCompletedRuleShareExport";
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import {
DISCORD_WEB_DM_HUB_URL,
DISCORD_NATIVE_DM_HUB_URL,
NATIVE_SHARE_FALLBACK_DELAY_MS,
SLACK_NATIVE_OPEN_URL,
} from "../../../lib/create/shareChannels";
vi.mock("../../../lib/create/lastPublishedRule", () => ({
readLastPublishedRule: vi.fn(),
}));
function wrapper({ children }: { children: React.ReactNode }) {
return <MessagesProvider messages={messages}>{children}</MessagesProvider>;
}
describe("useCompletedRuleShareExport", () => {
const mockRule = {
id: "rule-1",
title: "Garden norms",
summary: "Be kind.",
document: {},
};
beforeEach(() => {
vi.mocked(readLastPublishedRule).mockReturnValue(mockRule);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("shareViaSlack opens Slack web share URL when window.open succeeds", async () => {
vi.useFakeTimers();
const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {});
const openSpy = vi.spyOn(window, "open").mockReturnValue({} as Window);
const setBanner = vi.fn();
const { result } = renderHook(
() =>
useCompletedRuleShareExport({
setActionBanner: setBanner,
}),
{ wrapper },
);
await act(async () => {
await result.current.sharePublishedRuleViaSlack();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25);
});
expect(clickSpy).toHaveBeenCalled();
const anchorUnknown = clickSpy.mock.instances.at(-1) as unknown;
expect(anchorUnknown).toBeInstanceOf(HTMLAnchorElement);
const anchorEl = anchorUnknown as HTMLAnchorElement;
expect(anchorEl.getAttribute("href")).toBe(SLACK_NATIVE_OPEN_URL);
const expectedUrl = `https://slack.com/share?url=${encodeURIComponent(`${window.location.origin}/rules/rule-1`)}`;
expect(openSpy).toHaveBeenCalledWith(
expectedUrl,
"_blank",
"noopener,noreferrer",
);
expect(setBanner).not.toHaveBeenCalledWith(
expect.objectContaining({
key: "completedShareCopyFailed",
status: "danger",
}),
);
clickSpy.mockRestore();
openSpy.mockRestore();
});
it("shareViaSlack does not show copy-failed banner when fallback is skipped after handoff (no focus)", async () => {
vi.useFakeTimers();
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
vi.spyOn(window, "open").mockReturnValue(null);
const hasFocusSpy = vi.spyOn(document, "hasFocus").mockReturnValue(false);
const writeText = vi.fn().mockRejectedValue(new Error("NotAllowedError"));
vi.stubGlobal("navigator", {
...navigator,
share: undefined,
canShare: undefined,
clipboard: { writeText },
});
const setBanner = vi.fn();
const { result } = renderHook(
() =>
useCompletedRuleShareExport({
setActionBanner: setBanner,
}),
{ wrapper },
);
await act(async () => {
await result.current.sharePublishedRuleViaSlack();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25);
});
expect(writeText).not.toHaveBeenCalled();
expect(setBanner).not.toHaveBeenCalledWith(
expect.objectContaining({ key: "completedShareCopyFailed" }),
);
hasFocusSpy.mockRestore();
});
it("shareViaSlack suppresses copy-failed banner when clipboard denies after focus loss", async () => {
vi.useFakeTimers();
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
vi.spyOn(window, "open").mockReturnValue(null);
let hasFocusCalls = 0;
const hasFocusSpy = vi.spyOn(document, "hasFocus").mockImplementation(() => {
hasFocusCalls += 1;
return hasFocusCalls <= 2;
});
const writeText = vi.fn().mockImplementation(async () => {
throw new Error("NotAllowedError");
});
vi.stubGlobal("navigator", {
...navigator,
share: undefined,
canShare: undefined,
clipboard: { writeText },
});
const setBanner = vi.fn();
const { result } = renderHook(
() =>
useCompletedRuleShareExport({
setActionBanner: setBanner,
}),
{ wrapper },
);
await act(async () => {
await result.current.sharePublishedRuleViaSlack();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25);
});
expect(writeText).toHaveBeenCalled();
expect(setBanner).not.toHaveBeenCalledWith(
expect.objectContaining({ key: "completedShareCopyFailed" }),
);
hasFocusSpy.mockRestore();
});
it("shareViaSlack falls back to clipboard when popup blocked and Web Share cannot run", async () => {
vi.useFakeTimers();
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
vi.spyOn(window, "open").mockReturnValue(null);
vi.spyOn(document, "hasFocus").mockReturnValue(true);
const share = vi.fn();
vi.stubGlobal("navigator", {
...navigator,
share: share,
canShare: vi.fn().mockReturnValue(false),
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
const setBanner = vi.fn();
const { result } = renderHook(
() =>
useCompletedRuleShareExport({
setActionBanner: setBanner,
}),
{ wrapper },
);
await act(async () => {
await result.current.sharePublishedRuleViaSlack();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25);
});
expect(share).not.toHaveBeenCalled();
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
`${window.location.origin}/rules/rule-1`,
);
expect(setBanner).toHaveBeenCalledWith(
expect.objectContaining({
key: "completedShareSlackFallback",
status: "positive",
}),
);
});
it("shareViaSignal uses navigator.share when canShare allows URL-only data", async () => {
const share = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", {
...navigator,
share: share,
canShare: vi.fn().mockImplementation((data: ShareData) => data.url != null),
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
const setBanner = vi.fn();
const { result } = renderHook(
() =>
useCompletedRuleShareExport({
setActionBanner: setBanner,
}),
{ wrapper },
);
await act(async () => {
await result.current.sharePublishedRuleViaSignal();
});
expect(share).toHaveBeenCalledWith({
url: `${window.location.origin}/rules/rule-1`,
});
expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
expect(setBanner).not.toHaveBeenCalled();
});
it("shareViaDiscord opens Discord hub and copies link when share unavailable", async () => {
vi.useFakeTimers();
const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {});
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
vi.stubGlobal("navigator", {
...navigator,
share: undefined,
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
const setBanner = vi.fn();
const { result } = renderHook(
() =>
useCompletedRuleShareExport({
setActionBanner: setBanner,
}),
{ wrapper },
);
await act(async () => {
await result.current.sharePublishedRuleViaDiscord();
});
expect(clickSpy).toHaveBeenCalled();
const anchorUnknown = clickSpy.mock.instances.at(-1) as unknown;
expect(anchorUnknown).toBeInstanceOf(HTMLAnchorElement);
expect((anchorUnknown as HTMLAnchorElement).getAttribute("href")).toBe(
DISCORD_NATIVE_DM_HUB_URL,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25);
});
expect(openSpy).toHaveBeenCalledWith(
DISCORD_WEB_DM_HUB_URL,
"_blank",
"noopener,noreferrer",
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
`${window.location.origin}/rules/rule-1`,
);
expect(setBanner).toHaveBeenCalledWith(
expect.objectContaining({
key: "completedShareDiscordPaste",
status: "positive",
}),
);
clickSpy.mockRestore();
openSpy.mockRestore();
});
it("onSelectExportFormat pdf triggers download with community-rule pdf filename", () => {
vi.mocked(readLastPublishedRule).mockReturnValue({
...mockRule,
document: {
sections: [
{ categoryName: "Values", entries: [{ title: "Norm", body: "Text." }] },
],
},
});
const createObjectURL = vi.fn().mockReturnValue("blob:unit-test");
const revokeObjectURL = vi.fn();
Object.defineProperty(URL, "createObjectURL", {
value: createObjectURL,
writable: true,
configurable: true,
});
Object.defineProperty(URL, "revokeObjectURL", {
value: revokeObjectURL,
writable: true,
configurable: true,
});
const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {});
const setBanner = vi.fn();
try {
const { result } = renderHook(
() =>
useCompletedRuleShareExport({
setActionBanner: setBanner,
}),
{ wrapper },
);
act(() => {
result.current.onSelectExportFormat("pdf");
});
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
const blob = createObjectURL.mock.calls[0][0] as Blob;
expect(blob.type).toBe("application/pdf");
const anchorUnknown = clickSpy.mock.instances.at(-1) as unknown;
expect(anchorUnknown).toBeInstanceOf(HTMLAnchorElement);
const anchorEl = anchorUnknown as HTMLAnchorElement;
expect(anchorEl.getAttribute("download")).toBe(
"garden-norms-community-rule.pdf",
);
expect(anchorEl.getAttribute("href")).toBe("blob:unit-test");
expect(setBanner).not.toHaveBeenCalled();
} finally {
Reflect.deleteProperty(URL, "createObjectURL");
Reflect.deleteProperty(URL, "revokeObjectURL");
clickSpy.mockRestore();
}
});
});