package notification import ( "path/filepath" "testing" "github.com/stretchr/testify/require" ) const expectedNotificationRedisKeyTable = `| Logical artifact | Redis key | | --- | --- | | temporary route lease | ` + "`notification:route_leases::`" + ` | | stream offset record | ` + "`notification:stream_offsets:`" + ` | | ingress stream | ` + "`notification:intents`" + ` |` const expectedNotificationPostgresTable = `| Table | Frozen columns | | --- | --- | | ` + "`records`" + ` | ` + "`notification_id`" + `, ` + "`notification_type`" + `, ` + "`producer`" + `, ` + "`audience_kind`" + `, ` + "`recipient_user_ids`" + ` (jsonb), ` + "`payload_json`" + `, ` + "`idempotency_key`" + `, ` + "`request_fingerprint`" + `, ` + "`request_id`" + `, ` + "`trace_id`" + `, ` + "`occurred_at`" + `, ` + "`accepted_at`" + `, ` + "`updated_at`" + `, ` + "`idempotency_expires_at`" + `; ` + "`UNIQUE (producer, idempotency_key)`" + ` | | ` + "`routes`" + ` | ` + "`notification_id`" + `, ` + "`route_id`" + `, ` + "`channel`" + `, ` + "`recipient_ref`" + `, ` + "`status`" + `, ` + "`attempt_count`" + `, ` + "`max_attempts`" + `, ` + "`next_attempt_at`" + `, ` + "`resolved_email`" + `, ` + "`resolved_locale`" + `, ` + "`last_error_classification`" + `, ` + "`last_error_message`" + `, ` + "`last_error_at`" + `, ` + "`created_at`" + `, ` + "`updated_at`" + `, ` + "`published_at`" + `, ` + "`dead_lettered_at`" + `, ` + "`skipped_at`" + `; PRIMARY KEY ` + "`(notification_id, route_id)`" + ` | | ` + "`dead_letters`" + ` | ` + "`notification_id`" + `, ` + "`route_id`" + `, ` + "`channel`" + `, ` + "`recipient_ref`" + `, ` + "`final_attempt_count`" + `, ` + "`max_attempts`" + `, ` + "`failure_classification`" + `, ` + "`failure_message`" + `, ` + "`recovery_hint`" + `, ` + "`created_at`" + `; PRIMARY KEY ` + "`(notification_id, route_id)`" + ` cascading from ` + "`routes`" + ` | | ` + "`malformed_intents`" + ` | ` + "`stream_entry_id`" + `, ` + "`notification_type`" + `, ` + "`producer`" + `, ` + "`idempotency_key`" + `, ` + "`failure_code`" + `, ` + "`failure_message`" + `, ` + "`raw_fields`" + ` (jsonb), ` + "`recorded_at`" + ` |` var expectedNotificationPersistenceDocumentationSnippets = []string{ "the durable `records` row IS the idempotency reservation", "`next_attempt_at` is non-NULL only while the route is a scheduling candidate", "`payload_json` stores the canonical normalized JSON string used for idempotency fingerprinting", "`recipient_user_ids` is JSONB and omitted for `audience_kind=admin_email`", "record-level retention deletes cascade to `routes` and `dead_letters` via `ON DELETE CASCADE`", "dynamic Redis key segments are base64url-encoded", "temporary route lease keys store one opaque worker token and use `NOTIFICATION_ROUTE_LEASE_TTL`", "retained on Redis as a per-replica exclusivity hint atop the SQL claim", "the outbound streams `gateway:client-events` and `mail:delivery_commands` remain Redis Streams", "Notification Service emits one entry through `XADD` before committing the route's PostgreSQL state transition", "`routes_due_idx` (the partial index on `next_attempt_at`) replaces the former `notification:route_schedule` ZSET", "`push` publishers filter for `route_id` prefix `push:`", "`email` publishers filter for prefix `email:`", "only the current lease holder finalises one due publication attempt", "the durable transition is a `Complete*` SQL transaction with optimistic concurrency on `routes.updated_at`", "newly accepted publishable routes enter the partial index immediately", "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`", "`records` and their cascaded `routes` / `dead_letters` use `NOTIFICATION_RECORD_RETENTION`", "the per-record idempotency window (`records.idempotency_expires_at`) uses `NOTIFICATION_IDEMPOTENCY_TTL`", "`malformed_intents` use `NOTIFICATION_MALFORMED_INTENT_RETENTION`", "the retention worker runs once per `NOTIFICATION_CLEANUP_INTERVAL`", "stream offset records do not expire", } 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, expectedNotificationPostgresTable) for _, snippet := range expectedNotificationPersistenceDocumentationSnippets { 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")) }