Files
paliad/internal/services/mail_service_test.go
m 0e3411c40b feat(admin): /admin/email-templates editor (t-paliad-072)
DB-backed email-template editor for global_admins, replacing the
"Kommt bald" placeholder. Admins can edit invitation, deadline_digest,
and the shared base wrapper for both DE and EN, preview against sample
data, save with versions, and reset to the embedded default.

Backend:
- Migration 026 adds paliad.email_templates (active row per (key, lang))
  and paliad.email_template_versions (append-only, retained 20 deep).
- EmailTemplateService — GetActive falls through to the embedded per-
  language file when no DB row, Save validates parse + structural
  invariants and writes a version, Reset deletes the active row, Restore
  copies a version back. Mutations require DB; reads work without.
- MailService now consults the service for body and subject and falls
  back to the embedded default if the active row is malformed at parse
  time — a corrupt admin save can never wedge the send path.
- Subjects move from Go (buildDigestSubject + inviteSubject) to
  text/template strings stored in the (key, lang) row. Default subjects
  ship with a {{/* keep this phrasing */}} comment pointing at the
  reminder-redesign doc so the SLO framing rationale survives edits.
- Bilingual templates split into per-language files (invitation.de.html
  + .en.html, deadline_digest.de.html + .en.html, base.de.html + .en.html).
  No more {{if eq .Lang}} branching inside templates.
