Files
paliad/internal/services/email_template_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

158 lines
4.4 KiB
Go

package services
import (
"context"
"strings"
"testing"
)
// TestGetActiveEmbeddedFallback covers the no-DB path. Without a sqlx.DB,
// every key/lang pair returns the embedded default with IsDefault=true.
func TestGetActiveEmbeddedFallback(t *testing.T) {
svc := NewEmailTemplateService(nil)
for _, key := range CanonicalEmailTemplateKeys {
for _, lang := range EmailTemplateLanguages {
row, err := svc.GetActive(context.Background(), key, lang)
if err != nil {
t.Errorf("GetActive(%s, %s): %v", key, lang, err)
continue
}
if !row.IsDefault {
t.Errorf("GetActive(%s, %s): IsDefault=false on no-DB service", key, lang)
}
if row.Body == "" {
t.Errorf("GetActive(%s, %s): empty body", key, lang)
}
if key == EmailTemplateKeyBase {
if row.Subject != "" {
t.Errorf("base subject expected empty, got %q", row.Subject)
}
} else if row.Subject == "" {
t.Errorf("GetActive(%s, %s): empty subject for non-base key", key, lang)
}
}
}
}
// TestGetActiveUnknownKey ensures unknown keys are 404-shaped.
func TestGetActiveUnknownKey(t *testing.T) {
svc := NewEmailTemplateService(nil)
if _, err := svc.GetActive(context.Background(), "nope", "de"); err != ErrTemplateUnknownKey {
t.Errorf("expected ErrTemplateUnknownKey, got %v", err)
}
}
// TestGetActiveUnknownLang ensures unknown languages are 400-shaped.
func TestGetActiveUnknownLang(t *testing.T) {
svc := NewEmailTemplateService(nil)
if _, err := svc.GetActive(context.Background(), EmailTemplateKeyInvitation, "fr"); err != ErrTemplateUnknownLang {
t.Errorf("expected ErrTemplateUnknownLang, got %v", err)
}
}
// TestSaveRequiresStore checks that mutations against a no-DB service fail
// closed — no silent acceptance, no in-memory drift.
func TestSaveRequiresStore(t *testing.T) {
svc := NewEmailTemplateService(nil)
_, err := svc.Save(context.Background(), SaveInput{
Key: EmailTemplateKeyInvitation,
Lang: "de",
Subject: "x",
Body: `{{define "content"}}<p>x</p>{{end}}`,
})
if err != ErrTemplateStoreUnavailable {
t.Errorf("expected ErrTemplateStoreUnavailable, got %v", err)
}
}
// TestValidateTemplate checks every code path of the validation function.
// Save and the preview endpoint both lean on this — a drift here breaks both.
func TestValidateTemplate(t *testing.T) {
cases := []struct {
name string
key, subj string
body string
wantErr error
wantSubsErr string // substring required in the wrapped error
}{
{
"invitation valid",
EmailTemplateKeyInvitation,
"[Paliad] {{.InviterName}} invites you",
`{{define "content"}}<h1>{{.InviterName}}</h1>{{end}}`,
nil, "",
},
{
"invitation missing content block",
EmailTemplateKeyInvitation,
"x",
`<p>oops, no define</p>`,
ErrTemplateMissingContent, "",
},
{
"invitation bad body syntax",
EmailTemplateKeyInvitation,
"x",
`{{define "content"}}<p>{{.InviterName}{{end}}`,
nil, "syntax",
},
{
"invitation bad subject syntax",
EmailTemplateKeyInvitation,
`[Paliad] {{.InviterName`,
`{{define "content"}}<p>x</p>{{end}}`,
nil, "syntax",
},
{
"base valid",
EmailTemplateKeyBase,
"",
`<html><body>{{block "content" .}}{{end}}</body></html>`,
nil, "",
},
{
"base missing block call",
EmailTemplateKeyBase,
"",
`<html><body>no inner</body></html>`,
ErrTemplateMissingBaseBlock, "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateTemplate(tc.key, tc.subj, tc.body)
if tc.wantErr != nil {
if err != tc.wantErr {
t.Errorf("got %v, want %v", err, tc.wantErr)
}
return
}
if tc.wantSubsErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantSubsErr) {
t.Errorf("got %v, want substring %q", err, tc.wantSubsErr)
}
return
}
if err != nil {
t.Errorf("expected no error, got %v", err)
}
})
}
}
// TestEmailTemplateVariablesShape ensures every canonical key has a non-empty
// variable contract — the editor sidebar would render an empty box otherwise.
func TestEmailTemplateVariablesShape(t *testing.T) {
for _, key := range CanonicalEmailTemplateKeys {
vars := EmailTemplateVariables(key)
if len(vars) == 0 {
t.Errorf("key %s: no variables registered", key)
}
for _, v := range vars {
if v.Name == "" || v.Type == "" || v.Description == "" {
t.Errorf("key %s: variable %+v has empty field", key, v)
}
}
}
}