Implement share and export components
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildPrintableRuleHtmlDocument,
|
||||
buildPublicRuleUrl,
|
||||
buildStoredRulePdfBlob,
|
||||
exportFilenameBase,
|
||||
sectionsToCsv,
|
||||
sectionsToMarkdown,
|
||||
} from "../../../lib/create/ruleExport";
|
||||
import type { CommunityRuleSection } from "../../../app/components/type/CommunityRule/CommunityRule.types";
|
||||
|
||||
async function readBlobAsArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
|
||||
if (typeof blob.arrayBuffer === "function") {
|
||||
return blob.arrayBuffer();
|
||||
}
|
||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = (): void => resolve(r.result as ArrayBuffer);
|
||||
r.onerror = (): void => reject(new Error("FileReader failed"));
|
||||
r.readAsArrayBuffer(blob);
|
||||
});
|
||||
}
|
||||
|
||||
describe("ruleExport", () => {
|
||||
it("buildPublicRuleUrl encodes id and trims origin slash", () => {
|
||||
expect(buildPublicRuleUrl("https://example.com/", "abc/xyz")).toBe(
|
||||
"https://example.com/rules/abc%2Fxyz",
|
||||
);
|
||||
expect(buildPublicRuleUrl("https://example.com", "r1")).toBe(
|
||||
"https://example.com/rules/r1",
|
||||
);
|
||||
});
|
||||
|
||||
it("exportFilenameBase slugifies title", () => {
|
||||
expect(
|
||||
exportFilenameBase({
|
||||
id: "id-1",
|
||||
title: "Mutual Aid Mondays!",
|
||||
document: {},
|
||||
}),
|
||||
).toBe("mutual-aid-mondays");
|
||||
});
|
||||
|
||||
it("exportFilenameBase falls back to id fragment", () => {
|
||||
expect(
|
||||
exportFilenameBase({
|
||||
id: "full-uuid-here",
|
||||
title: " ",
|
||||
document: {},
|
||||
}),
|
||||
).toBe("rule-full-uui");
|
||||
});
|
||||
|
||||
it("sectionsToMarkdown renders title, summary, and sections", () => {
|
||||
const sections: CommunityRuleSection[] = [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{
|
||||
title: "Solidarity",
|
||||
body: "First paragraph.\n\nSecond paragraph.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const md = sectionsToMarkdown(
|
||||
"My Rule",
|
||||
"Short summary.",
|
||||
sections,
|
||||
);
|
||||
expect(md).toContain("# My Rule");
|
||||
expect(md).toContain("Short summary.");
|
||||
expect(md).toContain("## Values");
|
||||
expect(md).toContain("### Solidarity");
|
||||
expect(md).toContain("First paragraph.");
|
||||
expect(md).toContain("Second paragraph.");
|
||||
});
|
||||
|
||||
it("sectionsToCsv includes header row, title metadata, sections, and quotes commas", () => {
|
||||
const sections: CommunityRuleSection[] = [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{
|
||||
title: "Solidarity",
|
||||
body: "One, two",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const csv = sectionsToCsv("My Rule", "Sum, mary", sections);
|
||||
expect(csv).toContain("Section,Entry,Block label,Content");
|
||||
expect(csv).toContain('"Sum, mary"');
|
||||
expect(csv).toContain('"One, two"');
|
||||
expect(csv).toContain(",Title,,My Rule");
|
||||
});
|
||||
|
||||
it("buildPrintableRuleHtmlDocument escapes HTML in user content", () => {
|
||||
const sections: CommunityRuleSection[] = [
|
||||
{
|
||||
categoryName: 'Values <x>',
|
||||
entries: [{ title: "Entry", body: "<script>bad()</script>" }],
|
||||
},
|
||||
];
|
||||
const html = buildPrintableRuleHtmlDocument(
|
||||
'Title <t>',
|
||||
null,
|
||||
sections,
|
||||
);
|
||||
expect(html).toContain("<script>");
|
||||
expect(html).not.toContain("<script>bad()");
|
||||
expect(html).toContain("Values <x>");
|
||||
});
|
||||
|
||||
it("buildStoredRulePdfBlob produces application/pdf with PDF magic bytes", async () => {
|
||||
const blob = buildStoredRulePdfBlob({
|
||||
id: "id-1",
|
||||
title: "Garden Norms",
|
||||
summary: "Summary here.",
|
||||
document: {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [{ title: "Solidarity", body: "Be kind.\n\nShare tools." }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(blob.type).toBe("application/pdf");
|
||||
expect(blob.size).toBeGreaterThan(500);
|
||||
const buf = new Uint8Array(await readBlobAsArrayBuffer(blob));
|
||||
expect(String.fromCharCode(...buf.subarray(0, 5))).toBe("%PDF-");
|
||||
});
|
||||
|
||||
it("buildStoredRulePdfBlob throws exportEmptyDocument when sections empty", () => {
|
||||
expect(() =>
|
||||
buildStoredRulePdfBlob({
|
||||
id: "id-1",
|
||||
title: "T",
|
||||
document: {},
|
||||
}),
|
||||
).toThrowError("exportEmptyDocument");
|
||||
});
|
||||
|
||||
it("export pdf attachment filename matches csv/md convention", () => {
|
||||
const rule = {
|
||||
id: "id-1",
|
||||
title: "Garden norms",
|
||||
document: {
|
||||
sections: [
|
||||
{ categoryName: "X", entries: [{ title: "t", body: "b" }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(`${exportFilenameBase(rule)}-community-rule.pdf`).toBe(
|
||||
"garden-norms-community-rule.pdf",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
buildMailtoShareHref,
|
||||
buildSlackWebShareUrl,
|
||||
DISCORD_NATIVE_DM_HUB_URL,
|
||||
DISCORD_WEB_DM_HUB_URL,
|
||||
NATIVE_SHARE_FALLBACK_DELAY_MS,
|
||||
type NativeFallbackTimers,
|
||||
scheduleNativeSchemeThenFallback,
|
||||
SLACK_NATIVE_OPEN_URL,
|
||||
} from "../../../lib/create/shareChannels";
|
||||
|
||||
describe("shareChannels", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("buildSlackWebShareUrl encodes the outgoing URL query value", () => {
|
||||
expect(buildSlackWebShareUrl("https://example.com/rules/r1")).toBe(
|
||||
"https://slack.com/share?url=https%3A%2F%2Fexample.com%2Frules%2Fr1",
|
||||
);
|
||||
expect(
|
||||
buildSlackWebShareUrl("https://example.com/rules/a?b=c&d=e"),
|
||||
).toBe(
|
||||
"https://slack.com/share?url=https%3A%2F%2Fexample.com%2Frules%2Fa%3Fb%3Dc%26d%3De",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildMailtoShareHref percent-encodes subject and body including newlines", () => {
|
||||
expect(
|
||||
buildMailtoShareHref({
|
||||
subject: "Hello & welcome",
|
||||
body: "Line one\n\nhttps://x.com/y z",
|
||||
}),
|
||||
).toBe(
|
||||
"mailto:?subject=Hello%20%26%20welcome&body=Line%20one%0A%0Ahttps%3A%2F%2Fx.com%2Fy%20z",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildMailtoShareHref handles unicode", () => {
|
||||
const href = buildMailtoShareHref({
|
||||
subject: "日本語",
|
||||
body: "café ☕",
|
||||
});
|
||||
expect(href.startsWith("mailto:?subject=")).toBe(true);
|
||||
expect(href).toContain(encodeURIComponent("日本語"));
|
||||
expect(href).toContain(encodeURIComponent("café ☕"));
|
||||
});
|
||||
|
||||
it("exposes Discord native + web DM hub URL constants", () => {
|
||||
expect(DISCORD_WEB_DM_HUB_URL).toBe("https://discord.com/channels/@me");
|
||||
expect(DISCORD_NATIVE_DM_HUB_URL).toBe("discord://-/channels/@me");
|
||||
});
|
||||
|
||||
it("scheduleNativeSchemeThenFallback skips native assign and invokes fallback synchronously when URL is not allowlisted", () => {
|
||||
const assign = vi.fn();
|
||||
const fb = vi.fn();
|
||||
const timers: NativeFallbackTimers = {
|
||||
setTimeout: (): unknown => 0,
|
||||
clearTimeout: vi.fn(),
|
||||
};
|
||||
|
||||
scheduleNativeSchemeThenFallback(
|
||||
"javascript:alert(1)",
|
||||
fb,
|
||||
{
|
||||
assignLocationHref: assign,
|
||||
getVisibilityState: (): Document["visibilityState"] => "visible",
|
||||
onVisibilityChange: () => {},
|
||||
offVisibilityChange: () => {},
|
||||
},
|
||||
timers,
|
||||
);
|
||||
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
expect(fb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("scheduleNativeSchemeThenFallback triggers fallback once after timeout when tab stays visible", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const assign = vi.fn();
|
||||
const fb = vi.fn();
|
||||
|
||||
scheduleNativeSchemeThenFallback(
|
||||
SLACK_NATIVE_OPEN_URL,
|
||||
fb,
|
||||
{
|
||||
assignLocationHref: assign,
|
||||
getVisibilityState: (): Document["visibilityState"] => "visible",
|
||||
onVisibilityChange: () => {},
|
||||
offVisibilityChange: () => {},
|
||||
},
|
||||
window as unknown as NativeFallbackTimers,
|
||||
NATIVE_SHARE_FALLBACK_DELAY_MS,
|
||||
);
|
||||
|
||||
expect(assign).toHaveBeenCalledWith(SLACK_NATIVE_OPEN_URL);
|
||||
|
||||
vi.advanceTimersByTime(NATIVE_SHARE_FALLBACK_DELAY_MS - 1);
|
||||
expect(fb).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(10);
|
||||
expect(fb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("scheduleNativeSchemeThenFallback cancels fallback when visibility becomes hidden before timeout", () => {
|
||||
vi.useFakeTimers();
|
||||
let vis: Document["visibilityState"] = "visible";
|
||||
const listeners: (() => void)[] = [];
|
||||
const fb = vi.fn();
|
||||
|
||||
scheduleNativeSchemeThenFallback(
|
||||
DISCORD_NATIVE_DM_HUB_URL,
|
||||
fb,
|
||||
{
|
||||
assignLocationHref: vi.fn(),
|
||||
getVisibilityState: (): Document["visibilityState"] => vis,
|
||||
onVisibilityChange: (l: () => void): void => {
|
||||
listeners.push(l);
|
||||
},
|
||||
offVisibilityChange: (l: () => void): void => {
|
||||
const idx = listeners.indexOf(l);
|
||||
if (idx >= 0) listeners.splice(idx, 1);
|
||||
},
|
||||
},
|
||||
window as unknown as NativeFallbackTimers,
|
||||
NATIVE_SHARE_FALLBACK_DELAY_MS,
|
||||
);
|
||||
|
||||
vis = "hidden";
|
||||
listeners.forEach((l) => l());
|
||||
|
||||
vi.advanceTimersByTime(NATIVE_SHARE_FALLBACK_DELAY_MS + 200);
|
||||
expect(fb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user