219 lines
7.7 KiB
TypeScript
219 lines
7.7 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import type { BlockDocument } from "@wrnexus/blocks";
|
|
import { renderEmail, validateMjml } from "../src/index.ts";
|
|
|
|
function document(type: string, props: Record<string, string>): BlockDocument {
|
|
return {
|
|
schemaVersion: 1,
|
|
kind: "email",
|
|
meta: { subject: "Store update", lang: "en" },
|
|
theme: { tokens: {} },
|
|
root: {
|
|
id: "root",
|
|
type: "layout.email-document",
|
|
version: 1,
|
|
props: { width: "600" },
|
|
styles: {},
|
|
slots: {
|
|
content: [
|
|
{
|
|
id: "section",
|
|
type: "layout.section",
|
|
version: 1,
|
|
props: { background: "#ffffff", padding: "24px" },
|
|
styles: {},
|
|
slots: { content: [{ id: "item", type, version: 1, props, styles: {}, slots: {} }] },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
test("product and RSS images survive HTML, AMP and strict MJML exports", async () => {
|
|
for (const type of ["commerce.product", "content.feed-item"]) {
|
|
const props: Record<string, string> =
|
|
type === "commerce.product"
|
|
? {
|
|
title: "Tea & coffee",
|
|
image: "https://example.com/product.png",
|
|
href: "https://example.com/product",
|
|
price: "$12.00",
|
|
buttonLabel: "Shop now",
|
|
width: "320",
|
|
height: "180",
|
|
}
|
|
: {
|
|
title: "Tea & coffee",
|
|
image: "https://example.com/article.png",
|
|
href: "https://example.com/article",
|
|
summary: "Latest news",
|
|
width: "320",
|
|
height: "180",
|
|
};
|
|
const result = await renderEmail(document(type, props), { amp: true, mjml: true });
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) continue;
|
|
expect(result.value.parts.html.content).toContain("width:100%;max-width:320px;");
|
|
for (const output of [
|
|
result.value.parts.html.content,
|
|
result.value.parts.amp?.content,
|
|
result.value.exports?.mjml,
|
|
]) {
|
|
expect(output).toContain(props.image);
|
|
expect(output).toContain(props.href);
|
|
expect(output).toContain("Tea & coffee");
|
|
if (type === "commerce.product") expect(output).toContain("$12.00");
|
|
}
|
|
expect(result.value.parts.amp?.validation.valid).toBe(true);
|
|
expect(result.value.parts.amp?.content).toContain(
|
|
'width="320" height="180" layout="responsive"',
|
|
);
|
|
expect(result.value.parts.amp?.content).toContain(".marketing-image img{object-fit:contain}");
|
|
expect(await validateMjml(result.value.exports!.mjml!)).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test("image-free RSS and older product documents remain renderable", async () => {
|
|
const rss = await renderEmail(
|
|
document("content.feed-item", {
|
|
title: "News",
|
|
summary: "Update",
|
|
href: "https://example.com",
|
|
image: "",
|
|
}),
|
|
{ amp: true, mjml: true },
|
|
);
|
|
expect(rss.ok).toBe(true);
|
|
if (rss.ok) expect(rss.value.parts.amp?.content).not.toContain("<amp-img");
|
|
const product = await renderEmail(
|
|
document("commerce.product", {
|
|
title: "Product",
|
|
price: "12",
|
|
image: "https://example.com/p.png",
|
|
href: "https://example.com",
|
|
}),
|
|
{ amp: true },
|
|
);
|
|
expect(product.ok).toBe(true);
|
|
if (product.ok) expect(product.value.parts.amp?.content).toContain('width="600" height="400"');
|
|
});
|
|
|
|
test("marketing image dimensions reject invalid or injected values", async () => {
|
|
for (const width of ["0", "4001", "100%", '600" onerror="alert(1)']) {
|
|
const result = await renderEmail(
|
|
document("commerce.product", {
|
|
title: "Product",
|
|
price: "12",
|
|
image: "https://example.com/p.png",
|
|
href: "https://example.com",
|
|
width,
|
|
}),
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
}
|
|
});
|
|
|
|
test("video exports preserve thumbnail proportions, safe destination and accessible text", async () => {
|
|
const draft = document("content.video", {
|
|
title: "Watch & learn",
|
|
href: "https://example.com/watch?a=1&b=2",
|
|
thumbnail: "https://example.com/poster.png",
|
|
alt: 'Demo & "preview"',
|
|
width: "360",
|
|
height: "240",
|
|
});
|
|
const before = JSON.stringify(draft);
|
|
const result = await renderEmail(draft, { amp: true, mjml: true });
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
for (const output of [
|
|
result.value.parts.html.content,
|
|
result.value.parts.amp?.content,
|
|
result.value.exports?.mjml,
|
|
]) {
|
|
expect(output).toContain('href="https://example.com/watch?a=1&b=2"');
|
|
expect(output).toContain('src="https://example.com/poster.png"');
|
|
expect(output).toContain('alt="Demo & "preview""');
|
|
}
|
|
expect(result.value.parts.html.content).toContain('width="360" height="240"');
|
|
expect(result.value.parts.amp?.content).toContain('width="360" height="240" layout="responsive"');
|
|
expect(result.value.exports?.mjml).toContain('width="360px" height="240px"');
|
|
expect(result.value.parts.text.content).toContain(
|
|
"Watch & learn: https://example.com/watch?a=1&b=2",
|
|
);
|
|
expect(result.value.parts.amp?.validation.valid).toBe(true);
|
|
expect(await validateMjml(result.value.exports!.mjml!)).toEqual([]);
|
|
expect(JSON.stringify(draft)).toBe(before);
|
|
});
|
|
|
|
test("social exports include only configured networks and retain safe links in plain text", async () => {
|
|
const props = {
|
|
label: "Follow & connect",
|
|
facebook: "https://example.com/facebook?a=1&b=2",
|
|
linkedin: "https://example.com/linkedin",
|
|
instagram: "",
|
|
x: "",
|
|
};
|
|
const result = await renderEmail(document("content.social", props), { amp: true, mjml: true });
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
for (const output of [
|
|
result.value.parts.html.content,
|
|
result.value.parts.amp?.content,
|
|
result.value.exports?.mjml,
|
|
]) {
|
|
expect(output).toContain("Follow & connect");
|
|
expect(output).toContain('href="https://example.com/facebook?a=1&b=2"');
|
|
expect(output).toContain('href="https://example.com/linkedin"');
|
|
expect(output).not.toContain(">Instagram</a>");
|
|
expect(output).not.toContain(">X</a>");
|
|
}
|
|
expect(result.value.parts.text.content).toContain(
|
|
"Facebook: https://example.com/facebook?a=1&b=2",
|
|
);
|
|
expect(result.value.parts.text.content).toContain("LinkedIn: https://example.com/linkedin");
|
|
expect(result.value.parts.amp?.validation.valid).toBe(true);
|
|
expect(await validateMjml(result.value.exports!.mjml!)).toEqual([]);
|
|
expect(
|
|
(await renderEmail(document("content.social", { ...props, facebook: "javascript:alert(1)" })))
|
|
.ok,
|
|
).toBe(false);
|
|
});
|
|
|
|
test("basic images, video thumbnails and spacers validate dimensions before rendering", async () => {
|
|
const cases: Array<{ type: string; props: Record<string, string> }> = [
|
|
{ type: "content.image", props: { src: "https://example.com/image.png", alt: "Image" } },
|
|
{
|
|
type: "content.video",
|
|
props: {
|
|
href: "https://example.com/video",
|
|
thumbnail: "https://example.com/image.png",
|
|
title: "Video",
|
|
alt: "Video",
|
|
},
|
|
},
|
|
{ type: "content.spacer", props: {} },
|
|
];
|
|
for (const block of cases) {
|
|
for (const height of ["-1", "4001", "100%", "24;position:fixed", '24" onload="bad', "1e3"]) {
|
|
const result = await renderEmail(
|
|
document(block.type, { ...block.props, height } as Record<string, string>),
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
}
|
|
const valid = await renderEmail(
|
|
document(block.type, { ...block.props, height: "4000" } as Record<string, string>),
|
|
);
|
|
expect(valid.ok).toBe(true);
|
|
}
|
|
expect((await renderEmail(document("content.spacer", { height: "0" }))).ok).toBe(true);
|
|
expect(
|
|
(
|
|
await renderEmail(
|
|
document("content.image", { src: "https://example.com/image.png", height: "0" }),
|
|
)
|
|
).ok,
|
|
).toBe(false);
|
|
});
|