Files
galaxy-game/notification/mail_template_contract_test.go
T
2026-04-28 20:39:18 +02:00

193 lines
8.7 KiB
Go

package notification
import (
"path/filepath"
"strings"
"testing"
texttemplate "text/template"
"text/template/parse"
"github.com/stretchr/testify/require"
)
const expectedNotificationMailTemplateTable = `| ` + "`notification_type`" + ` | ` + "`template_id`" + ` | Required assets |
| --- | --- | --- |
| ` + "`geo.review_recommended`" + ` | ` + "`geo.review_recommended`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`game.turn.ready`" + ` | ` + "`game.turn.ready`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`game.finished`" + ` | ` + "`game.finished`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`game.generation_failed`" + ` | ` + "`game.generation_failed`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`lobby.runtime_paused_after_start`" + ` | ` + "`lobby.runtime_paused_after_start`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`lobby.application.submitted`" + ` | ` + "`lobby.application.submitted`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`lobby.membership.approved`" + ` | ` + "`lobby.membership.approved`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`lobby.membership.rejected`" + ` | ` + "`lobby.membership.rejected`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`lobby.membership.blocked`" + ` | ` + "`lobby.membership.blocked`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`lobby.invite.created`" + ` | ` + "`lobby.invite.created`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`lobby.invite.redeemed`" + ` | ` + "`lobby.invite.redeemed`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`lobby.invite.expired`" + ` | ` + "`lobby.invite.expired`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`lobby.race_name.registration_eligible`" + ` | ` + "`lobby.race_name.registration_eligible`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`lobby.race_name.registered`" + ` | ` + "`lobby.race_name.registered`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`lobby.race_name.registration_denied`" + ` | ` + "`lobby.race_name.registration_denied`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`runtime.image_pull_failed`" + ` | ` + "`runtime.image_pull_failed`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`runtime.container_start_failed`" + ` | ` + "`runtime.container_start_failed`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |
| ` + "`runtime.start_config_invalid`" + ` | ` + "`runtime.start_config_invalid`" + ` | ` + "`en/subject.tmpl`" + `, ` + "`en/text.tmpl`" + ` |`
var expectedNotificationMailReadmeSnippets = []string{
"`payload_mode` is always `template`",
"`template_id` equals `notification_type`",
"Auth-code email remains a direct `Auth / Session Service -> Mail Service` flow and does not pass through `Notification Service`.",
}
var expectedMailServiceReadmeSnippets = []string{
"`Notification Service` uses only `payload_mode=template` for notification-generated mail",
"notification-owned `template_id` values are identical to the `notification_type` vocabulary",
"`auth.login_code` remains the required auth template family for the direct `Auth / Session Service -> Mail Service` flow and is not part of the notification-owned template set.",
}
func TestNotificationMailTemplateDocsStayInSync(t *testing.T) {
t.Parallel()
readme := loadTextFile(t, "README.md")
flowsDoc := loadTextFile(t, filepath.Join("docs", "flows.md"))
examplesDoc := loadTextFile(t, filepath.Join("docs", "examples.md"))
docsIndex := loadTextFile(t, filepath.Join("docs", "README.md"))
mailReadme := loadTextFile(t, filepath.Join("..", "mail", "README.md"))
normalizedReadme := normalizeWhitespace(readme)
normalizedFlowsDoc := normalizeWhitespace(flowsDoc)
normalizedExamplesDoc := normalizeWhitespace(examplesDoc)
normalizedMailReadme := normalizeWhitespace(mailReadme)
require.Contains(t, docsIndex, "- [Main flows](flows.md)")
require.Contains(t, docsIndex, "- [Configuration and contract examples](examples.md)")
require.Contains(t, readme, expectedNotificationMailTemplateTable)
require.Contains(t, readme, "`auth.login_code` does not belong to the notification-owned template set.")
require.NotContains(t, readme, "The initial required template IDs are:")
require.NotContains(t, mailReadme, "Initial non-auth notification template directories:")
for _, snippet := range expectedNotificationMailReadmeSnippets {
require.Contains(t, normalizedReadme, normalizeWhitespace(snippet))
}
for _, snippet := range expectedMailServiceReadmeSnippets {
require.Contains(t, normalizedMailReadme, normalizeWhitespace(snippet))
}
require.Contains(t, normalizedFlowsDoc, normalizeWhitespace("Notification-generated mail always uses `source=notification`"))
require.Contains(t, normalizedFlowsDoc, normalizeWhitespace("`payload_mode=template`"))
require.Contains(t, normalizedFlowsDoc, normalizeWhitespace("`template_id == notification_type`"))
require.Contains(t, normalizedExamplesDoc, normalizeWhitespace("payload_mode template"))
}
func TestNotificationMailTemplatesExistAndAreNonEmpty(t *testing.T) {
t.Parallel()
for _, templateID := range expectedNotificationTypeCatalog {
subjectPath, textPath := notificationMailTemplatePaths(templateID)
subject := loadTextFile(t, subjectPath)
text := loadTextFile(t, textPath)
require.NotEmptyf(t, strings.TrimSpace(subject), "subject template %s must not be empty", subjectPath)
require.NotEmptyf(t, strings.TrimSpace(text), "text template %s must not be empty", textPath)
}
}
func TestNotificationMailTemplateVariablesStayWithinFrozenPayloadFields(t *testing.T) {
t.Parallel()
for _, templateID := range expectedNotificationTypeCatalog {
allowedFields := make(map[string]struct{}, len(expectedNotificationCatalog[templateID].requiredFields))
for _, field := range expectedNotificationCatalog[templateID].requiredFields {
allowedFields[field] = struct{}{}
}
for _, templatePath := range []string{
filepath.Join("..", "mail", "templates", templateID, "en", "subject.tmpl"),
filepath.Join("..", "mail", "templates", templateID, "en", "text.tmpl"),
} {
for _, fieldPath := range parsedTemplateFieldPaths(t, templatePath) {
_, ok := allowedFields[fieldPath]
require.Truef(
t,
ok,
"template %s references field %q outside frozen payload contract for %s",
templatePath,
fieldPath,
templateID,
)
}
}
}
}
func notificationMailTemplatePaths(templateID string) (subjectPath string, textPath string) {
return filepath.Join("..", "mail", "templates", templateID, "en", "subject.tmpl"),
filepath.Join("..", "mail", "templates", templateID, "en", "text.tmpl")
}
func parsedTemplateFieldPaths(t *testing.T, relativePath string) []string {
t.Helper()
source := loadTextFile(t, relativePath)
tmpl, err := texttemplate.New(filepath.Base(relativePath)).Parse(source)
require.NoErrorf(t, err, "parse template %s", relativePath)
require.NotNil(t, tmpl.Tree)
require.NotNil(t, tmpl.Tree.Root)
fields := make(map[string]struct{})
collectTemplateFieldPaths(tmpl.Tree.Root, fields)
result := make([]string, 0, len(fields))
for field := range fields {
result = append(result, field)
}
return result
}
func collectTemplateFieldPaths(node parse.Node, fields map[string]struct{}) {
if node == nil {
return
}
switch typed := node.(type) {
case *parse.ListNode:
for _, child := range typed.Nodes {
collectTemplateFieldPaths(child, fields)
}
case *parse.ActionNode:
collectTemplateFieldPaths(typed.Pipe, fields)
case *parse.IfNode:
collectTemplateFieldPaths(typed.Pipe, fields)
collectTemplateFieldPaths(typed.List, fields)
collectTemplateFieldPaths(typed.ElseList, fields)
case *parse.RangeNode:
collectTemplateFieldPaths(typed.Pipe, fields)
collectTemplateFieldPaths(typed.List, fields)
collectTemplateFieldPaths(typed.ElseList, fields)
case *parse.WithNode:
collectTemplateFieldPaths(typed.Pipe, fields)
collectTemplateFieldPaths(typed.List, fields)
collectTemplateFieldPaths(typed.ElseList, fields)
case *parse.TemplateNode:
collectTemplateFieldPaths(typed.Pipe, fields)
case *parse.PipeNode:
for _, child := range typed.Cmds {
collectTemplateFieldPaths(child, fields)
}
case *parse.CommandNode:
for _, child := range typed.Args {
collectTemplateFieldPaths(child, fields)
}
case *parse.FieldNode:
if fieldPath := strings.Join(typed.Ident, "."); fieldPath != "" {
fields[fieldPath] = struct{}{}
}
case *parse.ChainNode:
if fieldPath := strings.Join(typed.Field, "."); fieldPath != "" {
fields[fieldPath] = struct{}{}
}
collectTemplateFieldPaths(typed.Node, fields)
}
}