79 lines
5.5 KiB
Go
79 lines
5.5 KiB
Go
package notification
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const expectedNotificationRedisKeyTable = `| Logical artifact | Redis key |
|
|
| --- | --- |
|
|
| temporary route lease | ` + "`notification:route_leases:<notification_id>:<route_id>`" + ` |
|
|
| stream offset record | ` + "`notification:stream_offsets:<stream>`" + ` |
|
|
| 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"))
|
|
}
|