Files
galaxy-game/notification/redis_state_contract_test.go
T
2026-04-22 08:49:45 +02:00

88 lines
6.7 KiB
Go

package notification
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
const expectedNotificationRedisKeyTable = `| Logical artifact | Redis key |
| --- | --- |
| ` + "`notification_record`" + ` | ` + "`notification:records:<notification_id>`" + ` |
| ` + "`notification_route`" + ` | ` + "`notification:routes:<notification_id>:<route_id>`" + ` |
| temporary route lease | ` + "`notification:route_leases:<notification_id>:<route_id>`" + ` |
| ` + "`notification_idempotency_record`" + ` | ` + "`notification:idempotency:<producer>:<idempotency_key>`" + ` |
| ` + "`notification_dead_letter_entry`" + ` | ` + "`notification:dead_letters:<notification_id>:<route_id>`" + ` |
| malformed intent record | ` + "`notification:malformed_intents:<stream_entry_id>`" + ` |
| stream offset record | ` + "`notification:stream_offsets:<stream>`" + ` |
| ingress stream | ` + "`notification:intents`" + ` |
| route schedule sorted set | ` + "`notification:route_schedule`" + ` |`
const expectedNotificationRedisRecordFieldsTable = `| Record | Frozen fields |
| --- | --- |
| ` + "`notification_record`" + ` | ` + "`notification_id`" + `, ` + "`notification_type`" + `, ` + "`producer`" + `, ` + "`audience_kind`" + `, normalized ` + "`recipient_user_ids`" + `, normalized ` + "`payload_json`" + `, ` + "`idempotency_key`" + `, ` + "`request_fingerprint`" + `, optional ` + "`request_id`" + `, optional ` + "`trace_id`" + `, ` + "`occurred_at_ms`" + `, ` + "`accepted_at_ms`" + `, ` + "`updated_at_ms`" + ` |
| ` + "`notification_route`" + ` | ` + "`notification_id`" + `, ` + "`route_id`" + `, ` + "`channel`" + `, ` + "`recipient_ref`" + `, ` + "`status`" + `, ` + "`attempt_count`" + `, ` + "`max_attempts`" + `, ` + "`next_attempt_at_ms`" + `, optional ` + "`resolved_email`" + `, optional ` + "`resolved_locale`" + `, optional ` + "`last_error_classification`" + `, optional ` + "`last_error_message`" + `, optional ` + "`last_error_at_ms`" + `, ` + "`created_at_ms`" + `, ` + "`updated_at_ms`" + `, optional ` + "`published_at_ms`" + `, optional ` + "`dead_lettered_at_ms`" + `, optional ` + "`skipped_at_ms`" + ` |
| ` + "`notification_idempotency_record`" + ` | ` + "`producer`" + `, ` + "`idempotency_key`" + `, ` + "`notification_id`" + `, ` + "`request_fingerprint`" + `, ` + "`created_at_ms`" + `, ` + "`expires_at_ms`" + ` |
| ` + "`notification_dead_letter_entry`" + ` | ` + "`notification_id`" + `, ` + "`route_id`" + `, ` + "`channel`" + `, ` + "`recipient_ref`" + `, ` + "`final_attempt_count`" + `, ` + "`max_attempts`" + `, ` + "`failure_classification`" + `, ` + "`failure_message`" + `, ` + "`created_at_ms`" + `, optional ` + "`recovery_hint`" + ` |
| malformed intent record | ` + "`stream_entry_id`" + `, optional ` + "`notification_type`" + `, optional ` + "`producer`" + `, optional ` + "`idempotency_key`" + `, ` + "`failure_code`" + `, ` + "`failure_message`" + `, ` + "`raw_fields_json`" + `, ` + "`recorded_at_ms`" + ` |
| stream offset record | ` + "`stream`" + `, ` + "`last_processed_entry_id`" + `, ` + "`updated_at_ms`" + ` |`
var expectedNotificationRedisDocumentationSnippets = []string{
"Each route represents exactly one `(channel, recipient_ref)` pair.",
"every derived `recipient_ref` receives one `push` route slot and one `email` route slot, except that an empty administrator email list materializes one synthetic `config:<notification_type>` recipient slot with only a skipped `email` route",
"a route slot whose channel is outside the notification type channel matrix is materialized as `skipped`",
"`recipient_ref` is `user:<user_id>` for user-targeted routes",
"`recipient_ref` is `email:<normalized_address>` for configured administrator email routes",
"synthetic recipient slot `config:<notification_type>` with one skipped `email` route so the configuration gap remains durable and operator-visible",
"`route_id` is mandatory and equals `<channel>:<recipient_ref>`",
"durable records are stored as strict JSON blobs",
"timestamps are stored in Unix milliseconds",
"dynamic Redis key segments are base64url-encoded",
"`notification:route_schedule` is one shared sorted set for both `push` and `email`",
"`notification_record.payload_json` stores the canonical normalized JSON string used for idempotency fingerprinting",
"temporary route lease keys store one opaque worker token and use `NOTIFICATION_ROUTE_LEASE_TTL`; they are service-local coordination state rather than durable records",
"score = `next_attempt_at_ms` and member = full Redis route key with encoded dynamic segments",
"`status=pending` and `next_attempt_at_ms = accepted_at_ms`",
"`failed` routes remain scheduled for retry",
"`published`, `dead_letter`, and `skipped` are absent from the schedule",
"only the current lease holder may finalize one due publication attempt",
"after failed attempt `N`, the next delay is `clamp(NOTIFICATION_ROUTE_BACKOFF_MIN * 2^(N-1), NOTIFICATION_ROUTE_BACKOFF_MIN, NOTIFICATION_ROUTE_BACKOFF_MAX)`",
"no jitter is added to the retry delay",
"creates `notification_dead_letter_entry`, and is removed from `notification:route_schedule`",
"`notification_record` and `notification_route` use `NOTIFICATION_RECORD_TTL`",
"`notification_idempotency_record` uses `NOTIFICATION_IDEMPOTENCY_TTL`",
"`notification_dead_letter_entry` and malformed intent records use `NOTIFICATION_DEAD_LETTER_TTL`",
"stream offset records do not use TTL",
}
func TestNotificationRedisDocsStayInSync(t *testing.T) {
t.Parallel()
readme := loadTextFile(t, "README.md")
runtimeDoc := loadTextFile(t, filepath.Join("docs", "runtime.md"))
flowsDoc := loadTextFile(t, filepath.Join("docs", "flows.md"))
runbookDoc := loadTextFile(t, filepath.Join("docs", "runbook.md"))
docsIndex := loadTextFile(t, filepath.Join("docs", "README.md"))
normalizedReadme := normalizeWhitespace(readme)
normalizedRuntimeDoc := normalizeWhitespace(runtimeDoc)
normalizedFlowsDoc := normalizeWhitespace(flowsDoc)
normalizedRunbookDoc := normalizeWhitespace(runbookDoc)
require.Contains(t, docsIndex, "- [Runtime and components](runtime.md)")
require.Contains(t, docsIndex, "- [Main flows](flows.md)")
require.Contains(t, docsIndex, "- [Operator runbook](runbook.md)")
require.Contains(t, readme, expectedNotificationRedisKeyTable)
require.Contains(t, readme, expectedNotificationRedisRecordFieldsTable)
for _, snippet := range expectedNotificationRedisDocumentationSnippets {
normalizedSnippet := normalizeWhitespace(snippet)
require.Contains(t, normalizedReadme, normalizedSnippet)
}
require.Contains(t, normalizedRuntimeDoc, normalizeWhitespace("Redis client with startup connectivity check"))
require.Contains(t, normalizedFlowsDoc, normalizeWhitespace("Retry and Dead Letter"))
require.Contains(t, normalizedRunbookDoc, normalizeWhitespace("Route Schedule Backlog Grows"))
}