- Handlers under /api/admin/email-templates/* gated by the existing
  RequireAdminFunc(users) admin middleware, same shape as /admin/team.

Frontend:
- /admin/email-templates list page — three cards (one per template),
  each linking to DE + EN editors with their last-modified status.
- /admin/email-templates/{key}?lang=de three-pane editor — subject + body
  textarea + variable docs + actions on the left, sandboxed iframe
  preview + version log on the right. 500 ms debounced live preview;
  save validates server-side (422 on parse error, surfaced inline).
- admin.tsx flips the Email-Templates card from PLANNED to verfügbar.
- 50 new i18n keys (DE + EN) for the editor surface.

Tests: GetActive fallback path, ValidateTemplate happy + sad paths,
SaveRequiresStore on no-DB service, RenderTemplate body + subject
goldens, full SYSTEMAUSFALL/SYSTEM FAILURE subject matrix.

Smoke (knowledge-platform-only run, no DB/auth):
- GET /admin/email-templates → 302 to /login
- GET /api/admin/email-templates → 401
- go build/vet/test clean, bun run build clean

Design: docs/design-email-templates-2026-04-29.md.
2026-04-29 22:09:39 +02:00

199 lines
6.4 KiB
Go

package services
import (
"strings"
"testing"
)
// TestHTMLToText covers the HTML→plain-text fallback. Users with text-only
// clients still need to read reminder/invite mails, and some spam filters
// downrank multipart/alternative when the text part is empty or identical
// to the HTML.
func TestHTMLToText(t *testing.T) {
in := `<html><head><style>b{color:red}</style></head><body>` +
`<h1>Deadline &uuml;berf&auml;llig</h1><p>Hallo <b>Welt</b></p>` +
`<p>Zweite Zeile &mdash; ok.</p><script>alert(1)</script></body></html>`
got := htmlToText(in)
if !strings.Contains(got, "Deadline überfällig") {
t.Errorf("expected decoded umlauts in %q", got)
}
if strings.Contains(got, "alert(1)") {
t.Errorf("script content leaked into text body: %q", got)
}
if strings.Contains(got, "<b>") {
t.Errorf("raw tag remained in text body: %q", got)
}
if !strings.Contains(got, "—") {
t.Errorf("expected em-dash decoded, got %q", got)
}
}
// TestRenderTemplateDeadlineDigest verifies the bundled-digest template
// renders all three category sections, applies the DRINGEND wording on the
// evening slot, and folds in IsOtherOwner labels when a row's owner isn't
// the recipient. A typo in deadline_digest.de.html would fail here before
// any SMTP I/O.
//
// Also asserts that the rendered subject picks up the evening DRINGEND
// framing — the SLO-critical phrasing must survive the template-render
// path, not just the body.
func TestRenderTemplateDeadlineDigest(t *testing.T) {
svc, err := NewMailService()
if err != nil {
t.Fatalf("NewMailService: %v", err)
}
subject, html, err := svc.RenderTemplate(TemplateData{
Lang: "de",
Name: "deadline_digest",
Data: map[string]any{
"Slot": "evening",
"IsEvening": true,
"OverdueCount": 0,
"DueTodayCount": 1,
"DueWarningCount": 0,
"OpenTotal": 1,
"DueToday": []map[string]any{
{
"DueDate": "2026-04-28",
"Title": "Heute fällig",
"ProjectReference": "2026/0002",
"ProjectTitle": "Acme v Gadget",
"OwnerName": "Self",
"IsOtherOwner": false,
"URL": "https://paliad.de/deadlines/b",
},
},
"DeadlinesURL": "https://paliad.de/deadlines",
},
})
if err != nil {
t.Fatalf("RenderTemplate: %v", err)
}
wants := []string{
"Paliad",
"DRINGEND", // evening framing on the due_today section
"Heute fällig", // row title (passed through as data)
"2026/0002",
"Acme v Gadget",
"https://paliad.de/deadlines/b", // due_today link
"https://paliad.de/deadlines", // CTA
}
for _, want := range wants {
if !strings.Contains(html, want) {
t.Errorf("rendered html missing %q", want)
}
}
// "Self" owner name should NOT appear because IsOtherOwner=false suppresses
// the owner line.
if strings.Contains(html, "Self") {
t.Errorf("rendered html should not show OwnerName when IsOtherOwner=false: %q", html)
}
// The DE evening, no-overdue, due_today=1 path should render
// "DRINGEND — 1 heute noch offen".
wantSubject := "[Paliad] DRINGEND — 1 heute noch offen"
if subject != wantSubject {
t.Errorf("subject got %q, want %q", subject, wantSubject)
}
}
// TestRenderTemplateDeadlineDigestSystemausfall covers the worst-case
// subject: evening slot with overdue rows. Must produce SYSTEMAUSFALL
// framing in DE (and SYSTEM FAILURE in EN — covered alongside).
func TestRenderTemplateDeadlineDigestSystemausfall(t *testing.T) {
svc, _ := NewMailService()
for _, tc := range []struct {
name string
lang string
wantSubject string
}{
{"de evening overdue", "de", "[Paliad] SYSTEMAUSFALL: 2 überfällig — plus 1 heute offen"},
{"en evening overdue", "en", "[Paliad] SYSTEM FAILURE: 2 overdue — plus 1 still open today"},
} {
t.Run(tc.name, func(t *testing.T) {
subject, _, err := svc.RenderTemplate(TemplateData{
Lang: tc.lang,
Name: "deadline_digest",
Data: map[string]any{
"Slot": "evening",
"IsEvening": true,
"OverdueCount": 2,
"DueTodayCount": 1,
"DueWarningCount": 0,
"OpenTotal": 1,
"Overdue": []map[string]any{{}, {}},
"DueToday": []map[string]any{{}},
"DeadlinesURL": "https://paliad.de/deadlines",
},
})
if err != nil {
t.Fatalf("RenderTemplate: %v", err)
}
if subject != tc.wantSubject {
t.Errorf("subject got %q, want %q", subject, tc.wantSubject)
}
})
}
}
// TestRenderTemplateInvitation covers the invitation template so a typo in
// invitation.en.html would fail CI.
func TestRenderTemplateInvitation(t *testing.T) {
svc, err := NewMailService()
if err != nil {
t.Fatalf("NewMailService: %v", err)
}
subject, html, err := svc.RenderTemplate(TemplateData{
Lang: "en",
Name: "invitation",
Data: map[string]any{
"InviterName": "Anna Schmidt",
"InviterEmail": "anna@hlc.com",
"ToEmail": "colleague@hlc.com",
"Message": "Have a look at Paliad.",
"RegisterURL": "https://paliad.de/login",
},
})
if err != nil {
t.Fatalf("RenderTemplate: %v", err)
}
for _, want := range []string{
"Anna Schmidt", "invites you", "Have a look at Paliad.",
"https://paliad.de/login", "colleague@hlc.com",
// Branding placeholder: {{.Firm}} should resolve to the configured
// firm name (defaults to "HLC"). Catches accidental deletion of the
// template placeholder when nobody set FIRM_NAME in the test env.
"platform for HLC",
} {
if !strings.Contains(html, want) {
t.Errorf("rendered html missing %q", want)
}
}
if subject != "[Paliad] Anna Schmidt invites you to Paliad" {
t.Errorf("subject got %q", subject)
}
}
// TestBuildMIMEHasBothParts ensures the multipart/alternative structure
// carries both the text and HTML parts — an earlier refactor dropped one
// part by mistake, caught by this.
func TestBuildMIMEHasBothParts(t *testing.T) {
msg := buildMIME("mail@paliad.de", "Paliad", "to@example.com",
"Test", "<p>HTML</p>", "TEXT")
body := string(msg)
if !strings.Contains(body, "Content-Type: text/plain") {
t.Error("missing text/plain part")
}
if !strings.Contains(body, "Content-Type: text/html") {
t.Error("missing text/html part")
}
if !strings.Contains(body, "multipart/alternative") {
t.Error("not multipart/alternative")
}
if !strings.Contains(body, "TEXT") {
t.Error("text body missing")
}
if !strings.Contains(body, "<p>HTML</p>") {
t.Error("html body missing")
}
}