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) } }