feat: notification service
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
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.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`" + ` |`
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user