Application:
- IEmailSender.SendHtmlAsync(html, textFallback) - multipart/alternative
с plain-text для клиентов без HTML;
- EmailTemplateRenderer - минимальный mustache-light:
{{key}} (HTML-escape), {{{raw}}} (без escape), {{#key}}...{{/key}}
(условный блок, truthy если не-null/не-пусто/не-"0"/не-"false");
- EmailTemplates - загрузчик embedded HTML-шаблонов из
Resources/EmailTemplates/*.html, кеш after-first-read, parse
"Subject: <тема>" из первой строки, плюс plain-text strip для fallback.
Шаблоны (embedded в food-market.api.dll):
- invite.html - приглашение сотрудника с временным паролем и кнопкой
«Открыть Food Market».
- weekly-summary.html - выручка/чеки/средний чек за неделю + топ-товары.
- low-stock.html - таблица товаров с stock < MinStock.
EmployeesController.Create принимает SendInvite (требует CreateAccount):
формирует payload из orgName/loginUrl/role и шлёт через SendHtmlAsync.
SMTP-ошибки логируются warning'ом, не блокируют создание (showOnce
tempPassword фронту всё равно отдаётся).
Hangfire recurring jobs (EmailNotificationJobs):
- weekly-summary: cron "0 7 * * 1" (понедельник 07:00 UTC) - по каждой
активной орге считает revenue/tx/avgTicket/top-5, шлёт Admin'ам;
- low-stock-alert: cron "0 8 * * *" - товары с sum(stock)<MinStock,
шлёт Admin'ам. AsyncLocal tenant override на каждую орг чтобы
query-filter работал корректно.
Тесты: 8 unit на EmailTemplateRenderer + EmailTemplates (escape, raw,
условные блоки, invite/low-stock-шаблон-loaders). Все 35 unit
зелёные (27 + 8 новых).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
109 lines
3.8 KiB
C#
109 lines
3.8 KiB
C#
using FluentAssertions;
|
||
using foodmarket.Api.Infrastructure.Email;
|
||
using foodmarket.Application.Common.Email;
|
||
using Xunit;
|
||
|
||
namespace foodmarket.UnitTests;
|
||
|
||
public class EmailTemplateRendererTests
|
||
{
|
||
[Fact]
|
||
public void Substitutes_simple_placeholder()
|
||
{
|
||
var r = EmailTemplateRenderer.Render(
|
||
"Привет, {{name}}!",
|
||
new Dictionary<string, string?> { ["name"] = "Алия" });
|
||
r.Should().Be("Привет, Алия!");
|
||
}
|
||
|
||
[Fact]
|
||
public void Escapes_html_in_double_braces()
|
||
{
|
||
var r = EmailTemplateRenderer.Render(
|
||
"<p>{{text}}</p>",
|
||
new Dictionary<string, string?> { ["text"] = "<script>x</script>" });
|
||
r.Should().Be("<p><script>x</script></p>");
|
||
}
|
||
|
||
[Fact]
|
||
public void Triple_braces_render_raw_html()
|
||
{
|
||
var r = EmailTemplateRenderer.Render(
|
||
"<div>{{{table}}}</div>",
|
||
new Dictionary<string, string?> { ["table"] = "<table><tr><td>1</td></tr></table>" });
|
||
r.Should().Be("<div><table><tr><td>1</td></tr></table></div>");
|
||
}
|
||
|
||
[Fact]
|
||
public void Conditional_block_shown_when_truthy()
|
||
{
|
||
var r = EmailTemplateRenderer.Render(
|
||
"Hi{{#bonus}}, бонус: {{bonus}}{{/bonus}}!",
|
||
new Dictionary<string, string?> { ["bonus"] = "100₸" });
|
||
r.Should().Be("Hi, бонус: 100₸!");
|
||
}
|
||
|
||
[Fact]
|
||
public void Conditional_block_hidden_when_falsy()
|
||
{
|
||
// null
|
||
EmailTemplateRenderer.Render(
|
||
"Hi{{#bonus}}, бонус!{{/bonus}}",
|
||
new Dictionary<string, string?> { ["bonus"] = null }).Should().Be("Hi");
|
||
// empty
|
||
EmailTemplateRenderer.Render(
|
||
"Hi{{#bonus}}, бонус!{{/bonus}}",
|
||
new Dictionary<string, string?> { ["bonus"] = "" }).Should().Be("Hi");
|
||
// "0"
|
||
EmailTemplateRenderer.Render(
|
||
"Hi{{#bonus}}, бонус!{{/bonus}}",
|
||
new Dictionary<string, string?> { ["bonus"] = "0" }).Should().Be("Hi");
|
||
}
|
||
|
||
[Fact]
|
||
public void Missing_key_renders_empty()
|
||
{
|
||
EmailTemplateRenderer.Render(
|
||
"Hi, {{nope}}!",
|
||
new Dictionary<string, string?>()).Should().Be("Hi, !");
|
||
}
|
||
|
||
[Fact]
|
||
public void Invite_template_loads_and_substitutes()
|
||
{
|
||
var tpl = new EmailTemplates();
|
||
var (subject, html, text) = tpl.Render("invite", new Dictionary<string, string?>
|
||
{
|
||
["organizationName"] = "ТОО Тест",
|
||
["employeeName"] = "Иван Иванов",
|
||
["email"] = "ivan@test.kz",
|
||
["temporaryPassword"] = "Pass123!",
|
||
["roleName"] = "Кассир",
|
||
["loginUrl"] = "https://admin.test.kz/login",
|
||
});
|
||
subject.Should().Contain("ТОО Тест");
|
||
html.Should().Contain("Иван Иванов");
|
||
html.Should().Contain("Pass123!");
|
||
html.Should().Contain("Кассир");
|
||
html.Should().Contain("https://admin.test.kz/login");
|
||
text.Should().Contain("Иван Иванов");
|
||
text.Should().NotContain("<html>", "plain-text fallback не должен содержать теги");
|
||
}
|
||
|
||
[Fact]
|
||
public void Low_stock_template_uses_raw_html_for_items()
|
||
{
|
||
var tpl = new EmailTemplates();
|
||
var itemsHtml = "<ul><li>Молоко < 10</li></ul>";
|
||
var (_, html, _) = tpl.Render("low-stock", new Dictionary<string, string?>
|
||
{
|
||
["organizationName"] = "ТОО Тест",
|
||
["productCount"] = "3",
|
||
["itemsHtml"] = itemsHtml,
|
||
["stockUrl"] = "https://admin.test.kz/inventory/stock",
|
||
});
|
||
// Тройные фигурные → вставка как есть, без двойного экранирования.
|
||
html.Should().Contain(itemsHtml);
|
||
}
|
||
}
|