Files
WRNexusJS/packages/email-renderer/test/marketing-images.test.ts
T

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 &amp; 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&amp;b=2"');
expect(output).toContain('src="https://example.com/poster.png"');
expect(output).toContain('alt="Demo &amp; &quot;preview&quot;"');
}
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 &amp; connect");
expect(output).toContain('href="https://example.com/facebook?a=1&amp;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);
});