feat: mail service
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
// Package templates provides the filesystem-backed template catalog used by
|
||||
// Mail Service.
|
||||
package templates
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
htmltemplate "html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
texttemplate "text/template"
|
||||
"text/template/parse"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
templatedomain "galaxy/mail/internal/domain/template"
|
||||
)
|
||||
|
||||
const (
|
||||
subjectTemplateFile = "subject.tmpl"
|
||||
textTemplateFile = "text.tmpl"
|
||||
htmlTemplateFile = "html.tmpl"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrTemplateNotFound reports that no template family exists for the
|
||||
// requested template identifier.
|
||||
ErrTemplateNotFound = errors.New("template catalog template not found")
|
||||
|
||||
// ErrFallbackMissing reports that the requested locale is unavailable and
|
||||
// the mandatory `en` fallback variant is also missing.
|
||||
ErrFallbackMissing = errors.New("template catalog fallback locale missing")
|
||||
|
||||
// ErrTemplateParseFailed reports that one filesystem template file could
|
||||
// not be parsed into the in-memory registry.
|
||||
ErrTemplateParseFailed = errors.New("template catalog template parse failed")
|
||||
|
||||
requiredStartupTemplate = templateKey{
|
||||
TemplateID: common.TemplateID("auth.login_code"),
|
||||
Locale: common.Locale("en"),
|
||||
}
|
||||
)
|
||||
|
||||
// Catalog stores the immutable in-memory template registry built at process
|
||||
// startup.
|
||||
type Catalog struct {
|
||||
rootDir string
|
||||
templates map[templateKey]*compiledTemplate
|
||||
availableLocales map[common.TemplateID][]common.Locale
|
||||
}
|
||||
|
||||
// ResolvedTemplate stores one resolved template variant together with lookup
|
||||
// metadata such as locale fallback usage and required variable paths.
|
||||
type ResolvedTemplate struct {
|
||||
record templatedomain.Template
|
||||
resolvedLocale common.Locale
|
||||
localeFallbackUsed bool
|
||||
requiredVariablePaths []string
|
||||
subject *texttemplate.Template
|
||||
text *texttemplate.Template
|
||||
html *htmltemplate.Template
|
||||
}
|
||||
|
||||
type templateKey struct {
|
||||
TemplateID common.TemplateID
|
||||
Locale common.Locale
|
||||
}
|
||||
|
||||
type compiledTemplate struct {
|
||||
record templatedomain.Template
|
||||
requiredVariablePaths []string
|
||||
subject *texttemplate.Template
|
||||
text *texttemplate.Template
|
||||
html *htmltemplate.Template
|
||||
}
|
||||
|
||||
type templateSources struct {
|
||||
TemplateID common.TemplateID
|
||||
Locale common.Locale
|
||||
Subject string
|
||||
Text string
|
||||
HTML string
|
||||
}
|
||||
|
||||
// NewCatalog constructs Catalog for rootDir, parses the full template
|
||||
// registry, and validates the mandatory auth login-code fallback template.
|
||||
func NewCatalog(rootDir string) (*Catalog, error) {
|
||||
if strings.TrimSpace(rootDir) == "" {
|
||||
return nil, fmt.Errorf("new template catalog: root dir must not be empty")
|
||||
}
|
||||
|
||||
cleanRootDir := filepath.Clean(rootDir)
|
||||
info, err := os.Stat(cleanRootDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new template catalog: stat root dir %q: %w", cleanRootDir, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("new template catalog: root dir %q must be a directory", cleanRootDir)
|
||||
}
|
||||
|
||||
registry, availableLocales, err := loadRegistry(cleanRootDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new template catalog: %w", err)
|
||||
}
|
||||
if _, ok := registry[requiredStartupTemplate]; !ok {
|
||||
return nil, fmt.Errorf(
|
||||
"new template catalog: required template %q locale %q is missing",
|
||||
requiredStartupTemplate.TemplateID,
|
||||
requiredStartupTemplate.Locale,
|
||||
)
|
||||
}
|
||||
|
||||
return &Catalog{
|
||||
rootDir: cleanRootDir,
|
||||
templates: registry,
|
||||
availableLocales: availableLocales,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RootDir returns the configured template catalog root directory.
|
||||
func (catalog *Catalog) RootDir() string {
|
||||
if catalog == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return catalog.rootDir
|
||||
}
|
||||
|
||||
// Lookup resolves one template family for locale, applying the frozen exact
|
||||
// match followed by `en` fallback rule.
|
||||
func (catalog *Catalog) Lookup(templateID common.TemplateID, locale common.Locale) (ResolvedTemplate, error) {
|
||||
if catalog == nil {
|
||||
return ResolvedTemplate{}, errors.New("lookup template: nil catalog")
|
||||
}
|
||||
if err := templateID.Validate(); err != nil {
|
||||
return ResolvedTemplate{}, fmt.Errorf("lookup template: template id: %w", err)
|
||||
}
|
||||
if err := locale.Validate(); err != nil {
|
||||
return ResolvedTemplate{}, fmt.Errorf("lookup template: locale: %w", err)
|
||||
}
|
||||
|
||||
exactKey := templateKey{TemplateID: templateID, Locale: locale}
|
||||
if compiled, ok := catalog.templates[exactKey]; ok {
|
||||
return compiled.resolve(false), nil
|
||||
}
|
||||
|
||||
fallbackKey := templateKey{TemplateID: templateID, Locale: common.Locale("en")}
|
||||
if compiled, ok := catalog.templates[fallbackKey]; ok {
|
||||
return compiled.resolve(true), nil
|
||||
}
|
||||
|
||||
if _, ok := catalog.availableLocales[templateID]; ok {
|
||||
return ResolvedTemplate{}, fmt.Errorf(
|
||||
"lookup template %q locale %q: %w",
|
||||
templateID,
|
||||
locale,
|
||||
ErrFallbackMissing,
|
||||
)
|
||||
}
|
||||
|
||||
return ResolvedTemplate{}, fmt.Errorf(
|
||||
"lookup template %q locale %q: %w",
|
||||
templateID,
|
||||
locale,
|
||||
ErrTemplateNotFound,
|
||||
)
|
||||
}
|
||||
|
||||
// Template returns the resolved logical template record.
|
||||
func (resolved ResolvedTemplate) Template() templatedomain.Template {
|
||||
return resolved.record
|
||||
}
|
||||
|
||||
// ResolvedLocale returns the filesystem locale variant that will actually be
|
||||
// executed.
|
||||
func (resolved ResolvedTemplate) ResolvedLocale() common.Locale {
|
||||
return resolved.resolvedLocale
|
||||
}
|
||||
|
||||
// LocaleFallbackUsed reports whether lookup fell back from the requested
|
||||
// locale to `en`.
|
||||
func (resolved ResolvedTemplate) LocaleFallbackUsed() bool {
|
||||
return resolved.localeFallbackUsed
|
||||
}
|
||||
|
||||
// RequiredVariablePaths returns the sorted list of dot-path variables used by
|
||||
// the resolved template variant.
|
||||
func (resolved ResolvedTemplate) RequiredVariablePaths() []string {
|
||||
return append([]string(nil), resolved.requiredVariablePaths...)
|
||||
}
|
||||
|
||||
// ExecuteSubject executes the resolved subject template with data.
|
||||
func (resolved ResolvedTemplate) ExecuteSubject(data any) (string, error) {
|
||||
return executeTextTemplate("subject", resolved.subject, data)
|
||||
}
|
||||
|
||||
// ExecuteText executes the resolved plaintext body template with data.
|
||||
func (resolved ResolvedTemplate) ExecuteText(data any) (string, error) {
|
||||
return executeTextTemplate("text", resolved.text, data)
|
||||
}
|
||||
|
||||
// ExecuteHTML executes the resolved HTML body template with data. The second
|
||||
// return value reports whether the resolved variant contains HTML content.
|
||||
func (resolved ResolvedTemplate) ExecuteHTML(data any) (string, bool, error) {
|
||||
if resolved.html == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
rendered, err := executeHTMLTemplate("html", resolved.html, data)
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
|
||||
return rendered, true, nil
|
||||
}
|
||||
|
||||
func loadRegistry(rootDir string) (map[templateKey]*compiledTemplate, map[common.TemplateID][]common.Locale, error) {
|
||||
sourceBundles := make(map[templateKey]*templateSources)
|
||||
|
||||
if err := filepath.WalkDir(rootDir, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
|
||||
relativePath, err := filepath.Rel(rootDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if relativePath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
relativePath = filepath.ToSlash(relativePath)
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(relativePath, "/")
|
||||
if len(parts) != 3 {
|
||||
return fmt.Errorf("invalid template path %q: expected <template_id>/<locale>/<file>", relativePath)
|
||||
}
|
||||
|
||||
templateID := common.TemplateID(parts[0])
|
||||
if err := templateID.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid template path %q: %w", relativePath, err)
|
||||
}
|
||||
|
||||
locale, err := common.ParseLocale(parts[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid template path %q: %w", relativePath, err)
|
||||
}
|
||||
|
||||
contentsBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read template file %q: %w", path, err)
|
||||
}
|
||||
|
||||
key := templateKey{TemplateID: templateID, Locale: locale}
|
||||
bundle := sourceBundles[key]
|
||||
if bundle == nil {
|
||||
bundle = &templateSources{
|
||||
TemplateID: templateID,
|
||||
Locale: locale,
|
||||
}
|
||||
sourceBundles[key] = bundle
|
||||
}
|
||||
|
||||
switch parts[2] {
|
||||
case subjectTemplateFile:
|
||||
if bundle.Subject != "" {
|
||||
return fmt.Errorf("duplicate template subject for %q locale %q", templateID, locale)
|
||||
}
|
||||
bundle.Subject = string(contentsBytes)
|
||||
case textTemplateFile:
|
||||
if bundle.Text != "" {
|
||||
return fmt.Errorf("duplicate template text body for %q locale %q", templateID, locale)
|
||||
}
|
||||
bundle.Text = string(contentsBytes)
|
||||
case htmlTemplateFile:
|
||||
if bundle.HTML != "" {
|
||||
return fmt.Errorf("duplicate template html body for %q locale %q", templateID, locale)
|
||||
}
|
||||
bundle.HTML = string(contentsBytes)
|
||||
default:
|
||||
return fmt.Errorf("invalid template path %q: unsupported file name %q", relativePath, parts[2])
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
registry := make(map[templateKey]*compiledTemplate, len(sourceBundles))
|
||||
availableLocales := make(map[common.TemplateID][]common.Locale)
|
||||
|
||||
for key, bundle := range sourceBundles {
|
||||
compiled, err := compileTemplate(*bundle)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
registry[key] = compiled
|
||||
availableLocales[key.TemplateID] = append(availableLocales[key.TemplateID], key.Locale)
|
||||
}
|
||||
|
||||
for templateID := range availableLocales {
|
||||
sort.Slice(availableLocales[templateID], func(left int, right int) bool {
|
||||
return availableLocales[templateID][left].String() < availableLocales[templateID][right].String()
|
||||
})
|
||||
}
|
||||
|
||||
return registry, availableLocales, nil
|
||||
}
|
||||
|
||||
func compileTemplate(source templateSources) (*compiledTemplate, error) {
|
||||
if source.Subject == "" {
|
||||
return nil, fmt.Errorf("template %q locale %q is missing %s", source.TemplateID, source.Locale, subjectTemplateFile)
|
||||
}
|
||||
if source.Text == "" {
|
||||
return nil, fmt.Errorf("template %q locale %q is missing %s", source.TemplateID, source.Locale, textTemplateFile)
|
||||
}
|
||||
|
||||
subject, err := parseText(source.TemplateID, source.Locale, "subject", source.Subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
textBody, err := parseText(source.TemplateID, source.Locale, "text", source.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var htmlBody *htmltemplate.Template
|
||||
if source.HTML != "" {
|
||||
htmlBody, err = parseHTML(source.TemplateID, source.Locale, "html", source.HTML)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
record := templatedomain.Template{
|
||||
TemplateID: source.TemplateID,
|
||||
Locale: source.Locale,
|
||||
SubjectTemplate: source.Subject,
|
||||
TextTemplate: source.Text,
|
||||
HTMLTemplate: source.HTML,
|
||||
Version: computeVersion(source),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("compile template %q locale %q: %w", source.TemplateID, source.Locale, err)
|
||||
}
|
||||
|
||||
requiredVariablePaths := collectRequiredVariablePaths(subject.Tree, textBody.Tree)
|
||||
if htmlBody != nil {
|
||||
requiredVariablePaths = mergeRequiredVariablePaths(requiredVariablePaths, collectRequiredVariablePaths(htmlBody.Tree))
|
||||
}
|
||||
|
||||
return &compiledTemplate{
|
||||
record: record,
|
||||
requiredVariablePaths: requiredVariablePaths,
|
||||
subject: subject,
|
||||
text: textBody,
|
||||
html: htmlBody,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseText(templateID common.TemplateID, locale common.Locale, part string, source string) (*texttemplate.Template, error) {
|
||||
parsed, err := texttemplate.New(part).Option("missingkey=error").Parse(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"parse template %q locale %q part %q: %w: %v",
|
||||
templateID,
|
||||
locale,
|
||||
part,
|
||||
ErrTemplateParseFailed,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseHTML(templateID common.TemplateID, locale common.Locale, part string, source string) (*htmltemplate.Template, error) {
|
||||
parsed, err := htmltemplate.New(part).Option("missingkey=error").Parse(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"parse template %q locale %q part %q: %w: %v",
|
||||
templateID,
|
||||
locale,
|
||||
part,
|
||||
ErrTemplateParseFailed,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func computeVersion(source templateSources) string {
|
||||
sum := sha256.New()
|
||||
for _, part := range []string{
|
||||
source.TemplateID.String(),
|
||||
source.Locale.String(),
|
||||
source.Subject,
|
||||
source.Text,
|
||||
source.HTML,
|
||||
} {
|
||||
_, _ = sum.Write([]byte(part))
|
||||
_, _ = sum.Write([]byte{0})
|
||||
}
|
||||
|
||||
return "sha256:" + hex.EncodeToString(sum.Sum(nil))
|
||||
}
|
||||
|
||||
func (compiled *compiledTemplate) resolve(localeFallbackUsed bool) ResolvedTemplate {
|
||||
return ResolvedTemplate{
|
||||
record: compiled.record,
|
||||
resolvedLocale: compiled.record.Locale,
|
||||
localeFallbackUsed: localeFallbackUsed,
|
||||
requiredVariablePaths: append([]string(nil), compiled.requiredVariablePaths...),
|
||||
subject: compiled.subject,
|
||||
text: compiled.text,
|
||||
html: compiled.html,
|
||||
}
|
||||
}
|
||||
|
||||
func executeTextTemplate(name string, tmpl *texttemplate.Template, data any) (string, error) {
|
||||
if tmpl == nil {
|
||||
return "", fmt.Errorf("execute %s template: nil template", name)
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
if err := tmpl.Execute(&builder, data); err != nil {
|
||||
return "", fmt.Errorf("execute %s template: %w", name, err)
|
||||
}
|
||||
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
func executeHTMLTemplate(name string, tmpl *htmltemplate.Template, data any) (string, error) {
|
||||
if tmpl == nil {
|
||||
return "", fmt.Errorf("execute %s template: nil template", name)
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
if err := tmpl.Execute(&builder, data); err != nil {
|
||||
return "", fmt.Errorf("execute %s template: %w", name, err)
|
||||
}
|
||||
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
func collectRequiredVariablePaths(trees ...*parse.Tree) []string {
|
||||
paths := make(map[string]struct{})
|
||||
|
||||
for _, tree := range trees {
|
||||
if tree == nil || tree.Root == nil {
|
||||
continue
|
||||
}
|
||||
collectNodePaths(tree.Root, nil, paths)
|
||||
}
|
||||
|
||||
collected := make([]string, 0, len(paths))
|
||||
for path := range paths {
|
||||
collected = append(collected, path)
|
||||
}
|
||||
sort.Strings(collected)
|
||||
|
||||
return collected
|
||||
}
|
||||
|
||||
func mergeRequiredVariablePaths(existing []string, additional []string) []string {
|
||||
merged := make(map[string]struct{}, len(existing)+len(additional))
|
||||
for _, path := range existing {
|
||||
merged[path] = struct{}{}
|
||||
}
|
||||
for _, path := range additional {
|
||||
merged[path] = struct{}{}
|
||||
}
|
||||
|
||||
combined := make([]string, 0, len(merged))
|
||||
for path := range merged {
|
||||
combined = append(combined, path)
|
||||
}
|
||||
sort.Strings(combined)
|
||||
|
||||
return combined
|
||||
}
|
||||
|
||||
func collectNodePaths(node parse.Node, scope []string, paths map[string]struct{}) {
|
||||
switch typed := node.(type) {
|
||||
case *parse.ListNode:
|
||||
if typed == nil {
|
||||
return
|
||||
}
|
||||
for _, child := range typed.Nodes {
|
||||
collectNodePaths(child, scope, paths)
|
||||
}
|
||||
case *parse.ActionNode:
|
||||
collectPipePaths(typed.Pipe, scope, paths)
|
||||
case *parse.IfNode:
|
||||
collectPipePaths(typed.Pipe, scope, paths)
|
||||
collectNodePaths(typed.List, scope, paths)
|
||||
collectNodePaths(typed.ElseList, scope, paths)
|
||||
case *parse.RangeNode:
|
||||
collectPipePaths(typed.Pipe, scope, paths)
|
||||
collectNodePaths(typed.List, scopeForPipe(typed.Pipe, scope), paths)
|
||||
collectNodePaths(typed.ElseList, scope, paths)
|
||||
case *parse.WithNode:
|
||||
collectPipePaths(typed.Pipe, scope, paths)
|
||||
collectNodePaths(typed.List, scopeForPipe(typed.Pipe, scope), paths)
|
||||
collectNodePaths(typed.ElseList, scope, paths)
|
||||
case *parse.TemplateNode:
|
||||
collectPipePaths(typed.Pipe, scope, paths)
|
||||
}
|
||||
}
|
||||
|
||||
func collectPipePaths(pipe *parse.PipeNode, scope []string, paths map[string]struct{}) {
|
||||
if pipe == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, command := range pipe.Cmds {
|
||||
for _, arg := range command.Args {
|
||||
path, ok := nodePath(arg, scope)
|
||||
if !ok || len(path) == 0 {
|
||||
continue
|
||||
}
|
||||
paths[strings.Join(path, ".")] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scopeForPipe(pipe *parse.PipeNode, scope []string) []string {
|
||||
if pipe == nil || len(pipe.Cmds) != 1 || len(pipe.Cmds[0].Args) != 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
path, ok := nodePath(pipe.Cmds[0].Args[0], scope)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func nodePath(node parse.Node, scope []string) ([]string, bool) {
|
||||
switch typed := node.(type) {
|
||||
case *parse.FieldNode:
|
||||
return appendPath(scope, typed.Ident), true
|
||||
case *parse.ChainNode:
|
||||
prefix, ok := nodePath(typed.Node, scope)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return appendPath(prefix, typed.Field), true
|
||||
case *parse.DotNode:
|
||||
if len(scope) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return append([]string(nil), scope...), true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func appendPath(prefix []string, suffix []string) []string {
|
||||
combined := make([]string, 0, len(prefix)+len(suffix))
|
||||
combined = append(combined, prefix...)
|
||||
combined = append(combined, suffix...)
|
||||
return combined
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewCatalogBuildsImmutableRegistry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "subject.tmpl"), "Your login code")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "text.tmpl"), "Code: {{.code}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "fr-fr", "subject.tmpl"), "Tour {{.turn_number}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "fr-fr", "text.tmpl"), "Bonjour {{with .player}}{{.name}}{{end}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "fr-fr", "html.tmpl"), "<p>{{.player.name}}</p>")
|
||||
|
||||
catalog, err := NewCatalog(rootDir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, filepath.Clean(rootDir), catalog.RootDir())
|
||||
|
||||
locale, err := common.ParseLocale("fr-FR")
|
||||
require.NoError(t, err)
|
||||
resolved, err := catalog.Lookup(common.TemplateID("game.turn_ready"), locale)
|
||||
require.NoError(t, err)
|
||||
require.False(t, resolved.LocaleFallbackUsed())
|
||||
require.Equal(t, common.Locale("fr-FR"), resolved.ResolvedLocale())
|
||||
require.Equal(t, []string{"player", "player.name", "turn_number"}, resolved.RequiredVariablePaths())
|
||||
|
||||
subject, err := resolved.ExecuteSubject(map[string]any{
|
||||
"turn_number": 54,
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Tour 54", subject)
|
||||
|
||||
textBody, err := resolved.ExecuteText(map[string]any{
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Bonjour Pilot", textBody)
|
||||
|
||||
htmlBody, ok, err := resolved.ExecuteHTML(map[string]any{
|
||||
"player": map[string]any{
|
||||
"name": "Pilot",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "<p>Pilot</p>", htmlBody)
|
||||
}
|
||||
|
||||
func TestCatalogLookupFallsBackToEnglish(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "subject.tmpl"), "Your login code")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "text.tmpl"), "Code: {{.code}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "en", "subject.tmpl"), "Turn {{.turn_number}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "en", "text.tmpl"), "Hello {{.player.name}}")
|
||||
|
||||
catalog, err := NewCatalog(rootDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
locale, err := common.ParseLocale("fr-FR")
|
||||
require.NoError(t, err)
|
||||
resolved, err := catalog.Lookup(common.TemplateID("game.turn_ready"), locale)
|
||||
require.NoError(t, err)
|
||||
require.True(t, resolved.LocaleFallbackUsed())
|
||||
require.Equal(t, common.Locale("en"), resolved.ResolvedLocale())
|
||||
}
|
||||
|
||||
func TestCatalogLookupRejectsMissingEnglishFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "subject.tmpl"), "Your login code")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "text.tmpl"), "Code: {{.code}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "fr-FR", "subject.tmpl"), "Tour {{.turn_number}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "fr-FR", "text.tmpl"), "Bonjour {{.player.name}}")
|
||||
|
||||
catalog, err := NewCatalog(rootDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
locale, err := common.ParseLocale("de-DE")
|
||||
require.NoError(t, err)
|
||||
_, err = catalog.Lookup(common.TemplateID("game.turn_ready"), locale)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, ErrFallbackMissing))
|
||||
}
|
||||
|
||||
func TestCatalogLookupRejectsUnknownTemplateFamily(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "subject.tmpl"), "Your login code")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "text.tmpl"), "Code: {{.code}}")
|
||||
|
||||
catalog, err := NewCatalog(rootDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
locale, err := common.ParseLocale("en")
|
||||
require.NoError(t, err)
|
||||
_, err = catalog.Lookup(common.TemplateID("game.turn_ready"), locale)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, ErrTemplateNotFound))
|
||||
}
|
||||
|
||||
func TestCatalogAllowsTemplateWithoutHTML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "subject.tmpl"), "Your login code")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "text.tmpl"), "Code: {{.code}}")
|
||||
|
||||
catalog, err := NewCatalog(rootDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
locale, err := common.ParseLocale("en")
|
||||
require.NoError(t, err)
|
||||
resolved, err := catalog.Lookup(common.TemplateID("auth.login_code"), locale)
|
||||
require.NoError(t, err)
|
||||
|
||||
htmlBody, ok, err := resolved.ExecuteHTML(map[string]any{"code": "123456"})
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
require.Empty(t, htmlBody)
|
||||
}
|
||||
|
||||
func TestCatalogVersionIsDeterministic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "subject.tmpl"), "Your login code")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "text.tmpl"), "Code: {{.code}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "en", "subject.tmpl"), "Turn {{.turn_number}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "en", "text.tmpl"), "Hello {{.player.name}}")
|
||||
|
||||
firstCatalog, err := NewCatalog(rootDir)
|
||||
require.NoError(t, err)
|
||||
secondCatalog, err := NewCatalog(rootDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
locale, err := common.ParseLocale("en")
|
||||
require.NoError(t, err)
|
||||
firstResolved, err := firstCatalog.Lookup(common.TemplateID("game.turn_ready"), locale)
|
||||
require.NoError(t, err)
|
||||
secondResolved, err := secondCatalog.Lookup(common.TemplateID("game.turn_ready"), locale)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, firstResolved.Template().Version, secondResolved.Template().Version)
|
||||
}
|
||||
|
||||
func TestNewCatalogRejectsMissingDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := NewCatalog(filepath.Join(t.TempDir(), "missing"))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "stat root dir")
|
||||
}
|
||||
|
||||
func TestNewCatalogRejectsMissingRequiredStartupTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "en", "subject.tmpl"), "Turn {{.turn_number}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "en", "text.tmpl"), "Hello {{.player.name}}")
|
||||
|
||||
_, err := NewCatalog(rootDir)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), `required template "auth.login_code" locale "en" is missing`)
|
||||
}
|
||||
|
||||
func TestNewCatalogRejectsBrokenTemplateParse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "subject.tmpl"), "Your login code")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("auth.login_code", "en", "text.tmpl"), "Code: {{.code}}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "en", "subject.tmpl"), "{{if .turn_number}")
|
||||
writeTemplateFile(t, rootDir, filepath.Join("game.turn_ready", "en", "text.tmpl"), "Hello {{.player.name}}")
|
||||
|
||||
_, err := NewCatalog(rootDir)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, ErrTemplateParseFailed))
|
||||
}
|
||||
|
||||
func writeTemplateFile(t *testing.T, rootDir string, relativePath string, contents string) {
|
||||
t.Helper()
|
||||
|
||||
absolutePath := filepath.Join(rootDir, relativePath)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(absolutePath), 0o755))
|
||||
require.NoError(t, os.WriteFile(absolutePath, []byte(contents), 0o644))
|
||||
}
|
||||
Reference in New Issue
Block a user