Initial Commit
This commit is contained in:
565
openspec/specs/api-plugin-notifications/spec.md
Normal file
565
openspec/specs/api-plugin-notifications/spec.md
Normal file
@@ -0,0 +1,565 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-plugin-notifications (add-api-plugin-notifications)
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-notifications`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Plugin factory and lifecycle
|
||||
|
||||
The package SHALL export `createNotificationsPlugin(options?:
|
||||
NotificationsPluginConfigInput, services?: NotificationsPluginServices)`
|
||||
returning a `PluginDescriptor<NotificationsPluginConfig>` that mounts
|
||||
both the `/notifications` and `/push` route groups against
|
||||
`@suaveplan/api`. The factory SHALL Zod-validate input via
|
||||
`NotificationsPluginOptionsSchema` and throw
|
||||
`NotificationsPluginError(INVALID_CONFIG)` carrying the `ZodError`
|
||||
as `cause` on invalid input. `onInit` SHALL register
|
||||
`notificationRepository`, `deviceTokenRepository`, and (when
|
||||
`enablePush`) `apnService` in the host's `ServiceRegistry`. When the
|
||||
caller supplies `services.emailService`, `onInit` SHALL register it
|
||||
under the `emailService` slot. When the config has `enableDispatcher:
|
||||
true`, `onInit` SHALL construct a `NotificationDispatcher` wired with
|
||||
every available channel (`apn` if `apnService` is registered, `email`
|
||||
if `emailService` is registered) and register it under
|
||||
`notificationDispatcher`; the dispatcher SHALL refuse to construct
|
||||
when zero channels are available
|
||||
(`NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)`). `onReady`
|
||||
SHALL mount both route trees and refuse to double-mount. `onClose`
|
||||
SHALL shut down the APN transport (if any), null the dispatcher
|
||||
reference, and clear runtime state.
|
||||
|
||||
**Verified by:** `src/plugin/plugin.test.ts`, `src/schemas/options.test.ts`,
|
||||
`src/api-plugin-notifications.spec-conformance.test.ts`.
|
||||
|
||||
#### Scenario: valid options produce a descriptor
|
||||
|
||||
- **WHEN** `createNotificationsPlugin({ basePath: "/v1/notifications" })` is called
|
||||
- **THEN** the descriptor has `name === "notifications"`, `dependencies === ["database"]`, and `defaults.basePath === "/v1/notifications"`
|
||||
|
||||
#### Scenario: invalid options fail fast
|
||||
|
||||
- **WHEN** `createNotificationsPlugin({ basePath: "" } as never)` is called
|
||||
- **THEN** the call throws `NotificationsPluginError` with `code === NOTIFICATIONS_PLUGIN_CODES.INVALID_CONFIG`
|
||||
|
||||
#### Scenario: onInit registers repositories
|
||||
|
||||
- **WHEN** `onInit(ctx)` runs with a real `db` service in the registry
|
||||
- **THEN** `ctx.getService("notificationRepository")` and `ctx.getService("deviceTokenRepository")` resolve to non-null instances
|
||||
|
||||
#### Scenario: onInit fails without database service
|
||||
|
||||
- **WHEN** `onInit(ctx)` runs with no `db` service registered
|
||||
- **THEN** it throws `NotificationsPluginError(SERVICE_NOT_AVAILABLE)`
|
||||
|
||||
#### Scenario: onInit registers APN when enabled
|
||||
|
||||
- **WHEN** `onInit(ctx)` runs with `enablePush === true` and a valid `apn` config
|
||||
- **THEN** `ctx.getService("apnService")` resolves to an `ApnService` instance whose `isInitialized()` returns `true`
|
||||
|
||||
#### Scenario: onInit fails when enablePush without apn
|
||||
|
||||
- **WHEN** `onInit(ctx)` runs with `enablePush === true` and `apn === undefined`
|
||||
- **THEN** it throws `NotificationsPluginError(APN_CONFIG_MISSING)`
|
||||
|
||||
#### Scenario: onInit registers notificationDispatcher when enableDispatcher is on
|
||||
|
||||
- **WHEN** `onInit(ctx)` runs with `enableDispatcher: true`, `enablePush: true`, and a valid APN config
|
||||
- **THEN** `ctx.getService("notificationDispatcher")` resolves to a `NotificationDispatcher` whose `dispatch({ channel: "apn", … })` succeeds
|
||||
|
||||
#### Scenario: onInit refuses dispatcher with zero channels
|
||||
|
||||
- **WHEN** `onInit(ctx)` runs with `enableDispatcher: true`, `enablePush: false`, and no `emailService` injected
|
||||
- **THEN** it throws `NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)`
|
||||
|
||||
#### Scenario: onReady mounts both route trees
|
||||
|
||||
- **WHEN** `onReady(ctx)` runs after `onInit`
|
||||
- **THEN** `ctx.getService("app").routes` contains entries whose `path` begins with the configured `basePath` AND with the configured `pushBasePath`
|
||||
|
||||
#### Scenario: onReady refuses to double-mount
|
||||
|
||||
- **WHEN** `onReady(ctx)` is invoked twice against the same app instance
|
||||
- **THEN** the second call throws `NotificationsPluginError(ROUTE_PREFIX_COLLISION)`
|
||||
|
||||
#### Scenario: onClose clears repositories AND dispatcher
|
||||
|
||||
- **WHEN** `onClose()` is invoked after a successful boot with `enableDispatcher: true`
|
||||
- **THEN** the dispatcher reference is nulled and subsequent `onReady` invocations against a fresh app raise `SERVICE_NOT_AVAILABLE`
|
||||
|
||||
### Requirement: NotificationRepository contract
|
||||
|
||||
The package SHALL export a `NotificationRepository` class implementing
|
||||
`INotificationRepository` against the genesis `SurrealAdapter`. Every
|
||||
statement SHALL use positional placeholders (`$p0`, `$p1`, …) matching
|
||||
the `DatabaseAdapter.query(sql, params: readonly unknown[])` surface.
|
||||
Twelve operations SHALL be present: `create`, `findById`,
|
||||
`findByRecipient`, `getUnreadCount`, `update`, `markAsRead`,
|
||||
`markManyAsRead`, `markAllAsRead`, `dismiss`, `delete`,
|
||||
`findGroupedByType`, `deleteOldRead`. Record-id normalisation
|
||||
SHALL accept both bare and prefixed ids; SurrealDB `RecordId` row
|
||||
shapes SHALL collapse to string ids inside `transformRecord`.
|
||||
|
||||
**Verified by:** `src/repositories/notification-repository/notification-repository.test.ts`.
|
||||
|
||||
#### Scenario: create returns transformed row
|
||||
|
||||
- **WHEN** `repo.create({ type, recipient, content })` returns a fresh notification id
|
||||
- **THEN** the returned `Notification.id` is a string, even when the adapter returned a `{ tb, id }` RecordId shape
|
||||
|
||||
#### Scenario: empty adapter row raises NOTIFICATION_CREATE_FAILED
|
||||
|
||||
- **WHEN** the adapter returns `[]` on CREATE
|
||||
- **THEN** the repository throws `NotificationsPluginError(NOTIFICATION_CREATE_FAILED)`
|
||||
|
||||
#### Scenario: findByRecipient builds the SurrealQL filter
|
||||
|
||||
- **WHEN** `findByRecipient(userId, siteId, opts)` is called with every option
|
||||
- **THEN** the SurQL string contains `WHERE recipient = type::thing('user', $p0)`, `AND site = type::thing('site', $p…)`, `AND read = false`, `AND type = $p…`, `AND created_at > $p…`, `LIMIT $p…`, and `START $p…`
|
||||
|
||||
#### Scenario: deleteOldRead rejects negative input
|
||||
|
||||
- **WHEN** `deleteOldRead(-1)` is invoked
|
||||
- **THEN** the repository throws `NotificationsPluginError(INVALID_CONFIG)`
|
||||
|
||||
### Requirement: DeviceTokenRepository contract
|
||||
|
||||
The package SHALL export a `DeviceTokenRepository` class implementing
|
||||
`IDeviceTokenRepository`. Eight operations SHALL be present:
|
||||
`register`, `findById`, `findByToken`, `findActiveForUser`,
|
||||
`deactivate`, `deactivateByToken`, `touchLastUsed`, `cleanup`.
|
||||
Duplicate-token detection SHALL fold the SurrealDB UNIQUE-index
|
||||
violation into `DEVICE_TOKEN_DUPLICATE`. Other adapter failures SHALL
|
||||
fold into `DEVICE_TOKEN_CREATE_FAILED`.
|
||||
|
||||
**Verified by:** `src/repositories/device-token-repository/device-token-repository.test.ts`.
|
||||
|
||||
#### Scenario: duplicate token raises DEVICE_TOKEN_DUPLICATE
|
||||
|
||||
- **WHEN** the adapter throws an error mentioning `token` on CREATE
|
||||
- **THEN** the repository throws `NotificationsPluginError(DEVICE_TOKEN_DUPLICATE)`
|
||||
|
||||
#### Scenario: generic create failure raises DEVICE_TOKEN_CREATE_FAILED
|
||||
|
||||
- **WHEN** the adapter throws an unrelated error on CREATE
|
||||
- **THEN** the repository throws `NotificationsPluginError(DEVICE_TOKEN_CREATE_FAILED)`
|
||||
|
||||
#### Scenario: cleanup rejects negative daysOld
|
||||
|
||||
- **WHEN** `cleanup(-1)` is invoked
|
||||
- **THEN** the repository throws `NotificationsPluginError(INVALID_CONFIG)`
|
||||
|
||||
### Requirement: ApnService with pluggable transport
|
||||
|
||||
The package SHALL export an `ApnService` class that validates its
|
||||
config, builds an `ApnTransport` (default:
|
||||
`createMemoryApnTransport`), and exposes
|
||||
`initialize` / `sendNotification` / `sendBulkNotifications` /
|
||||
`shutdown` / `isInitialized`. Per-device failures SHALL appear inside
|
||||
the returned `PushNotificationResult.errors` array; the service SHALL
|
||||
NOT throw for per-device delivery failures. Transport-init errors
|
||||
SHALL fold into `APN_TRANSPORT_INIT_FAILED`; transport-shutdown
|
||||
errors fold into `APN_TRANSPORT_SHUTDOWN_FAILED`. The
|
||||
`MemoryApnTransport` ships as the deterministic default; hosts inject
|
||||
their own transport for production `@parse/node-apn` delivery.
|
||||
|
||||
**Verified by:** `src/services/apn-service/apn-service.test.ts`,
|
||||
`src/transport/apn-transport/apn-transport.test.ts`.
|
||||
|
||||
#### Scenario: missing auth material raises APN_CONFIG_MISSING
|
||||
|
||||
- **WHEN** `initialize()` runs with no `cert`, `key`, `pfx`, or `token`
|
||||
- **THEN** the service throws `NotificationsPluginError(APN_CONFIG_MISSING)`
|
||||
|
||||
#### Scenario: incomplete token raises APN_CONFIG_INVALID
|
||||
|
||||
- **WHEN** `initialize()` runs with `token: { key, keyId: "", teamId }`
|
||||
- **THEN** the service throws `NotificationsPluginError(APN_CONFIG_INVALID)`
|
||||
|
||||
#### Scenario: per-device failure surfaces in the result
|
||||
|
||||
- **WHEN** `sendNotification` is called against a transport whose `failurePlan` maps `bad` → `{ status: 410, reason: "Unregistered" }`
|
||||
- **THEN** the call resolves with `{ sent: 0, failed: 1, errors: [{ status: 410, reason: "Unregistered" }] }` — no throw
|
||||
|
||||
#### Scenario: transport-thrown error folds into a synthetic failure
|
||||
|
||||
- **WHEN** `sendNotification` is called against a transport whose `send` throws
|
||||
- **THEN** the call resolves with `{ sent: 0, failed: 1, errors: [{ error: <message> }] }` — no throw
|
||||
|
||||
#### Scenario: bulk send empty list returns 0/0
|
||||
|
||||
- **WHEN** `sendBulkNotifications([], payload)` is invoked
|
||||
- **THEN** the call resolves with `{ sent: 0, failed: 0 }` without contacting the transport
|
||||
|
||||
### Requirement: Hono OpenAPI route groups
|
||||
|
||||
The package SHALL export `createNotificationsRoutes(config)` and
|
||||
`createPushRoutes(config)` that return `OpenAPIHono` route bundles.
|
||||
Notifications routes SHALL cover 11 endpoints (list, unread-count,
|
||||
create, get, patch, mark-read, mark-many, mark-all, dismiss, delete,
|
||||
grouped-by-type). Push routes SHALL cover 5 endpoints (register
|
||||
token, unregister token, list tokens, send single, send bulk).
|
||||
Every route SHALL require auth via `c.get("user")` and return
|
||||
`401 { error, code: NOTIFICATIONS_PLUGIN.UNAUTHENTICATED }` when
|
||||
missing. Mutating routes SHALL verify the notification belongs to
|
||||
the authenticated user; mismatches return `403 { code: FORBIDDEN }`.
|
||||
Delivery endpoints SHALL return `503` when `apnService === null`
|
||||
(i.e. `enablePush === false`). Every successful mutation SHALL
|
||||
forward a `NotificationsRouteEvent` to the host's `ctx.eventBus`
|
||||
through the injected `emitEvent` callback.
|
||||
|
||||
**Verified by:** `src/routes/notifications-routes/notifications-routes.test.ts`,
|
||||
`src/routes/push-routes/push-routes.test.ts`.
|
||||
|
||||
#### Scenario: missing user returns 401
|
||||
|
||||
- **WHEN** any route is hit without `c.get("user")` set
|
||||
- **THEN** the response is `401` with body `{ error: "Authentication required", code: "NOTIFICATIONS_PLUGIN.UNAUTHENTICATED" }`
|
||||
|
||||
#### Scenario: ownership mismatch returns 403
|
||||
|
||||
- **WHEN** `PATCH /notifications/{id}` is invoked by a user whose id does not match the notification's `recipient`
|
||||
- **THEN** the response is `403` with `code: NOTIFICATIONS_PLUGIN.FORBIDDEN`
|
||||
|
||||
#### Scenario: delivery without APN returns 503
|
||||
|
||||
- **WHEN** `POST /push/send` is invoked with `apnService === null` in the route config
|
||||
- **THEN** the response is `503` with `code: NOTIFICATIONS_PLUGIN.APN_SERVICE_UNAVAILABLE`
|
||||
|
||||
#### Scenario: successful create forwards event-bus event
|
||||
|
||||
- **WHEN** `POST /notifications` succeeds
|
||||
- **THEN** the injected `emitEvent` callback receives `{ type: "notification:created", payload: { notificationId, recipientId, type } }`
|
||||
|
||||
#### Scenario: duplicate device token returns 409
|
||||
|
||||
- **WHEN** `POST /push/tokens` is invoked with a token that triggers `DEVICE_TOKEN_DUPLICATE`
|
||||
- **THEN** the response is `409` with `code: NOTIFICATIONS_PLUGIN.DEVICE_TOKEN_DUPLICATE`
|
||||
|
||||
#### Scenario: bulk send caps recipients at maxBulkRecipients
|
||||
|
||||
- **WHEN** `POST /push/send-bulk` is invoked with `userIds` longer than `maxBulkRecipients`
|
||||
- **THEN** the route processes only the first `maxBulkRecipients` entries; `totalUsers` reflects the cap
|
||||
|
||||
### Requirement: Error-code registry
|
||||
|
||||
The package SHALL export `NOTIFICATIONS_PLUGIN_CODES` (frozen
|
||||
record built by `defineCodes("NOTIFICATIONS_PLUGIN", [...])`) and a
|
||||
`NotificationsPluginError` class extending `SuaveplanError`. Every
|
||||
code SHALL be reachable from at least one production source file;
|
||||
the spec-conformance test SHALL grep the source tree to assert
|
||||
reachability.
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts`.
|
||||
|
||||
#### Scenario: namespace literal is NOTIFICATIONS_PLUGIN
|
||||
|
||||
- **WHEN** every value of `NOTIFICATIONS_PLUGIN_CODES` is inspected
|
||||
- **THEN** each value starts with `"NOTIFICATIONS_PLUGIN."`
|
||||
|
||||
#### Scenario: every code reachable from src/
|
||||
|
||||
- **WHEN** the spec test concatenates every non-test `.ts` under `src/`
|
||||
- **THEN** the concatenation contains `NOTIFICATIONS_PLUGIN_CODES.<KEY>` for every key
|
||||
|
||||
### Requirement: OpenTelemetry instruments
|
||||
|
||||
The package SHALL expose `getNotificationsInstruments()` /
|
||||
`resetNotificationsInstruments()` returning six counters
|
||||
(`notificationsCreatedTotal`, `notificationsReadTotal`,
|
||||
`notificationsDeletedTotal`, `pushSentTotal`, `pushFailedTotal`,
|
||||
`deviceTokensRegisteredTotal`). Each counter SHALL be emitted from
|
||||
at least one production code path and SHALL be observable through
|
||||
an `InMemoryMetricExporter` in the test suite.
|
||||
|
||||
**Verified by:** `src/otel/instruments.test.ts`.
|
||||
|
||||
#### Scenario: bundle is memoised
|
||||
|
||||
- **WHEN** `getNotificationsInstruments()` is called twice without a reset
|
||||
- **THEN** both calls return the same object reference
|
||||
|
||||
#### Scenario: counters emit on flush
|
||||
|
||||
- **WHEN** counters are incremented and `flushTestMetrics()` is called
|
||||
- **THEN** the exporter snapshot contains every counter name
|
||||
|
||||
### Requirement: Service-registry augmentation
|
||||
|
||||
The package SHALL augment
|
||||
`@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
|
||||
interface with three slots: `notificationRepository`
|
||||
(`INotificationRepository`), `deviceTokenRepository`
|
||||
(`IDeviceTokenRepository`), and `apnService` (`ApnService`). The
|
||||
package barrel SHALL side-effect import the augmentation so
|
||||
consumers that import any public symbol pick up the type widening
|
||||
automatically.
|
||||
|
||||
**Verified by:** `src/api-plugin-notifications.spec-conformance.test.ts`.
|
||||
|
||||
#### Scenario: registry slots typed
|
||||
|
||||
- **WHEN** a downstream consumer calls `ctx.getService("notificationRepository")` against the augmented registry
|
||||
- **THEN** the result type narrows to `INotificationRepository | undefined` without a cast
|
||||
|
||||
### Requirement: NotificationDispatcher orchestrator
|
||||
|
||||
The package SHALL export a `NotificationDispatcher` class that
|
||||
resolves `@suaveplan/notification-channel`'s `DeliveryChannel`
|
||||
instances by the dispatcher-facing channel name and exposes a
|
||||
`dispatch(input: DispatchInput): Promise<DispatchResult>` surface.
|
||||
The dispatcher SHALL fan a single input out to one of the registered
|
||||
delivery channels (defaulting to the configured `defaultChannel`),
|
||||
record an OTel counter increment per dispatch
|
||||
(`notificationsDispatchedTotal` on success,
|
||||
`notificationsDispatchFailedTotal` on failure), and forward a
|
||||
`delivery:sent` or `delivery:bounced` event through the configured
|
||||
`DeliveryEventStore` (when one is supplied). Channel selection
|
||||
failures SHALL surface as
|
||||
`NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)`. Underlying
|
||||
transport failures returned by the channel as `success === false`
|
||||
SHALL surface as `NotificationsPluginError(DISPATCH_FAILED)` carrying
|
||||
the channel error as `cause`. Per-dispatch idempotency SHALL be
|
||||
applied through an injected `DispatcherIdempotencyAdapter` whose
|
||||
default implementation (`NoopIdempotencyAdapter`) is the identity.
|
||||
|
||||
**Verified by:** `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch routes to the default channel"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch routes to an explicit channel"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch raises DISPATCH_CHANNEL_UNAVAILABLE for an unknown channel"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch raises DISPATCH_FAILED when the channel returns failed"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch records delivery:sent through the configured store"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch records delivery:bounced when the channel fails"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch emits notificationsDispatchedTotal on success"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch emits notificationsDispatchFailedTotal on failure"`.
|
||||
|
||||
#### Scenario: dispatch routes to the default channel when channel is unspecified
|
||||
|
||||
- **WHEN** `dispatcher.dispatch({ recipient, body })` is called and `defaultChannel === "apn"`
|
||||
- **THEN** the registered APN `DeliveryChannel` receives `send(notification)` and the result `channel === "apn"`
|
||||
|
||||
#### Scenario: dispatch routes to an explicit channel when provided
|
||||
|
||||
- **WHEN** `dispatcher.dispatch({ recipient, body, channel: "email" })` is called against a dispatcher with both `apn` and `email` channels registered
|
||||
- **THEN** only the email channel receives `send`, and the result `channel === "email"`
|
||||
|
||||
#### Scenario: unknown channel raises DISPATCH_CHANNEL_UNAVAILABLE
|
||||
|
||||
- **WHEN** `dispatcher.dispatch({ channel: "sms", … })` is called and no `sms` channel is registered
|
||||
- **THEN** the call rejects with `NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)`
|
||||
|
||||
#### Scenario: channel-returned failure raises DISPATCH_FAILED
|
||||
|
||||
- **WHEN** the underlying channel's `send` returns `{ success: false, error: "transport failed" }`
|
||||
- **THEN** `dispatch` rejects with `NotificationsPluginError(DISPATCH_FAILED)` whose `context.channelError === "transport failed"`
|
||||
|
||||
#### Scenario: delivery-tracking sees a delivery:sent event on success
|
||||
|
||||
- **WHEN** `dispatch` succeeds and a `DeliveryEventStore` is configured
|
||||
- **THEN** the store records a `DeliveryEvent` with `eventType === "sent"`, the dispatcher's tenantId, and `messageId === dispatchResult.id`
|
||||
|
||||
#### Scenario: delivery-tracking sees a delivery:bounced event on failure
|
||||
|
||||
- **WHEN** `dispatch` fails through a channel failure and a `DeliveryEventStore` is configured
|
||||
- **THEN** the store records a `DeliveryEvent` with `eventType === "bounced"`
|
||||
|
||||
#### Scenario: idempotency adapter dedupes repeated dispatches
|
||||
|
||||
- **WHEN** the dispatcher is configured with an adapter that caches by key and `dispatch` is invoked twice with the same key
|
||||
- **THEN** the underlying channel's `send` is called only once
|
||||
|
||||
### Requirement: ApnDeliveryChannel adapter
|
||||
|
||||
The package SHALL export an `ApnDeliveryChannel` class implementing
|
||||
`@suaveplan/notification-channel`'s `DeliveryChannel` interface
|
||||
against the in-package `ApnService`. The channel SHALL be registered
|
||||
under `name === "apn"` and SHALL accept the device token via
|
||||
`Notification.metadata.deviceToken` (string) or
|
||||
`Notification.metadata.deviceTokens` (string array, for fan-out).
|
||||
Missing-token inputs SHALL return
|
||||
`{ success: false, error: "deviceToken metadata required" }`. APN
|
||||
per-device failures SHALL fold into a single `DeliveryResult` whose
|
||||
`success` reflects whether at least one delivery succeeded.
|
||||
|
||||
**Verified by:** `src/channels/apn-channel/apn-channel.test.ts::"sends through ApnService for a single deviceToken"`, `src/channels/apn-channel/apn-channel.test.ts::"fans out to multiple deviceTokens via metadata.deviceTokens"`, `src/channels/apn-channel/apn-channel.test.ts::"returns success false when deviceToken metadata is missing"`, `src/channels/apn-channel/apn-channel.test.ts::"folds APN per-device failures into the DeliveryResult"`, `src/channels/apn-channel/apn-channel.test.ts::"reports canSend true only for push channel notifications"`.
|
||||
|
||||
#### Scenario: single deviceToken in metadata
|
||||
|
||||
- **WHEN** `apnChannel.send({ channel: "push", metadata: { deviceToken: "tok" }, body: "hi", … })` is called
|
||||
- **THEN** `ApnService.sendNotification` is invoked with `payload.deviceToken === "tok"` and the returned `DeliveryResult.success === true`
|
||||
|
||||
#### Scenario: deviceTokens array fans out
|
||||
|
||||
- **WHEN** `apnChannel.send({ channel: "push", metadata: { deviceTokens: ["a","b"] }, body: "hi", … })` is called
|
||||
- **THEN** `ApnService.sendBulkNotifications` is invoked with the two-element array and the returned `DeliveryResult.success === true`
|
||||
|
||||
#### Scenario: missing deviceToken metadata
|
||||
|
||||
- **WHEN** `apnChannel.send` is called with no `deviceToken` and no `deviceTokens`
|
||||
- **THEN** the call resolves with `{ success: false, channel: "apn", error: "deviceToken metadata required" }`
|
||||
|
||||
#### Scenario: APN per-device failure surfaces as DeliveryResult.error
|
||||
|
||||
- **WHEN** the underlying `ApnService.sendNotification` returns `{ sent: 0, failed: 1, errors: [{ error: "Unregistered" }] }`
|
||||
- **THEN** `apnChannel.send` resolves with `success: false` and `error: "Unregistered"`
|
||||
|
||||
#### Scenario: canSend gates on channel
|
||||
|
||||
- **WHEN** `apnChannel.canSend(notification)` is called
|
||||
- **THEN** the result is `true` iff `notification.channel === "push"`
|
||||
|
||||
### Requirement: EmailDeliveryChannel adapter
|
||||
|
||||
The package SHALL export an `EmailDeliveryChannel` class implementing
|
||||
`@suaveplan/notification-channel`'s `DeliveryChannel` interface
|
||||
against `@suaveplan/emailer`'s `EmailService`. The channel SHALL be
|
||||
registered under `name === "email"` and SHALL map
|
||||
`Notification.recipient` → `EmailMessage.to`, `Notification.subject`
|
||||
→ `EmailMessage.subject` (defaulting to the configured
|
||||
`defaultSubject` when missing), `Notification.body` →
|
||||
`EmailMessage.text` (or `html` when `metadata.html === true`).
|
||||
Transport failures returned by `EmailService.send` SHALL surface as
|
||||
`{ success: false, error: <error message> }`. The channel SHALL throw
|
||||
`NotificationsPluginError(EMAIL_SERVICE_UNAVAILABLE)` at construction
|
||||
when `emailService` is `null`.
|
||||
|
||||
**Verified by:** `src/channels/email-channel/email-channel.test.ts::"sends through EmailService with subject + text"`, `src/channels/email-channel/email-channel.test.ts::"falls back to defaultSubject when notification has no subject"`, `src/channels/email-channel/email-channel.test.ts::"sends HTML body when metadata.html is true"`, `src/channels/email-channel/email-channel.test.ts::"folds transport failure into DeliveryResult.error"`, `src/channels/email-channel/email-channel.test.ts::"throws EMAIL_SERVICE_UNAVAILABLE when constructed with null service"`, `src/channels/email-channel/email-channel.test.ts::"canSend gates on channel === email"`.
|
||||
|
||||
#### Scenario: success path with explicit subject
|
||||
|
||||
- **WHEN** `emailChannel.send({ channel: "email", recipient: "x@y.z", subject: "S", body: "B", … })` is called
|
||||
- **THEN** `EmailService.send` receives `{ to: "x@y.z", subject: "S", text: "B" }` and the channel returns `{ success: true, channel: "email" }`
|
||||
|
||||
#### Scenario: default subject fallback
|
||||
|
||||
- **WHEN** the notification has no `subject` and the channel was constructed with `defaultSubject: "Notification"`
|
||||
- **THEN** `EmailService.send` receives `subject: "Notification"`
|
||||
|
||||
#### Scenario: HTML body via metadata
|
||||
|
||||
- **WHEN** `emailChannel.send({ channel: "email", metadata: { html: true }, body: "<p>x</p>", … })` is called
|
||||
- **THEN** `EmailService.send` receives `{ html: "<p>x</p>" }` instead of `text`
|
||||
|
||||
#### Scenario: transport failure
|
||||
|
||||
- **WHEN** the underlying `EmailService.send` returns `{ success: false, error: new Error("smtp dead"), … }`
|
||||
- **THEN** the channel returns `{ success: false, error: "smtp dead" }`
|
||||
|
||||
#### Scenario: null service refused at construction
|
||||
|
||||
- **WHEN** `new EmailDeliveryChannel({ emailService: null, … })` is invoked
|
||||
- **THEN** the constructor throws `NotificationsPluginError(EMAIL_SERVICE_UNAVAILABLE)`
|
||||
|
||||
#### Scenario: canSend gating
|
||||
|
||||
- **WHEN** `emailChannel.canSend({ channel: "email", … })` versus `{ channel: "push", … }`
|
||||
- **THEN** the first returns `true`, the second returns `false`
|
||||
|
||||
### Requirement: DispatcherIdempotencyAdapter contract
|
||||
|
||||
The package SHALL export a `DispatcherIdempotencyAdapter` interface
|
||||
exposing exactly one method
|
||||
`execute<T>(key: string, fn: () => Promise<T>): Promise<T>` and a
|
||||
default `NoopIdempotencyAdapter` class that simply calls `fn()`.
|
||||
Callers SHALL be able to plug `@suaveplan/idempotency`'s
|
||||
`IdempotencyGuard` (whose `execute(key, fn, opts?)` signature is
|
||||
structurally compatible) without the package importing the guard at
|
||||
runtime. The dispatcher SHALL call `adapter.execute(key, () =>
|
||||
underlyingDispatch())` when a `key` is derivable from the input,
|
||||
short-circuiting duplicate dispatches with the adapter's cached
|
||||
return value.
|
||||
|
||||
**Verified by:** `src/dispatcher/idempotency-adapter/idempotency-adapter.test.ts::"NoopIdempotencyAdapter calls fn exactly once"`, `src/dispatcher/idempotency-adapter/idempotency-adapter.test.ts::"a caching adapter dedupes repeated executions"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"idempotency adapter dedupes repeated dispatches"`.
|
||||
|
||||
#### Scenario: noop adapter is the identity
|
||||
|
||||
- **WHEN** `new NoopIdempotencyAdapter().execute("k", fn)` is invoked
|
||||
- **THEN** `fn()` is called exactly once and its result is returned
|
||||
|
||||
#### Scenario: caching adapter dedupes
|
||||
|
||||
- **WHEN** the same `key` is passed twice and the adapter caches by key
|
||||
- **THEN** the underlying `fn` is invoked only once and both calls return the same result
|
||||
|
||||
### Requirement: Dispatch input/result schemas
|
||||
|
||||
The package SHALL export `DispatchInputSchema` and
|
||||
`DispatchResultSchema` (Zod) plus matching `DispatchInput` /
|
||||
`DispatchResult` types. `DispatchInputSchema` SHALL accept
|
||||
`recipient` (non-empty string), `body` (non-empty string), optional
|
||||
`channel` (one of the `notification-channel` channel literals),
|
||||
optional `subject`, optional `metadata` (record),
|
||||
optional `priority`, and an optional `idempotencyKey`. Invalid input
|
||||
SHALL produce `NotificationsPluginError(DISPATCH_INPUT_INVALID)`
|
||||
carrying the `ZodError` as `cause`.
|
||||
|
||||
**Verified by:** `src/schemas/dispatch-schemas.test.ts::"DispatchInputSchema rejects empty recipient"`, `src/schemas/dispatch-schemas.test.ts::"DispatchInputSchema rejects empty body"`, `src/schemas/dispatch-schemas.test.ts::"DispatchInputSchema accepts a minimal payload"`, `src/schemas/dispatch-schemas.test.ts::"DispatchInputSchema accepts idempotencyKey"`, `src/schemas/dispatch-schemas.test.ts::"DispatchResultSchema accepts canonical shape"`.
|
||||
|
||||
#### Scenario: empty recipient rejected
|
||||
|
||||
- **WHEN** `DispatchInputSchema.safeParse({ recipient: "", body: "x" })` is invoked
|
||||
- **THEN** the result `success === false`
|
||||
|
||||
#### Scenario: minimal payload accepted
|
||||
|
||||
- **WHEN** `DispatchInputSchema.safeParse({ recipient: "u", body: "b" })` is invoked
|
||||
- **THEN** the result `success === true`
|
||||
|
||||
### Requirement: Dispatcher error codes wired into production paths
|
||||
|
||||
The registry SHALL include `DISPATCH_FAILED`,
|
||||
`DISPATCH_CHANNEL_UNAVAILABLE`, `DISPATCH_INPUT_INVALID`, and
|
||||
`EMAIL_SERVICE_UNAVAILABLE` codes, and every one SHALL be thrown
|
||||
from at least one production source under `src/`. The
|
||||
`error-codes.test.ts` reachability scan SHALL include all four codes.
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"every NOTIFICATIONS_PLUGIN_CODES value is referenced by src/"`.
|
||||
|
||||
#### Scenario: registry exposes the four dispatcher codes
|
||||
|
||||
- **WHEN** `Object.values(NOTIFICATIONS_PLUGIN_CODES)` is inspected
|
||||
- **THEN** the array contains `NOTIFICATIONS_PLUGIN.DISPATCH_FAILED`, `NOTIFICATIONS_PLUGIN.DISPATCH_CHANNEL_UNAVAILABLE`, `NOTIFICATIONS_PLUGIN.DISPATCH_INPUT_INVALID`, and `NOTIFICATIONS_PLUGIN.EMAIL_SERVICE_UNAVAILABLE`
|
||||
|
||||
#### Scenario: every dispatcher code reachable from src
|
||||
|
||||
- **WHEN** the spec-conformance scan concatenates every non-test `.ts` under `src/`
|
||||
- **THEN** the concatenation contains `NOTIFICATIONS_PLUGIN_CODES.DISPATCH_FAILED`, `…DISPATCH_CHANNEL_UNAVAILABLE`, `…DISPATCH_INPUT_INVALID`, and `…EMAIL_SERVICE_UNAVAILABLE`
|
||||
|
||||
### Requirement: Dispatcher OTel counters
|
||||
|
||||
`getNotificationsInstruments()` SHALL expose two additional counters
|
||||
beyond the original six: `notificationsDispatchedTotal` (incremented
|
||||
by `NotificationDispatcher` on a successful dispatch, with `channel`
|
||||
attribute) and `notificationsDispatchFailedTotal` (incremented on a
|
||||
failed dispatch, with `channel` + `reason` attributes). Both SHALL
|
||||
be observable through an `InMemoryMetricExporter` in the OTel test.
|
||||
|
||||
**Verified by:** `src/otel/instruments.test.ts::"exposes dispatcher counters"`, `src/otel/instruments.test.ts::"dispatcher counters emit on flush"`.
|
||||
|
||||
#### Scenario: instrument bundle exposes the two new counters
|
||||
|
||||
- **WHEN** `getNotificationsInstruments()` is called
|
||||
- **THEN** the returned object has `notificationsDispatchedTotal` and `notificationsDispatchFailedTotal` properties
|
||||
|
||||
#### Scenario: counters appear in the exporter snapshot
|
||||
|
||||
- **WHEN** both counters are incremented and `flushTestMetrics()` is called
|
||||
- **THEN** the exporter snapshot includes both metric names
|
||||
|
||||
### Requirement: Dispatcher service-registry slots
|
||||
|
||||
The package SHALL extend the existing `ServiceRegistry` module
|
||||
augmentation with two optional slots:
|
||||
`notificationDispatcher` (`NotificationDispatcher`) and
|
||||
`emailService` (`EmailService` from `@suaveplan/emailer`). The slots
|
||||
SHALL only be populated when the plugin's `enableDispatcher` option
|
||||
is `true` (dispatcher) or when the host injects an `EmailService`
|
||||
(email).
|
||||
|
||||
**Verified by:** `src/api-plugin-notifications.spec-conformance.test.ts::"registry slots include dispatcher and emailService"`, `src/plugin/plugin.test.ts::"onInit registers notificationDispatcher when enableDispatcher is on"`.
|
||||
|
||||
#### Scenario: dispatcher slot populated when enabled
|
||||
|
||||
- **WHEN** `onInit` runs with `enableDispatcher: true`
|
||||
- **THEN** `ctx.getService("notificationDispatcher")` resolves to a `NotificationDispatcher` instance
|
||||
|
||||
#### Scenario: emailService slot populated when injected
|
||||
|
||||
- **WHEN** the host calls `createNotificationsPlugin({}, { emailService })` and `onInit` runs
|
||||
- **THEN** `ctx.getService("emailService")` resolves to the same `EmailService` instance
|
||||
Reference in New Issue
Block a user