food-market/tests/food-market.UnitTests/EmailTemplateRendererTests.cs
nns 25d92c989a feat(email): HTML-шаблоны MailKit + invite/weekly/low-stock джобы (P1-22)
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>
2026-05-28 16:37:32 +05:00

109 lines
3.8 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>&lt;script&gt;x&lt;/script&gt;</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>Молоко &lt; 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);
}
}