feat: notification service
This commit is contained in:
@@ -42,6 +42,8 @@ Cross-service routing rules:
|
||||
- `Notification Service -> Mail Service` is asynchronous `Redis Streams`
|
||||
- `Geo Profile Service` must route optional admin e-mail through
|
||||
`Notification Service`, not directly to `Mail Service`
|
||||
- auth-code delivery remains a direct `Auth / Session Service -> Mail Service`
|
||||
flow and does not pass through `Notification Service`
|
||||
|
||||
## Runtime Surface
|
||||
|
||||
@@ -192,6 +194,7 @@ Stable envelope fields:
|
||||
- `source`
|
||||
- `payload_mode`
|
||||
- `idempotency_key`
|
||||
- `requested_at_ms`
|
||||
- `request_id`
|
||||
- `trace_id`
|
||||
- `payload_json`
|
||||
@@ -200,6 +203,16 @@ Contract rules:
|
||||
|
||||
- async `source` is fixed to `notification`
|
||||
- supported `payload_mode` values are `rendered` and `template`
|
||||
- `Notification Service` uses only `payload_mode=template` for
|
||||
notification-generated mail, even though the generic async contract keeps
|
||||
both `rendered` and `template`
|
||||
- notification-owned `template_id` values are identical to the
|
||||
`notification_type` vocabulary, for example `game.turn.ready` and
|
||||
`lobby.membership.approved`
|
||||
- the real `Notification Service -> Mail Service` integration suite verifies
|
||||
template-mode handoff for notification-owned mail
|
||||
- `requested_at_ms` stores the publisher-side original request timestamp in
|
||||
Unix milliseconds
|
||||
- `request_id` and `trace_id` are observability-only metadata and do not
|
||||
participate in idempotency fingerprinting
|
||||
- malformed commands are metered, logged, and recorded as dedicated
|
||||
@@ -338,6 +351,13 @@ Required auth fallback files:
|
||||
- `auth.login_code/en/subject.tmpl`
|
||||
- `auth.login_code/en/text.tmpl`
|
||||
|
||||
Notification-owned English template directories are frozen by
|
||||
[`../notification/README.md`](../notification/README.md) and the service-local
|
||||
[`Notification Service` docs](../notification/docs/README.md).
|
||||
`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.
|
||||
|
||||
Rendering rules:
|
||||
|
||||
- the process loads the full catalog at startup
|
||||
|
||||
@@ -56,7 +56,7 @@ components:
|
||||
payload_mode: template
|
||||
idempotency_key: notification:mail-124
|
||||
requested_at_ms: "1775121700001"
|
||||
payload_json: '{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn_ready","locale":"fr-FR","variables":{"turn_number":54},"attachments":[]}'
|
||||
payload_json: '{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn.ready","locale":"fr-FR","variables":{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54},"attachments":[]}'
|
||||
schemas:
|
||||
RenderedDeliveryCommandEnvelope:
|
||||
type: object
|
||||
|
||||
@@ -91,7 +91,7 @@ redis-cli XADD mail:delivery_commands '*' \
|
||||
idempotency_key notification:mail-124 \
|
||||
request_id req-124 \
|
||||
trace_id trace-124 \
|
||||
payload_json '{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn_ready","locale":"fr-FR","variables":{"turn_number":54},"attachments":[]}'
|
||||
payload_json '{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn.ready","locale":"fr-FR","variables":{"turn_number":54},"attachments":[]}'
|
||||
```
|
||||
|
||||
## Operator API Examples
|
||||
|
||||
@@ -17,9 +17,9 @@ func TestNewCatalogBuildsImmutableRegistry(t *testing.T) {
|
||||
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>")
|
||||
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)
|
||||
@@ -27,7 +27,7 @@ func TestNewCatalogBuildsImmutableRegistry(t *testing.T) {
|
||||
|
||||
locale, err := common.ParseLocale("fr-FR")
|
||||
require.NoError(t, err)
|
||||
resolved, err := catalog.Lookup(common.TemplateID("game.turn_ready"), locale)
|
||||
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())
|
||||
@@ -66,15 +66,15 @@ func TestCatalogLookupFallsBackToEnglish(t *testing.T) {
|
||||
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}}")
|
||||
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)
|
||||
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())
|
||||
@@ -86,15 +86,15 @@ func TestCatalogLookupRejectsMissingEnglishFallback(t *testing.T) {
|
||||
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}}")
|
||||
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)
|
||||
_, err = catalog.Lookup(common.TemplateID("game.turn.ready"), locale)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, ErrFallbackMissing))
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func TestCatalogLookupRejectsUnknownTemplateFamily(t *testing.T) {
|
||||
|
||||
locale, err := common.ParseLocale("en")
|
||||
require.NoError(t, err)
|
||||
_, err = catalog.Lookup(common.TemplateID("game.turn_ready"), locale)
|
||||
_, err = catalog.Lookup(common.TemplateID("game.turn.ready"), locale)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, ErrTemplateNotFound))
|
||||
}
|
||||
@@ -143,8 +143,8 @@ func TestCatalogVersionIsDeterministic(t *testing.T) {
|
||||
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}}")
|
||||
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)
|
||||
@@ -153,9 +153,9 @@ func TestCatalogVersionIsDeterministic(t *testing.T) {
|
||||
|
||||
locale, err := common.ParseLocale("en")
|
||||
require.NoError(t, err)
|
||||
firstResolved, err := firstCatalog.Lookup(common.TemplateID("game.turn_ready"), locale)
|
||||
firstResolved, err := firstCatalog.Lookup(common.TemplateID("game.turn.ready"), locale)
|
||||
require.NoError(t, err)
|
||||
secondResolved, err := secondCatalog.Lookup(common.TemplateID("game.turn_ready"), locale)
|
||||
secondResolved, err := secondCatalog.Lookup(common.TemplateID("game.turn.ready"), locale)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, firstResolved.Template().Version, secondResolved.Template().Version)
|
||||
@@ -173,8 +173,8 @@ 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}}")
|
||||
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)
|
||||
@@ -187,8 +187,8 @@ func TestNewCatalogRejectsBrokenTemplateParse(t *testing.T) {
|
||||
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}}")
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"galaxy/mail/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var expectedNotificationTemplateIDs = []common.TemplateID{
|
||||
"geo.review_recommended",
|
||||
"game.turn.ready",
|
||||
"game.finished",
|
||||
"game.generation_failed",
|
||||
"lobby.runtime_paused_after_start",
|
||||
"lobby.application.submitted",
|
||||
"lobby.membership.approved",
|
||||
"lobby.membership.rejected",
|
||||
"lobby.invite.created",
|
||||
"lobby.invite.redeemed",
|
||||
"lobby.invite.expired",
|
||||
}
|
||||
|
||||
func TestCheckedInTemplateCatalogIncludesNotificationEnglishAssets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
catalog, err := NewCatalog(checkedInTemplateRoot(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
locale, err := common.ParseLocale("en")
|
||||
require.NoError(t, err)
|
||||
|
||||
authTemplate, err := catalog.Lookup(common.TemplateID("auth.login_code"), locale)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.Locale("en"), authTemplate.ResolvedLocale())
|
||||
require.False(t, authTemplate.LocaleFallbackUsed())
|
||||
|
||||
for _, templateID := range expectedNotificationTemplateIDs {
|
||||
resolved, err := catalog.Lookup(templateID, locale)
|
||||
require.NoErrorf(t, err, "lookup checked-in template %s", templateID)
|
||||
require.Equalf(t, common.Locale("en"), resolved.ResolvedLocale(), "template %s must resolve to en", templateID)
|
||||
require.Falsef(t, resolved.LocaleFallbackUsed(), "template %s must not use fallback for en", templateID)
|
||||
}
|
||||
}
|
||||
|
||||
func checkedInTemplateRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
require.FailNow(t, "runtime.Caller failed")
|
||||
}
|
||||
|
||||
return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "templates"))
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func TestDecodeCommandSuccessTemplate(t *testing.T) {
|
||||
command, err := DecodeCommand(validTemplateFields(t))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, common.TemplateID("game.turn_ready"), command.TemplateID)
|
||||
require.Equal(t, common.TemplateID("game.turn.ready"), command.TemplateID)
|
||||
require.Equal(t, common.Locale("fr-FR"), command.Locale)
|
||||
require.Equal(t, map[string]any{
|
||||
"turn_number": float64(54),
|
||||
@@ -171,7 +171,7 @@ func TestDecodeCommandRejectsInvalidPayload(t *testing.T) {
|
||||
"subject": "Turn ready",
|
||||
"text_body": "Turn 54 is ready.",
|
||||
"attachments": []map[string]any{},
|
||||
"template_id": "game.turn_ready",
|
||||
"template_id": "game.turn.ready",
|
||||
})
|
||||
return fields
|
||||
}(t),
|
||||
@@ -212,7 +212,7 @@ func TestDecodeCommandRejectsInvalidPayload(t *testing.T) {
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"template_id": "game.turn_ready",
|
||||
"template_id": "game.turn.ready",
|
||||
"locale": "english",
|
||||
"variables": map[string]any{},
|
||||
"attachments": []map[string]any{},
|
||||
@@ -230,7 +230,7 @@ func TestDecodeCommandRejectsInvalidPayload(t *testing.T) {
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"template_id": "game.turn_ready",
|
||||
"template_id": "game.turn.ready",
|
||||
"locale": "fr-FR",
|
||||
"variables": []string{"not", "object"},
|
||||
"attachments": []map[string]any{},
|
||||
@@ -428,7 +428,7 @@ func validTemplatePayloadJSON(t *testing.T) string {
|
||||
"cc": []string{},
|
||||
"bcc": []string{},
|
||||
"reply_to": []string{},
|
||||
"template_id": "game.turn_ready",
|
||||
"template_id": "game.turn.ready",
|
||||
"locale": "fr-FR",
|
||||
"variables": map[string]any{
|
||||
"turn_number": 54,
|
||||
|
||||
@@ -104,9 +104,9 @@ func TestNewRuntimeRejectsBrokenTemplateCatalog(t *testing.T) {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(rootDir, "auth.login_code", "en"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "auth.login_code", "en", "subject.tmpl"), []byte("Your login code"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "auth.login_code", "en", "text.tmpl"), []byte("Code: {{.code}}"), 0o644))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(rootDir, "game.turn_ready", "en"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "game.turn_ready", "en", "subject.tmpl"), []byte("{{if .turn_number}"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "game.turn_ready", "en", "text.tmpl"), []byte("Turn ready"), 0o644))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(rootDir, "game.turn.ready", "en"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "game.turn.ready", "en", "subject.tmpl"), []byte("{{if .turn_number}"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "game.turn.ready", "en", "text.tmpl"), []byte("Turn ready"), 0o644))
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Redis.Addr = redisServer.Addr()
|
||||
|
||||
@@ -264,7 +264,7 @@ func validTemplateQueuedDelivery(t *testing.T) Delivery {
|
||||
DeliveryID: common.DeliveryID("delivery-124"),
|
||||
Source: SourceNotification,
|
||||
PayloadMode: PayloadModeTemplate,
|
||||
TemplateID: common.TemplateID("game.turn_ready"),
|
||||
TemplateID: common.TemplateID("game.turn.ready"),
|
||||
Envelope: validEnvelope(),
|
||||
Locale: locale,
|
||||
TemplateVariables: map[string]any{
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestServiceExecuteAcceptsTemplateDelivery(t *testing.T) {
|
||||
require.Equal(t, Result{Outcome: OutcomeAccepted}, result)
|
||||
require.Len(t, store.createInputs, 1)
|
||||
require.Nil(t, store.createInputs[0].DeliveryPayload)
|
||||
require.Equal(t, common.TemplateID("game.turn_ready"), store.createInputs[0].Delivery.TemplateID)
|
||||
require.Equal(t, common.TemplateID("game.turn.ready"), store.createInputs[0].Delivery.TemplateID)
|
||||
require.Equal(t, map[string]any{
|
||||
"turn_number": float64(54),
|
||||
"player": map[string]any{
|
||||
@@ -201,7 +201,7 @@ func TestServiceExecuteLogsAcceptedDeliveryAndCreatesSpan(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, loggerBuffer.String(), "\"delivery_id\":\"mail-124\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"source\":\"notification\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"template_id\":\"game.turn_ready\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"template_id\":\"game.turn.ready\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"trace_id\":\"trace-123\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"otel_trace_id\":")
|
||||
require.True(t, hasSpanNamed(recorder.Ended(), "mail.accept_generic_delivery"))
|
||||
@@ -295,7 +295,7 @@ func validTemplateCommand(t *testing.T) streamcommand.Command {
|
||||
"payload_mode": "template",
|
||||
"idempotency_key": "notification:mail-124",
|
||||
"requested_at_ms": "1775121700001",
|
||||
"payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn_ready","locale":"fr-FR","variables":{"turn_number":54,"player":{"name":"Pilot"}},"attachments":[]}`,
|
||||
"payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn.ready","locale":"fr-FR","variables":{"turn_number":54,"player":{"name":"Pilot"}},"attachments":[]}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -308,7 +308,7 @@ func TestServiceExecuteRecordsMetricsAndLogsProviderResult(t *testing.T) {
|
||||
require.Equal(t, []string{"smtp:accepted"}, telemetry.providerDurations)
|
||||
require.Contains(t, loggerBuffer.String(), "\"delivery_id\":\"delivery-template-sending\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"source\":\"notification\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"template_id\":\"game.turn_ready\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"template_id\":\"game.turn.ready\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"attempt_no\":1")
|
||||
require.Contains(t, loggerBuffer.String(), "\"otel_trace_id\":")
|
||||
require.True(t, hasExecuteSpanNamed(recorder.Ended(), "mail.provider_send"))
|
||||
@@ -431,7 +431,7 @@ func queuedTemplateWorkItem(t *testing.T) WorkItem {
|
||||
DeliveryID: common.DeliveryID("delivery-template"),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeTemplate,
|
||||
TemplateID: common.TemplateID("game.turn_ready"),
|
||||
TemplateID: common.TemplateID("game.turn.ready"),
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{common.Email("pilot@example.com")},
|
||||
},
|
||||
@@ -512,7 +512,7 @@ func sendingTemplateWorkItem(t *testing.T, attemptNo int) WorkItem {
|
||||
DeliveryID: common.DeliveryID("delivery-template-sending"),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeTemplate,
|
||||
TemplateID: common.TemplateID("game.turn_ready"),
|
||||
TemplateID: common.TemplateID("game.turn.ready"),
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{common.Email("pilot@example.com")},
|
||||
},
|
||||
|
||||
@@ -26,9 +26,9 @@ func TestServiceExecuteRendersExactLocale(t *testing.T) {
|
||||
catalog := newTestCatalog(t, map[string]string{
|
||||
filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code",
|
||||
filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}",
|
||||
filepath.Join("game.turn_ready", "fr-fr", "subject.tmpl"): "Tour {{.turn_number}}",
|
||||
filepath.Join("game.turn_ready", "fr-fr", "text.tmpl"): "Bonjour {{with .player}}{{.name}}{{end}}",
|
||||
filepath.Join("game.turn_ready", "fr-fr", "html.tmpl"): "<p>{{.player.name}}</p>",
|
||||
filepath.Join("game.turn.ready", "fr-fr", "subject.tmpl"): "Tour {{.turn_number}}",
|
||||
filepath.Join("game.turn.ready", "fr-fr", "text.tmpl"): "Bonjour {{with .player}}{{.name}}{{end}}",
|
||||
filepath.Join("game.turn.ready", "fr-fr", "html.tmpl"): "<p>{{.player.name}}</p>",
|
||||
})
|
||||
|
||||
store := &stubStore{}
|
||||
@@ -61,8 +61,8 @@ func TestServiceExecuteFallsBackToEnglish(t *testing.T) {
|
||||
catalog := newTestCatalog(t, map[string]string{
|
||||
filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code",
|
||||
filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}",
|
||||
filepath.Join("game.turn_ready", "en", "subject.tmpl"): "Turn {{.turn_number}}",
|
||||
filepath.Join("game.turn_ready", "en", "text.tmpl"): "Hello {{.player.name}}",
|
||||
filepath.Join("game.turn.ready", "en", "subject.tmpl"): "Turn {{.turn_number}}",
|
||||
filepath.Join("game.turn.ready", "en", "text.tmpl"): "Hello {{.player.name}}",
|
||||
})
|
||||
|
||||
store := &stubStore{}
|
||||
@@ -86,8 +86,8 @@ func TestServiceExecuteRecordsLocaleFallbackAndLogsFields(t *testing.T) {
|
||||
catalog := newTestCatalog(t, map[string]string{
|
||||
filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code",
|
||||
filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}",
|
||||
filepath.Join("game.turn_ready", "en", "subject.tmpl"): "Turn {{.turn_number}}",
|
||||
filepath.Join("game.turn_ready", "en", "text.tmpl"): "Hello {{.player.name}}",
|
||||
filepath.Join("game.turn.ready", "en", "subject.tmpl"): "Turn {{.turn_number}}",
|
||||
filepath.Join("game.turn.ready", "en", "text.tmpl"): "Hello {{.player.name}}",
|
||||
})
|
||||
|
||||
telemetry := &stubTelemetry{}
|
||||
@@ -107,10 +107,10 @@ func TestServiceExecuteRecordsLocaleFallbackAndLogsFields(t *testing.T) {
|
||||
_, err := service.Execute(context.Background(), validInput(t, "fr-FR"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"notification:rendered"}, telemetry.statuses)
|
||||
require.Equal(t, []string{"game.turn_ready:fr-FR:en"}, telemetry.fallbacks)
|
||||
require.Equal(t, []string{"game.turn.ready:fr-FR:en"}, telemetry.fallbacks)
|
||||
require.Contains(t, loggerBuffer.String(), "\"delivery_id\":\"delivery-123\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"source\":\"notification\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"template_id\":\"game.turn_ready\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"template_id\":\"game.turn.ready\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"attempt_no\":1")
|
||||
require.Contains(t, loggerBuffer.String(), "\"otel_trace_id\":")
|
||||
require.True(t, hasRenderSpanNamed(recorder.Ended(), "mail.render_delivery"))
|
||||
@@ -122,8 +122,8 @@ func TestServiceExecuteFailsOnMissingRequiredVariable(t *testing.T) {
|
||||
catalog := newTestCatalog(t, map[string]string{
|
||||
filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code",
|
||||
filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}",
|
||||
filepath.Join("game.turn_ready", "en", "subject.tmpl"): "Turn {{.turn_number}}",
|
||||
filepath.Join("game.turn_ready", "en", "text.tmpl"): "Hello {{.player.name}}",
|
||||
filepath.Join("game.turn.ready", "en", "subject.tmpl"): "Turn {{.turn_number}}",
|
||||
filepath.Join("game.turn.ready", "en", "text.tmpl"): "Hello {{.player.name}}",
|
||||
})
|
||||
|
||||
store := &stubStore{}
|
||||
@@ -153,8 +153,8 @@ func TestServiceExecuteFailsOnTemplateExecutionError(t *testing.T) {
|
||||
catalog := newTestCatalog(t, map[string]string{
|
||||
filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code",
|
||||
filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}",
|
||||
filepath.Join("game.turn_ready", "en", "subject.tmpl"): "{{call .callable}}",
|
||||
filepath.Join("game.turn_ready", "en", "text.tmpl"): "Hello {{.player.name}}",
|
||||
filepath.Join("game.turn.ready", "en", "subject.tmpl"): "{{call .callable}}",
|
||||
filepath.Join("game.turn.ready", "en", "text.tmpl"): "Hello {{.player.name}}",
|
||||
})
|
||||
|
||||
store := &stubStore{}
|
||||
@@ -231,8 +231,8 @@ func TestServiceExecuteReturnsServiceUnavailableOnStoreFailure(t *testing.T) {
|
||||
catalog := newTestCatalog(t, map[string]string{
|
||||
filepath.Join("auth.login_code", "en", "subject.tmpl"): "Your login code",
|
||||
filepath.Join("auth.login_code", "en", "text.tmpl"): "Code: {{.code}}",
|
||||
filepath.Join("game.turn_ready", "en", "subject.tmpl"): "Turn {{.turn_number}}",
|
||||
filepath.Join("game.turn_ready", "en", "text.tmpl"): "Hello {{.player.name}}",
|
||||
filepath.Join("game.turn.ready", "en", "subject.tmpl"): "Turn {{.turn_number}}",
|
||||
filepath.Join("game.turn.ready", "en", "text.tmpl"): "Hello {{.player.name}}",
|
||||
})
|
||||
|
||||
service := newTestService(t, Config{
|
||||
@@ -346,7 +346,7 @@ func validInput(t *testing.T, localeValue string) Input {
|
||||
DeliveryID: common.DeliveryID("delivery-123"),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeTemplate,
|
||||
TemplateID: common.TemplateID("game.turn_ready"),
|
||||
TemplateID: common.TemplateID("game.turn.ready"),
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{common.Email("pilot@example.com")},
|
||||
},
|
||||
|
||||
@@ -121,7 +121,7 @@ func TestServiceExecuteLogsCloneCreationAndCreatesSpan(t *testing.T) {
|
||||
require.Equal(t, []string{"operator_resend:queued"}, telemetry.statuses)
|
||||
require.Contains(t, loggerBuffer.String(), "\"delivery_id\":\"clone-456\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"source\":\"operator_resend\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"template_id\":\"game.turn_ready\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"template_id\":\"game.turn.ready\"")
|
||||
require.Contains(t, loggerBuffer.String(), "\"otel_trace_id\":")
|
||||
require.True(t, hasResendSpanNamed(recorder.Ended(), "mail.resend_delivery"))
|
||||
}
|
||||
@@ -205,7 +205,7 @@ func validOriginalDelivery() deliverydomain.Delivery {
|
||||
DeliveryID: common.DeliveryID("delivery-original"),
|
||||
Source: deliverydomain.SourceNotification,
|
||||
PayloadMode: deliverydomain.PayloadModeTemplate,
|
||||
TemplateID: common.TemplateID("game.turn_ready"),
|
||||
TemplateID: common.TemplateID("game.turn.ready"),
|
||||
Envelope: deliverydomain.Envelope{
|
||||
To: []common.Email{common.Email("pilot@example.com")},
|
||||
Cc: []common.Email{common.Email("copilot@example.com")},
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestCommandConsumerAcceptsTemplateCommand(t *testing.T) {
|
||||
return false
|
||||
}
|
||||
entryID, found, err := fixture.offsetStore.Load(context.Background(), fixture.stream)
|
||||
return err == nil && found && entryID == messageID && delivery.TemplateID == "game.turn_ready"
|
||||
return err == nil && found && entryID == messageID && delivery.TemplateID == "game.turn.ready"
|
||||
}, 5*time.Second, 20*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
@@ -324,7 +324,7 @@ func addTemplateCommand(t *testing.T, client *redis.Client, deliveryID string, i
|
||||
"payload_mode": "template",
|
||||
"idempotency_key": idempotencyKey,
|
||||
"requested_at_ms": "1775121700001",
|
||||
"payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn_ready","locale":"fr-FR","variables":{"turn_number":54},"attachments":[]}`,
|
||||
"payload_json": `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn.ready","locale":"fr-FR","variables":{"turn_number":54},"attachments":[]}`,
|
||||
},
|
||||
}).Result()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Game finished: {{.game_name}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{.game_name}} has finished.
|
||||
|
||||
Game ID: {{.game_id}}
|
||||
Final turn: {{.final_turn_number}}
|
||||
@@ -0,0 +1 @@
|
||||
Turn generation failed in {{.game_name}}
|
||||
@@ -0,0 +1,4 @@
|
||||
Turn generation failed for {{.game_name}}.
|
||||
|
||||
Game ID: {{.game_id}}
|
||||
Failure reason: {{.failure_reason}}
|
||||
@@ -0,0 +1 @@
|
||||
Turn {{.turn_number}} is ready in {{.game_name}}
|
||||
@@ -0,0 +1,4 @@
|
||||
A new turn is ready in {{.game_name}}.
|
||||
|
||||
Game ID: {{.game_id}}
|
||||
Turn: {{.turn_number}}
|
||||
@@ -0,0 +1 @@
|
||||
Geo review recommended for {{.user_email}}
|
||||
@@ -0,0 +1,5 @@
|
||||
User {{.user_email}} ({{.user_id}}) entered the geo review queue.
|
||||
|
||||
Observed country: {{.observed_country}}
|
||||
Usual connection country: {{.usual_connection_country}}
|
||||
Reason: {{.review_reason}}
|
||||
@@ -0,0 +1 @@
|
||||
New application for {{.game_name}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{.applicant_name}} submitted an application for {{.game_name}}.
|
||||
|
||||
Game ID: {{.game_id}}
|
||||
Applicant user ID: {{.applicant_user_id}}
|
||||
@@ -0,0 +1 @@
|
||||
You were invited to {{.game_name}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{.inviter_name}} invited you to join {{.game_name}}.
|
||||
|
||||
Game ID: {{.game_id}}
|
||||
Inviter user ID: {{.inviter_user_id}}
|
||||
@@ -0,0 +1 @@
|
||||
Invite expired for {{.game_name}}
|
||||
@@ -0,0 +1,4 @@
|
||||
An invite for {{.game_name}} expired before redemption.
|
||||
|
||||
Game ID: {{.game_id}}
|
||||
Invitee user ID: {{.invitee_user_id}}
|
||||
@@ -0,0 +1 @@
|
||||
Invite redeemed for {{.game_name}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{.invitee_name}} redeemed an invite for {{.game_name}}.
|
||||
|
||||
Game ID: {{.game_id}}
|
||||
Invitee user ID: {{.invitee_user_id}}
|
||||
@@ -0,0 +1 @@
|
||||
Application approved for {{.game_name}}
|
||||
@@ -0,0 +1,3 @@
|
||||
Your application for {{.game_name}} was approved.
|
||||
|
||||
Game ID: {{.game_id}}
|
||||
@@ -0,0 +1 @@
|
||||
Application rejected for {{.game_name}}
|
||||
@@ -0,0 +1,3 @@
|
||||
Your application for {{.game_name}} was rejected.
|
||||
|
||||
Game ID: {{.game_id}}
|
||||
@@ -0,0 +1 @@
|
||||
Game paused after start: {{.game_name}}
|
||||
@@ -0,0 +1,3 @@
|
||||
{{.game_name}} entered paused state after runtime startup.
|
||||
|
||||
Game ID: {{.game_id}}
|
||||
Reference in New Issue
Block a user