package notification import ( "path/filepath" "testing" "github.com/stretchr/testify/require" ) const expectedNotificationRedisKeyTable = `| Logical artifact | Redis key | | --- | --- | | ` + "`notification_record`" + ` | ` + "`notification:records:`" + ` | | ` + "`notification_route`" + ` | ` + "`notification:routes::`" + ` | | temporary route lease | ` + "`notification:route_leases::`" + ` | | ` + "`notification_idempotency_record`" + ` | ` + "`notification:idempotency::`" + ` | | ` + "`notification_dead_letter_entry`" + ` | ` + "`notification:dead_letters::`" + ` | | malformed intent record | ` + "`notification:malformed_intents:`" + ` | | stream offset record | ` + "`notification:stream_offsets:`" + ` | | 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:` 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:` for user-targeted routes", "`recipient_ref` is `email:` for configured administrator email routes", "synthetic recipient slot `config:` with one skipped `email` route so the configuration gap remains durable and operator-visible", "`route_id` is mandatory and equals `:`", "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")) }