import { assert, assertStringIncludes } from "@std/assert"; import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { stub } from "@std/testing/mock"; import { Liquid } from "liquidjs"; import { ALL_SETTINGS_KEYS, settings } from "#db/settings.ts"; import { renderEmailContent } from "#shared/email-renderer.ts"; import { expectIncludesAll } from "#test-utils/assertions.ts"; import { makeTestEntry as makeEntry } from "#test-utils/factories.ts"; import { buildTestData, describeEmailRenderer, renderConfirmation, } from "./test-helpers.ts"; describeEmailRenderer(() => { describe("renderEmailContent", () => { test("uses default templates when no custom templates are set", async () => { const { result } = await renderConfirmation(); assertStringIncludes(result.subject, "Test Listing"); expectIncludesAll(result.html, [ "Test Listing", "https://example.com/t/ABC", ]); expectIncludesAll(result.text, [ "Test Listing", "https://example.com/t/ABC", ]); }); test("uses custom templates when set", async () => { await settings.update.email.template( "confirmation", "subject", "Custom: {{ listing_names }}", ); await settings.update.email.template( "confirmation", "html", "Custom HTML for {{ attendee.name }}", ); await settings.update.email.template( "confirmation", "text", "Custom text for {{ attendee.name }}", ); const { result } = await renderConfirmation(); expect(result.subject).toBe("Custom: Test Listing"); expect(result.html).toBe("Custom HTML for Jane Doe"); expect(result.text).toBe("Custom text for Jane Doe"); }); test("falls back to default on custom template render error", async () => { await settings.update.email.template( "confirmation", "subject", "{{ invalid | nonexistent_filter }}", ); const { result } = await renderConfirmation(); assertStringIncludes(result.subject, "Test Listing"); expect(result.errors).toHaveLength(1); expect(result.errors[0]).toBeInstanceOf(Error); }); test("renders admin notification defaults correctly", async () => { const data = await buildTestData([makeEntry()]); const result = await renderEmailContent("admin", data); expectIncludesAll(result.subject, ["Jane Doe", "Test Listing"]); assertStringIncludes(result.html, "Jane Doe"); assertStringIncludes(result.text, "Name: Jane Doe"); }); test("renders admin notification with contact details", async () => { const data = await buildTestData([ makeEntry( {}, { address: "123 Main St", phone: "555-1234", special_instructions: "Wheelchair", }, ), ]); const result = await renderEmailContent("admin", data); expectIncludesAll(result.text, [ "Phone: 555-1234", "Address: 123 Main St", "Notes: Wheelchair", ]); }); test("escapes HTML and script-like values in booking fields", async () => { const data = await buildTestData([ makeEntry( {}, { address: `7 Elm St & "Sign"`, email: "jane@example.com", name: `Jane Doe`, phone: "", special_instructions: "", }, ), ]); const admin = await renderEmailContent("admin", data); const { html } = admin; assert(!html.includes(" Doe`, ); assertStringIncludes( admin.text, `Name: Jane Doe`, ); }); test("escapes booking fields in a custom HTML template", async () => { await settings.update.email.template( "admin", "html", "Booked for {{ attendee.name }} ({{ attendee.phone }})", ); const data = await buildTestData([ makeEntry({}, { name: "Hostile", phone: "555" }), ]); const result = await renderEmailContent("admin", data); expect(result.html).toBe( "Booked for <b>Hostile</b> (<i>555</i>)", ); }); test("falls back to the default template when a custom HTML template uses raw", async () => { await settings.update.email.template( "admin", "html", "{{ attendee.name | raw }}", ); const data = await buildTestData([ makeEntry({}, { name: "Hostile" }), ]); const result = await renderEmailContent("admin", data); assert(!result.html.includes("Hostile")); assertStringIncludes(result.html, "<b>Hostile</b>"); expect(result.errors).toHaveLength(1); }); test("omits empty contact fields in admin notification", async () => { const data = await buildTestData([ makeEntry({}, { address: "", phone: "", special_instructions: "" }), ]); const result = await renderEmailContent("admin", data); assert(!result.text.includes("Phone:")); assert(!result.text.includes("Address:")); assert(!result.text.includes("Notes:")); }); test("renders paid listing with currency in confirmation", async () => { const data = await buildTestData([ makeEntry({ unit_price: 1000 }, { price_paid: "2000", quantity: 2 }), ]); const result = await renderEmailContent("confirmation", data); assertStringIncludes(result.html, "£20"); assertStringIncludes(result.text, "£20"); }); test("shows the amount owed when a balance is outstanding", async () => { const data = await buildTestData([ makeEntry( { unit_price: 1000 }, { price_paid: "0", remaining_balance: 2000 }, ), ]); const confirmation = await renderEmailContent("confirmation", data); const admin = await renderEmailContent("admin", data); expectIncludesAll(confirmation.html, ["Amount owed", "£20"]); assertStringIncludes(confirmation.text, "Amount owed: £20"); assertStringIncludes(admin.html, "Amount owed"); }); test("omits the amount owed when the booking is fully paid", async () => { const data = await buildTestData([ makeEntry( { unit_price: 1000 }, { price_paid: "1000", remaining_balance: 0 }, ), ]); const result = await renderEmailContent("confirmation", data); assert(!result.html.includes("Amount owed")); assert(!result.text.includes("Amount owed")); }); test("shows date when attendee has date", async () => { const data = await buildTestData([makeEntry({}, { date: "2026-07-15" })]); const result = await renderEmailContent("confirmation", data); assertStringIncludes(result.html, "2026-07-15"); assertStringIncludes(result.text, "2026-07-15"); }); test("uses mix of custom and default parts", async () => { await settings.update.email.template( "confirmation", "subject", "Custom Subject: {{ listing_names }}", ); const { result } = await renderConfirmation(); expect(result.subject).toBe("Custom Subject: Test Listing"); assertStringIncludes(result.html, "Thanks for registering!"); assertStringIncludes(result.text, "Thanks for registering!"); }); test("collects non-Error thrown values", async () => { await settings.update.email.template( "confirmation", "subject", "Custom {{ listing_names }}", ); settings.invalidateCache(); await settings.loadKeys(ALL_SETTINGS_KEYS); let callCount = 0; const original = Liquid.prototype.parseAndRender; const parseAndRenderStub = stub( Liquid.prototype, "parseAndRender", function ( this: InstanceType, ...args: Parameters ) { if (callCount++ === 0) throw "string error value"; return original.apply(this, args); }, ); try { const data = await buildTestData([makeEntry()]); const result = await renderEmailContent("confirmation", data); expect(result.errors).toEqual(["string error value"]); } finally { parseAndRenderStub.restore(); } }); test("resets to defaults after clearing custom template", async () => { await settings.update.email.template( "confirmation", "subject", "Custom Subject", ); settings.invalidateCache(); await settings.loadKeys(ALL_SETTINGS_KEYS); await settings.update.email.template("confirmation", "subject", ""); const { result } = await renderConfirmation(); assertStringIncludes(result.subject, "Test Listing"); }); }); });