diff --git a/CHANGELOG.md b/CHANGELOG.md index be7e2f3..63a476b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). - Return the full HTTP response from the REST notification handler. Note: With outbox enabled (default), the application's `await notify()` resolves when the message is queued; the return value is only available when `outbox: false`. +- Support for defining notification types via CDS `@notification` annotations as an alternative to `srv/notification-types.json`. +- Support for email delivery channels via `@notification.deliveryChannels` in CDS annotations and `DeliveryChannels` in the JSON format. +- Support for email templates via `@notification.template.email.subject` and `@notification.template.email.html` in CDS annotations, and `EmailSubject` / `EmailHtml` in JSON templates. +- i18n support for CDS annotation string values using `{i18n>key}` syntax. +- Notification types are automatically registered and kept in sync with the notification service on application startup when running in hybrid or production mode. + +### Fixed + +- Fixed `#EnumValue` enum references in `@notification.deliveryChannels` — the `{ "#": "..." }` CSN form produced by the CDS compiler was not handled by `resolveEnum`, causing a `TypeError` at runtime. +- Improved error messages when notification type registration fails, now surfacing the ANS error detail instead of a raw HTTP error dump. - New default `auto` for `cds.env.requires.notifications.authenticationIdentifier`. Each recipient is inspected: UUID values are published with `GlobalUserId`, everything else with `RecipientId`, with a warning when a value is neither a UUID nor an email. The previous values `UserUUID` and `RecipientId` are still supported for an explicit choice. In practice this means the plugin "just works" without configuration: applications can pass emails, UUIDs, or a mix of both, and the correct recipient key is chosen per value — no upfront configuration about Work Zone's authentication identifier required. diff --git a/README.md b/README.md index dfd44de..fdeb54a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Notifications Plugin -The `@cap-js/notifications` package is a [CDS plugin](https://cap.cloud.sap/docs/node.js/cds-plugins#cds-plugin-packages) that provides support for publishing business notifications in SAP Build WorkZone. +The `@cap-js/notifications` package is a [CDS plugin](https://cap.cloud.sap/docs/node.js/cds-plugins#cds-plugin-packages) that provides support for publishing business notifications in SAP Build Work Zone. ### Table of Contents @@ -17,6 +17,7 @@ The `@cap-js/notifications` package is a [CDS plugin](https://cap.cloud.sap/docs - [Code of Conduct](#code-of-conduct) - [Licensing](#licensing) + ## Setup To enable notifications, simply add this self-configuring plugin package to your project: @@ -25,25 +26,26 @@ To enable notifications, simply add this self-configuring plugin package to your npm add @cap-js/notifications ``` -In this guide, we use the [Incidents Management reference sample app](https://github.com/cap-js/incidents-app) as the base, to publish notifications. +In this guide, we use the [Bookshop reference sample app](https://github.com/capire/bookshop) as the basis for publishing notifications. + ## Send Notifications With that you can use the NotificationService as any other CAP Service like so in you event handlers: ```js -const alert = await cds.connect.to('notifications'); +const alert = await cds.connect.to('notifications') ``` You can use the following signature to send the simple notification with title and description ```js alert.notify({ - recipients: [ ...supporters() ], + recipients: [ ...readers() ], priority: "HIGH", - title: "New high priority incident is assigned to you!", - description: "Incident titled 'Engine overheating' created by 'customer X' with priority high is assigned to you!" -}); + title: "New book arrived!", + description: "Book 'Wuthering Heights' has been added to the catalogue." +}) ``` > **Note:** The simple API supports only: `recipients`, `priority`, `title`, and `description`. For advanced properties like `ActorId`, `NavigationTargetObject`, `TargetParameters`, etc., use a [named notification type](#use-notification-types) or the [low-level API](#low-level-notifications-api). @@ -51,42 +53,145 @@ alert.notify({ * **priority** - Priority of the notification, this argument is optional, it defaults to NEUTRAL * **description** - Subtitle for the notification, this argument is optional + ## Use Notification Types +The plugin supports two ways to define notification types. These can be combined with types from both sources are merged at startup. + ### 1. Add notification types -If you want to send custom notifications in your application, you can add the notification types in the `srv/notification-types.json` file. +#### Option A: CDS Annotations + +The recommended approach is to annotate CDS events directly in your service model. The plugin discovers these at startup and registers them as notification types automatically. No separate file is needed. + +Define events in your `srv/` model and annotate them with `@notification`: + +```cds +@description: 'Sent when a book is ordered' +@notification: { + template: { + title : 'Book {{title}} Ordered', + publicTitle : 'Book Ordered', + subtitle : '{{buyer}} ordered {{title}}', + groupedTitle : 'Bookshop Updates' + } +} +@Common.SemanticObject: 'Books' +@Common.SemanticObjectAction: 'display' +event BookOrdered { + title : String; + buyer : String; +} +``` + +Any event with at least one `@notification` annotation (the bare `@notification` flag or any `@notification.*` property) is picked up as a notification type. The notification type key is derived from the event name. Namespace prefixes are stripped, so `my.bookshop.BookOrdered` becomes `BookOrdered`. + +> **Note:** The plugin automatically injects a `recipients` element into every notification event at model-load time, no need to declare it yourself. + +> **Note:** Be sure that the event is contained within a service. This can be done by wrapping the event with a service or using the keyword `using` to include the event within an existing service. + +The following annotations are supported: + +| Annotation | Notification field | +|---|---| +| `@description` | `Description` | +| `@notification.template.title` | `TemplateSensitive` | +| `@notification.template.publicTitle` | `TemplatePublic` | +| `@notification.template.subtitle` | `Subtitle` | +| `@notification.template.groupedTitle` | `TemplateGrouped` | +| `@notification.template.email.subject` | `EmailSubject` | +| `@notification.template.email.html` | `EmailHtml` | +| `@Common.SemanticObject` | `NavigationTargetObject` | +| `@Common.SemanticObjectAction` | `NavigationTargetAction` | + +Annotation values support `{i18n>key}` syntax. Keys are resolved against your project's `_i18n/i18n.properties` English labels at startup: + +```cds +@notification.template.title: '{i18n>BOOK_ORDERED_TITLE}' +@notification.template.subtitle: '{i18n>BOOK_ORDERED_SUBTITLE}' +event BookOrdered { ... } +``` + +**Build integration:** Running `cds build` also processes `@notification`-annotated events and writes a merged `notification-types.json` to the build output. This file combines types derived from your CDS annotations with any types defined in the JSON file. + +#### Option B: JSON file + +As an alternative (or in addition) to CDS annotations, you can define types statically in `srv/notification-types.json`: + +```json +[ + { + "NotificationTypeKey": "BookOrdered", + "NotificationTypeVersion": "1", + "Templates": [ + { + "Language": "en", + "TemplatePublic": "Book Ordered", + "TemplateSensitive": "Book '{{title}}' Ordered", + "TemplateGrouped": "Bookshop Updates", + "TemplateLanguage": "mustache", + "Subtitle": "{{buyer}} ordered {{title}}." + } + ] + } +] +``` + +#### Email Delivery + +To enable email delivery for a notification type, add `deliveryChannels` and email template fields. Both definition approaches support this. + +**Via CDS annotations:** + +```cds +@notification: { + template: { + title : 'Book {{title}} Ordered', + publicTitle : 'Book Ordered', + subtitle : '{{buyer}} ordered {{title}}', + groupedTitle : 'Bookshop Updates', + email: { + subject: 'Your order: {{title}}', + html : '
Thanks for ordering {{title}}!
' + } + }, + deliveryChannels: [{ channel: #Mail, enabled: true, defaultPreference: true, editablePreference: true }] +} +event BookOrdered { ... } +``` -Sample: If you want to send the notification when the incident is resolved, you can modify the `srv/notification-types.json` as below: +**Via JSON:** ```json - [ +{ + "NotificationTypeKey": "BookOrdered", + "Templates": [ { - "NotificationTypeKey": "IncidentResolved", - "NotificationTypeVersion": "1", - "Templates": [ - { - "Language": "en", - "TemplatePublic": "Incident Resolved", - "TemplateSensitive": "Incident '{{title}}' Resolved", - "TemplateGrouped": "Incident Status Update", - "TemplateLanguage": "mustache", - "Subtitle": "Incident from '{{customer}}' resolved by {{user}}." - } - ] + "Language": "en", + "TemplatePublic": "Book Ordered", + "TemplateSensitive": "Book '{{title}}' Ordered", + "TemplateGrouped": "Bookshop Updates", + "TemplateLanguage": "mustache", + "EmailSubject": "Your order: {{title}}", + "EmailHtml": "Thanks for ordering {{title}}!
" } + ], + "DeliveryChannels": [ + { "Type": "MAIL", "Enabled": true, "DefaultPreference": true, "EditablePreference": true } ] +} ``` +> **Note:** Email delivery requires the SAP Alert Notification service with the `business-notifications` plan and a corresponding BTP destination. The `business-notifications` plan enforces that `TemplatePublic` and `TemplateGrouped` are set on all notification types (including those without email). + ### 2. Use pre-defined types in your code like that: ```js - await alert.notify ('IncidentResolved', { - recipients: [ customer.id ], + await alert.notify ('BookOrdered', { + recipients: [ buyer.id ], data: { - customer: customer.info, - title: incident.title, - user: cds.context.user.id, + title: book.title, + buyer: buyer.name, } }) ``` @@ -98,24 +203,28 @@ Sample: If you want to send the notification when the incident is resolved, you * **priority** - Priority of the notification, this argument is optional, it defaults to NEUTRAL * **data** - A key-value pair that is used to fill a placeholder of the notification type template, this argument is optional + ## Test-drive Locally In local environment, when you publish notification, it is mocked to publish the nofication to the console.
+
## Run in Production
#### Notification Destination
-As a pre-requisite to publish the notification, you need to have a [destination](https://help.sap.com/docs/build-work-zone-standard-edition/sap-build-work-zone-standard-edition/enabling-notifications-for-custom-apps-on-sap-btp-cloud-foundry#configure-the-destination-to-the-notifications-service) configured to publish the notification. The plugin is pre-configured to use destination name `SAP_Notifications` by default for hybrid and production environments. You can override this in your
- application's CDS configuration if needed (see Advanced Usage section below).
+As a pre-requisite to publish the notification, you need to have a [destination](https://help.sap.com/docs/build-work-zone-standard-edition/sap-build-work-zone-standard-edition/enabling-notifications-for-custom-apps-on-sap-btp-cloud-foundry#configure-the-destination-to-the-notifications-service) configured to publish the notification. The plugin is pre-configured to use destination name `SAP_Notifications` by default for hybrid and production environments. You can override this in your application's CDS configuration if needed (see [Advanced Usage](#advanced-usage) section below).
#### Integrate with SAP Build Work Zone
-Once application is deployed and integrated with SAP Build Work Zone, you can see the notification under fiori notifications icon!
+Once application is deployed and integrated with SAP Build Work Zone, you can see the notification under Fiori notifications icon!
+#### Notification Type Registration
+
+Notification types are automatically registered and synced with the notification service each time the application starts in hybrid or production mode. No manual `cds build` or content deployment step is required as any additions, changes, or removals to your notification types (whether defined via CDS annotations or JSON) are applied on the next startup.
## Advanced Usage
@@ -134,7 +243,7 @@ To make notification types unique to the application, prefix is added to the typ
- `auto` (default): the recipient key is chosen per recipient. Values matching the UUID format are published with `GlobalUserId`, everything else with `RecipientId`. If a value is neither a UUID nor an email a warning is logged. This allows mixing UUIDs and emails in the same `recipients` array without additional configuration.
- `UserUUID`: always publish with `GlobalUserId`. Use this when the authentication identifier in Work Zone is set to `User ID`.
-- `RecipientId`: always publish with `RecipientId`. Use this when you want to enforce email recipients.
+- `RecipientId`: always publish with `RecipientId`. Use this when recipients are identified by email or login name.
Note, that in order for E-Mail Notifications to be sent for notifications published with a User ID, a destination to the IDS needs to be configured for the lookup of the corresponding email address.
@@ -150,13 +259,12 @@ By using this approach you can send notifications with the predefined parameters
```js
alert.notify({
- recipients: [...supporters()],
- type: "IncidentResolved",
+ recipients: [...readers()],
+ type: "BookOrdered",
priority: 'NEUTRAL',
data: {
- customer: customer.info,
- title: incident.title,
- user: cds.context.user.id,
+ title: book.title,
+ buyer: buyer.name,
},
OriginId: "Example Origin Id",
NotificationTypeVersion: "1",
@@ -171,7 +279,7 @@ alert.notify({
"Value": "string"
}
]
- });
+ })
```
#### Passing the whole notification object
@@ -180,37 +288,39 @@ By using this approach you need to pass the whole notification object as describ
```js
alert.notify({
- NotificationTypeKey: 'IncidentCreated',
+ NotificationTypeKey: 'BookOrdered',
NotificationTypeVersion: '1',
Priority: 'NEUTRAL',
Properties: [
{
- Key: 'name',
+ Key: 'title',
IsSensitive: false,
Language: 'en',
- Value: 'Engine overheating',
+ Value: 'Wuthering Heights',
Type: 'String'
},
{
- Key: 'customer',
+ Key: 'buyer',
IsSensitive: false,
Language: 'en',
- Value: 'Dave',
+ Value: 'reader@bookshop.com',
Type: 'String'
}
],
- Recipients: [{ RecipientId: "supportuser1@mycompany.com" },{ RecipientId: "supportuser2@mycompany.com" }]
-});
+ Recipients: [{ RecipientId: "reader1@bookshop.com" },{ RecipientId: "reader2@bookshop.com" }]
+})
```
## Contributing
This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/cap-js/change-tracking/issues). Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](CONTRIBUTING.md).
+
## Code of Conduct
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. By participating in this project, you agree to abide by its [Code of Conduct](CODE_OF_CONDUCT.md) at all times.
+
## Licensing
Copyright 2023 SAP SE or an SAP affiliate company and contributors. Please see our [LICENSE](LICENSE) for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available [via the REUSE tool](https://api.reuse.software/info/github.com/cap-js/change-tracking).
diff --git a/cds-plugin.js b/cds-plugin.js
index dc41325..98118cd 100644
--- a/cds-plugin.js
+++ b/cds-plugin.js
@@ -1,21 +1,46 @@
-const cds = require("@sap/cds/lib");
+const cds = require("@sap/cds/lib")
+
+cds.on("loaded", m => {
+ for (const def of Object.values(m.definitions)) {
+ if (def.kind !== 'event') continue
+ if (!Object.keys(def).some(k => k === '@notification' || k.startsWith('@notification.'))) continue
+ if (!def.elements) def.elements = {}
+ if (!def.elements.recipients) {
+ def.elements.recipients = { items: { type: 'cds.String' } }
+ }
+ }
+})
if (cds.cli.command === "build") {
// register build plugin
- cds.build?.register?.('notifications', require("./lib/build"));
+ cds.build?.register?.('notifications', require("./lib/build"))
}
else cds.once("served", async () => {
- const { validateNotificationTypes, readFile } = require("./lib/utils");
- const { createNotificationTypesMap } = require("./lib/notificationTypes");
- const production = cds.env.profiles?.includes("production");
+ const { validateNotificationTypes, readFile } = require("./lib/utils")
+ const { createNotificationTypesMap } = require("./lib/notificationTypes")
+ const { notificationTypesFromModel } = require("./lib/compile")
+ const { path } = cds.utils
+ const production = cds.env.profiles?.includes("production")
+
+ const typesPath = cds.env.requires?.notifications?.types
+ const kind = cds.env.requires?.notifications?.kind
+ const needsProcessing = kind === 'notify-to-rest' || !production
+ if (!needsProcessing) return
+
+ const model = cds.context?.model ?? cds.model
+ const notificationTypes = [
+ ...notificationTypesFromModel(model),
+ ...( typesPath ? readFile(typesPath) : [] )
+ ]
- // read notification types
- const notificationTypes = readFile(cds.env.requires?.notifications?.types);
if (validateNotificationTypes(notificationTypes)) {
- if (!production) {
- const notificationTypesMap = createNotificationTypesMap(notificationTypes, true);
- cds.notifications = { local: { types: notificationTypesMap } };
+ if (kind === 'notify-to-rest') {
+ const { processNotificationTypes } = require('./lib/notificationTypes')
+ await processNotificationTypes(notificationTypes)
+ } else if (!production) {
+ const notificationTypesMap = createNotificationTypesMap(notificationTypes, true)
+ cds.notifications = { local: { types: notificationTypesMap } }
}
}
diff --git a/lib/build.js b/lib/build.js
index ab1cd4c..7a27751 100644
--- a/lib/build.js
+++ b/lib/build.js
@@ -1,19 +1,29 @@
const cds = require('@sap/cds')
-const { copy, exists, path } = cds.utils
+const { path } = cds.utils
module.exports = class NotificationsBuildPlugin extends cds.build.Plugin {
static taskDefaults = { src: cds.env.folders.srv }
-
+
static hasTask() {
- const notificationTypesFile = cds.env.requires?.notifications?.types;
- return notificationTypesFile === undefined ? false : exists(notificationTypesFile);
+ return !!cds.env.requires?.notifications
}
async build() {
- if (exists(cds.env.requires.notifications?.types)) {
- const fileName = path.basename(cds.env.requires.notifications.types);
- await copy(cds.env.requires.notifications.types).to(path.join(this.task.dest, fileName));
+ const model = await this.model()
+ if (!model) return
+
+ const { notificationTypesFromModel } = require('./compile')
+ const { readFile } = require('./utils')
+
+ const typesPath = cds.env.requires.notifications?.types
+ const types = [
+ ...notificationTypesFromModel(model),
+ ...(typesPath ? readFile(typesPath) : [])
+ ]
+
+ if (types.length) {
+ await this.write(JSON.stringify(types, null, 2)).to(path.join(this.task.dest, 'notification-types.json'))
}
}
}
\ No newline at end of file
diff --git a/lib/compile.js b/lib/compile.js
new file mode 100644
index 0000000..426d809
--- /dev/null
+++ b/lib/compile.js
@@ -0,0 +1,61 @@
+const cds = require('@sap/cds')
+
+function resolveEnum(val) {
+ if (val && typeof val === 'object') {
+ if ('=' in val) return val['=']
+ if ('#' in val) return val['#']
+ }
+ return val
+}
+
+function notificationTypesFromModel(model) {
+ if (!model) return []
+ const types = []
+
+ for (const def of model.definitions) {
+ if (def.kind !== 'event') continue
+ if (!Object.keys(def).some(k => k === '@notification' || k.startsWith('@notification.'))) continue
+
+ const tmpl = { Language: 'en', TemplateLanguage: 'mustache' }
+ if (def['@description']) tmpl.Description = resolveI18n(def['@description'])
+ if (def['@notification.template.title']) tmpl.TemplateSensitive = resolveI18n(def['@notification.template.title'])
+ if (def['@notification.template.publicTitle']) tmpl.TemplatePublic = resolveI18n(def['@notification.template.publicTitle'])
+ if (def['@notification.template.subtitle']) tmpl.Subtitle = resolveI18n(def['@notification.template.subtitle'])
+ if (def['@notification.template.groupedTitle']) tmpl.TemplateGrouped = resolveI18n(def['@notification.template.groupedTitle'])
+ if (def['@notification.template.email.subject']) tmpl.EmailSubject = resolveI18n(def['@notification.template.email.subject'])
+ if (def['@notification.template.email.html']) tmpl.EmailHtml = resolveI18n(def['@notification.template.email.html'])
+
+ const type = {
+ NotificationTypeKey: def.name.split('.').pop(),
+ NotificationTypeVersion: '1',
+ Templates: [tmpl],
+ }
+
+ if (def['@Common.SemanticObject']) type.NavigationTargetObject = def['@Common.SemanticObject']
+ if (def['@Common.SemanticObjectAction']) type.NavigationTargetAction = def['@Common.SemanticObjectAction']
+
+ if (def['@notification.deliveryChannels']?.length) {
+ type.DeliveryChannels = def['@notification.deliveryChannels'].map(ch => {
+ if (!ch.channel) return null
+ const entry = { Type: resolveEnum(ch.channel).toUpperCase() }
+ if (ch.enabled !== undefined) entry.Enabled = ch.enabled
+ if (ch.defaultPreference !== undefined) entry.DefaultPreference = ch.defaultPreference
+ if (ch.editablePreference !== undefined) entry.EditablePreference = ch.editablePreference
+ return entry
+ }).filter(Boolean)
+ }
+
+ types.push(type)
+ }
+
+ return types
+}
+
+function resolveI18n(value) {
+ if (typeof value !== 'string') return value
+ const match = value.match(/^\{i18n>([^}]+)\}$/)
+ if (!match) return value
+ return cds.i18n?.labels?.at(match[1], 'en') ?? value
+}
+
+module.exports = { notificationTypesFromModel }
diff --git a/lib/content-deployment.js b/lib/content-deployment.js
index 522506c..2d66802 100644
--- a/lib/content-deployment.js
+++ b/lib/content-deployment.js
@@ -1,22 +1,26 @@
-const cds = require("@sap/cds");
-const { validateNotificationTypes, readFile } = require("./utils");
-const { processNotificationTypes } = require("./notificationTypes");
-const { setGlobalLogLevel } = require("@sap-cloud-sdk/util");
+const cds = require("@sap/cds")
+const { validateNotificationTypes, readFile } = require("./utils")
+const { processNotificationTypes } = require("./notificationTypes")
+const { notificationTypesFromModel } = require("./compile")
+const { setGlobalLogLevel } = require("@sap-cloud-sdk/util")
async function deployNotificationTypes() {
- setGlobalLogLevel("error");
+ setGlobalLogLevel("error")
// read notification types
- const filePath = cds.env.requires?.notifications?.types ?? '';
- const notificationTypes = readFile(filePath);
+ const model = await cds.load('*')
+ const filePath = cds.env.requires?.notifications?.types ?? ''
+
+ const notificationTypes = [
+ ...notificationTypesFromModel(model),
+ ...(filePath ? readFile(filePath) : [])
+ ]
if (validateNotificationTypes(notificationTypes)) {
- await processNotificationTypes(notificationTypes);
+ await processNotificationTypes(notificationTypes)
}
}
-deployNotificationTypes();
-
module.exports = {
deployNotificationTypes
}
diff --git a/lib/notificationTypes.js b/lib/notificationTypes.js
index 2b00b18..cc58d00 100644
--- a/lib/notificationTypes.js
+++ b/lib/notificationTypes.js
@@ -1,9 +1,9 @@
-const { executeHttpRequest } = require("@sap-cloud-sdk/http-client");
-const { buildHeadersForDestination } = require("@sap-cloud-sdk/connectivity");
-const { getNotificationDestination, getPrefix, getNotificationTypesKeyWithPrefix } = require("./utils");
-const NOTIFICATION_TYPES_API_ENDPOINT = "v2/NotificationType.svc";
-const cds = require("@sap/cds");
-const LOG = cds.log('notifications');
+const { executeHttpRequest } = require("@sap-cloud-sdk/http-client")
+const { buildHeadersForDestination } = require("@sap-cloud-sdk/connectivity")
+const { getNotificationDestination, getPrefix, getNotificationTypesKeyWithPrefix } = require("./utils")
+const NOTIFICATION_TYPES_API_ENDPOINT = "v2/NotificationType.svc"
+const cds = require("@sap/cds")
+const LOG = cds.log('notifications')
const defaultTemplate = {
NotificationTypeKey: "Default",
@@ -19,146 +19,152 @@ const defaultTemplate = {
Subtitle: "{{description}}"
}
]
-};
+}
function fromOdataArrayFormat(objectInArray) {
if (objectInArray === undefined || objectInArray === null || Array.isArray(objectInArray)) {
- return (objectInArray === undefined || objectInArray === null) ? [] : objectInArray;
+ return (objectInArray === undefined || objectInArray === null) ? [] : objectInArray
} else {
- return (objectInArray.results === undefined || objectInArray.results === null) ? [] : objectInArray.results;
+ return (objectInArray.results === undefined || objectInArray.results === null) ? [] : objectInArray.results
}
}
function createNotificationTypesMap(notificationTypesJSON, isLocal = false) {
- const types = {};
+ const types = {}
if (isLocal) {
- types["Default"] = { "1": defaultTemplate };
+ types["Default"] = { "1": defaultTemplate }
}
// add user provided templates
notificationTypesJSON.forEach((notificationType) => {
// set default NotificationTypeVersion if required
if (notificationType.NotificationTypeVersion === undefined) {
- notificationType.NotificationTypeVersion = "1";
+ notificationType.NotificationTypeVersion = "1"
}
- const notificationTypeKeyWithPrefix = getNotificationTypesKeyWithPrefix(notificationType.NotificationTypeKey);
+ const notificationTypeKeyWithPrefix = getNotificationTypesKeyWithPrefix(notificationType.NotificationTypeKey)
// update the notification type key with prefix
- notificationType.NotificationTypeKey = notificationTypeKeyWithPrefix;
+ notificationType.NotificationTypeKey = notificationTypeKeyWithPrefix
if (!(notificationTypeKeyWithPrefix in types)) {
- types[notificationTypeKeyWithPrefix] = {};
+ types[notificationTypeKeyWithPrefix] = {}
}
- types[notificationTypeKeyWithPrefix][notificationType.NotificationTypeVersion] = notificationType;
- });
+ types[notificationTypeKeyWithPrefix][notificationType.NotificationTypeVersion] = notificationType
+ })
- return types;
+ return types
}
async function getNotificationTypes() {
- const notificationDestination = await getNotificationDestination();
+ const notificationDestination = await getNotificationDestination()
const response = await executeHttpRequest(notificationDestination, {
url: `${NOTIFICATION_TYPES_API_ENDPOINT}/NotificationTypes?$format=json&$expand=Templates,Actions,DeliveryChannels`,
method: "get"
- });
- return response.data.d.results;
+ })
+ return response.data.d.results
}
async function createNotificationType(notificationType) {
- const notificationDestination = await getNotificationDestination();
+ const notificationDestination = await getNotificationDestination()
const csrfHeaders = await buildHeadersForDestination(notificationDestination, {
url: NOTIFICATION_TYPES_API_ENDPOINT
- });
+ })
LOG._warn && LOG.warn(
`Notification Type of key ${notificationType.NotificationTypeKey} and version ${notificationType.NotificationTypeVersion} was not found. Creating it...`
- );
-
- const response = await executeHttpRequest(notificationDestination, {
- url: `${NOTIFICATION_TYPES_API_ENDPOINT}/NotificationTypes`,
- method: "post",
- data: notificationType,
- headers: csrfHeaders
- });
- return response.data?.d ?? response;
+ )
+
+ let response
+ try {
+ response = await executeHttpRequest(notificationDestination, {
+ url: `${NOTIFICATION_TYPES_API_ENDPOINT}/NotificationTypes`,
+ method: "post",
+ data: notificationType,
+ headers: csrfHeaders
+ })
+ } catch (err) {
+ const message = err.response?.data?.error?.message?.value ?? err.message
+ throw new Error(`Failed to create notification type ${notificationType.NotificationTypeKey}: ${message}`)
+ }
+ return response.data?.d ?? response
}
async function updateNotificationType(id, notificationType) {
- const notificationDestination = await getNotificationDestination();
+ const notificationDestination = await getNotificationDestination()
const csrfHeaders = await buildHeadersForDestination(notificationDestination, {
url: NOTIFICATION_TYPES_API_ENDPOINT
- });
+ })
LOG._info && LOG.info(
`Detected change in notification type of key ${notificationType.NotificationTypeKey} and version ${notificationType.NotificationTypeVersion}. Updating it...`
- );
+ )
const response = await executeHttpRequest(notificationDestination, {
url: `${NOTIFICATION_TYPES_API_ENDPOINT}/NotificationTypes(guid'${id}')`,
method: "patch",
data: notificationType,
headers: csrfHeaders
- });
- return response.status;
+ })
+ return response.status
}
async function deleteNotificationType(notificationType) {
- const notificationDestination = await getNotificationDestination();
+ const notificationDestination = await getNotificationDestination()
const csrfHeaders = await buildHeadersForDestination(notificationDestination, {
url: NOTIFICATION_TYPES_API_ENDPOINT
- });
+ })
LOG._info && LOG.info(
`Notification Type of key ${notificationType.NotificationTypeKey} and version ${notificationType.NotificationTypeVersion} not present in the types file. Deleting it...`
- );
+ )
const response = await executeHttpRequest(notificationDestination, {
url: `${NOTIFICATION_TYPES_API_ENDPOINT}/NotificationTypes(guid'${notificationType.NotificationTypeId}')`,
method: "delete",
headers: csrfHeaders
- });
- return response.status;
+ })
+ return response.status
}
function _createChannelsMap(channels) {
- const channelMap = {};
+ const channelMap = {}
channels.forEach((channel) => {
- channelMap[channel.Type] = channel;
- });
+ channelMap[channel.Type] = channel
+ })
- return channelMap;
+ return channelMap
}
function areDeliveryChannelsEqual(oldChannels, newChannels) {
if (oldChannels.length !== newChannels.length) {
- return false;
+ return false
}
- const oldChannelsMap = _createChannelsMap(oldChannels);
- const newChannelsMap = _createChannelsMap(newChannels);
+ const oldChannelsMap = _createChannelsMap(oldChannels)
+ const newChannelsMap = _createChannelsMap(newChannels)
for (let type of Object.keys(oldChannelsMap)) {
- if (!(type in newChannelsMap)) return false;
+ if (!(type in newChannelsMap)) return false
- const oldChannel = oldChannelsMap[type];
- const newChannel = newChannelsMap[type];
+ const oldChannel = oldChannelsMap[type]
+ const newChannel = newChannelsMap[type]
// TODO: Check if language is not there
const equal =
oldChannel.Type == newChannel.Type.toUpperCase() &&
oldChannel.Enabled == newChannel.Enabled &&
oldChannel.DefaultPreference == newChannel.DefaultPreference &&
- oldChannel.EditablePreference == newChannel.EditablePreference;
+ oldChannel.EditablePreference == newChannel.EditablePreference
- if (!equal) return false;
- delete newChannelsMap[type];
+ if (!equal) return false
+ delete newChannelsMap[type]
}
- return Object.keys(newChannelsMap).length == 0;
+ return Object.keys(newChannelsMap).length == 0
}
function isActionEqual(oldAction, newAction) {
@@ -168,28 +174,28 @@ function isActionEqual(oldAction, newAction) {
oldAction.ActionText == newAction.ActionText &&
oldAction.GroupActionText == newAction.GroupActionText &&
oldAction.Nature == newAction.Nature
- );
+ )
}
function areActionsEqual(oldActions, newActions) {
if (oldActions.length !== newActions.length) {
- return false;
+ return false
}
- let matchFound = false;
+ let matchFound = false
for (const oldAction of oldActions) {
for (const newAction of newActions) {
if (isActionEqual(oldAction, newAction)) {
- matchFound = true;
- break;
+ matchFound = true
+ break
}
}
if (!matchFound) {
- return false;
+ return false
}
}
- return true;
+ return true
}
function isTemplateEqual(oldTemplate, newTemplate) {
@@ -204,33 +210,33 @@ function isTemplateEqual(oldTemplate, newTemplate) {
oldTemplate.EmailSubject == newTemplate.EmailSubject &&
oldTemplate.EmailText == newTemplate.EmailText &&
oldTemplate.EmailHtml == newTemplate.EmailHtml
- );
+ )
}
function areTemplatesEqual(oldTemplates, newTemplates) {
if (oldTemplates.length !== newTemplates.length) {
- return false;
+ return false
}
- let matchFound = false;
+ let matchFound = false
for (const oldTemplate of oldTemplates) {
for (const newTemplate of newTemplates) {
if (isTemplateEqual(oldTemplate, newTemplate)) {
- matchFound = true;
- break;
+ matchFound = true
+ break
}
}
if (!matchFound) {
- return false;
+ return false
}
}
- return true;
+ return true
}
function isNotificationTypeEqual(oldNotificationType, newNotificationType) {
if (newNotificationType.IsGroupable === undefined) {
- newNotificationType.IsGroupable = true;
+ newNotificationType.IsGroupable = true
}
return (
@@ -238,27 +244,27 @@ function isNotificationTypeEqual(oldNotificationType, newNotificationType) {
areTemplatesEqual(fromOdataArrayFormat(oldNotificationType.Templates), fromOdataArrayFormat(newNotificationType.Templates)) &&
areActionsEqual(fromOdataArrayFormat(oldNotificationType.Actions), fromOdataArrayFormat(newNotificationType.Actions)) &&
areDeliveryChannelsEqual(fromOdataArrayFormat(oldNotificationType.DeliveryChannels), fromOdataArrayFormat(newNotificationType.DeliveryChannels))
- );
+ )
}
async function processNotificationTypes(notificationTypesJSON) {
- const notificationTypes = createNotificationTypesMap(notificationTypesJSON);
- const prefix = getPrefix();
- let defaultTemplateExists = false;
+ const notificationTypes = createNotificationTypesMap(notificationTypesJSON)
+ const prefix = getPrefix()
+ let defaultTemplateExists = false
// get notficationTypes
- const existingTypes = await getNotificationTypes();
+ const existingTypes = await getNotificationTypes()
// iterate through notification types
for (const existingType of existingTypes) {
if (existingType.NotificationTypeKey == "Default") {
- defaultTemplateExists = true;
- continue;
+ defaultTemplateExists = true
+ continue
}
if (!existingType.NotificationTypeKey.startsWith(`${prefix}/`)) {
- LOG._info && LOG.info(`Skipping Notification Type of other application: ${existingType.NotificationTypeKey}.`);
- continue;
+ LOG._info && LOG.info(`Skipping Notification Type of other application: ${existingType.NotificationTypeKey}.`)
+ continue
}
// if the type isn't present in the JSON file, delete it
@@ -266,37 +272,37 @@ async function processNotificationTypes(notificationTypesJSON) {
notificationTypes[existingType.NotificationTypeKey] === undefined ||
notificationTypes[existingType.NotificationTypeKey][existingType.NotificationTypeVersion] === undefined
) {
- await deleteNotificationType(existingType);
- continue;
+ await deleteNotificationType(existingType)
+ continue
}
- const newType = JSON.parse(JSON.stringify(notificationTypes[existingType.NotificationTypeKey][existingType.NotificationTypeVersion]));
+ const newType = JSON.parse(JSON.stringify(notificationTypes[existingType.NotificationTypeKey][existingType.NotificationTypeVersion]))
// if the type is there then verify if everything is same or not
if (!isNotificationTypeEqual(existingType, newType)) {
- await updateNotificationType(existingType.NotificationTypeId, notificationTypes[existingType.NotificationTypeKey][existingType.NotificationTypeVersion]);
+ await updateNotificationType(existingType.NotificationTypeId, notificationTypes[existingType.NotificationTypeKey][existingType.NotificationTypeVersion])
} else {
LOG._info && LOG.info(
`Notification Type of key ${existingType.NotificationTypeKey} and version ${existingType.NotificationTypeVersion} unchanged.`
- );
+ )
}
- delete notificationTypes[existingType.NotificationTypeKey][existingType.NotificationTypeVersion];
+ delete notificationTypes[existingType.NotificationTypeKey][existingType.NotificationTypeVersion]
if (Object.keys(notificationTypes[existingType.NotificationTypeKey]).length == 0) {
- delete notificationTypes[existingType.NotificationTypeKey];
+ delete notificationTypes[existingType.NotificationTypeKey]
}
}
// create default template if required
if (!defaultTemplateExists) {
- await createNotificationType(defaultTemplate);
+ await createNotificationType(defaultTemplate)
}
// create notification types that aren't there
for (const notificationTypeKey in notificationTypes) {
for (const notificationTypeVersion in notificationTypes[notificationTypeKey]) {
- await createNotificationType(notificationTypes[notificationTypeKey][notificationTypeVersion]);
+ await createNotificationType(notificationTypes[notificationTypeKey][notificationTypeVersion])
}
}
}
diff --git a/lib/utils.js b/lib/utils.js
index 510defb..375990a 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -1,11 +1,11 @@
-const { existsSync, readFileSync } = require('fs');
-const { basename } = require('path');
-const cds = require("@sap/cds");
-const LOG = cds.log('notifications');
-const { getDestination } = require("@sap-cloud-sdk/connectivity");
-const PRIORITIES = ["LOW", "NEUTRAL", "MEDIUM", "HIGH"];
-const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
-const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const { existsSync, readFileSync } = require('fs')
+const { basename } = require('path')
+const cds = require("@sap/cds")
+const LOG = cds.log('notifications')
+const { getDestination } = require("@sap-cloud-sdk/connectivity")
+const PRIORITIES = ["LOW", "NEUTRAL", "MEDIUM", "HIGH"]
+const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const messages = {
TYPES_FILE_NOT_EXISTS: "Notification Types file path is incorrect.",
@@ -21,113 +21,113 @@ const messages = {
PAYLOAD_IS_NOT_OBJECT: "Payload is not an object.",
EMPTY_OBJECT_FOR_NOTIFY: "Empty object is passed a single parameter to notify function.",
NO_OBJECT_FOR_NOTIFY: "An object must be passed to notify function."
-};
+}
function validateNotificationTypes(notificationTypes) {
for(let notificationType of notificationTypes){
if (!("NotificationTypeKey" in notificationType)) {
- LOG._warn && LOG.warn(messages.INVALID_NOTIFICATION_TYPES);
- return false;
+ LOG._warn && LOG.warn(messages.INVALID_NOTIFICATION_TYPES)
+ return false
}
}
- return true;
+ return true
}
function validateDefaultNotifyParameters(recipients, priority, title, description) {
if (!recipients || !title) {
- LOG._warn && LOG.warn(messages.MANDATORY_PARAMETER_NOT_PASSED_FOR_DEFAULT_NOTIFICATION);
- return false;
+ LOG._warn && LOG.warn(messages.MANDATORY_PARAMETER_NOT_PASSED_FOR_DEFAULT_NOTIFICATION)
+ return false
}
if (!Array.isArray(recipients) || recipients.length == 0) {
- LOG._warn && LOG.warn(messages.RECIPIENTS_IS_NOT_ARRAY);
- return false;
+ LOG._warn && LOG.warn(messages.RECIPIENTS_IS_NOT_ARRAY)
+ return false
}
if (typeof title !== "string") {
- LOG._warn && LOG.warn(messages.TITLE_IS_NOT_STRING);
- return false;
+ LOG._warn && LOG.warn(messages.TITLE_IS_NOT_STRING)
+ return false
}
if (priority && !PRIORITIES.includes(priority.toUpperCase())) {
- LOG._warn && LOG.warn(`Invalid priority ${priority}. Allowed priorities are LOW, NEUTRAL, MEDIUM, HIGH`);
- return false;
+ LOG._warn && LOG.warn(`Invalid priority ${priority}. Allowed priorities are LOW, NEUTRAL, MEDIUM, HIGH`)
+ return false
}
if (description && typeof description !== "string") {
- LOG._warn && LOG.warn(messages.DESCRIPTION_IS_NOT_STRING);
- return false;
+ LOG._warn && LOG.warn(messages.DESCRIPTION_IS_NOT_STRING)
+ return false
}
- return true;
+ return true
}
function validateCustomNotifyParameters(type, recipients, properties, navigation, priority, payload) {
if (!recipients) {
- LOG._warn && LOG.warn(messages.MANDATORY_PARAMETER_NOT_PASSED_FOR_CUSTOM_NOTIFICATION);
- return false;
+ LOG._warn && LOG.warn(messages.MANDATORY_PARAMETER_NOT_PASSED_FOR_CUSTOM_NOTIFICATION)
+ return false
}
if (!Array.isArray(recipients) || recipients.length == 0) {
- LOG._warn && LOG.warn(messages.RECIPIENTS_IS_NOT_ARRAY);
- return false;
+ LOG._warn && LOG.warn(messages.RECIPIENTS_IS_NOT_ARRAY)
+ return false
}
if (priority && !PRIORITIES.includes(priority.toUpperCase())) {
- LOG._warn && LOG.warn(`Invalid priority ${priority}. Allowed priorities are LOW, NEUTRAL, MEDIUM, HIGH`);
- return false;
+ LOG._warn && LOG.warn(`Invalid priority ${priority}. Allowed priorities are LOW, NEUTRAL, MEDIUM, HIGH`)
+ return false
}
if (properties && !Array.isArray(properties)) {
- LOG._warn && LOG.warn(messages.PROPERTIES_IS_NOT_OBJECT);
- return false;
+ LOG._warn && LOG.warn(messages.PROPERTIES_IS_NOT_OBJECT)
+ return false
}
if (navigation && typeof navigation !== "object") {
- LOG._warn && LOG.warn(messages.NAVIGATION_IS_NOT_OBJECT);
- return false;
+ LOG._warn && LOG.warn(messages.NAVIGATION_IS_NOT_OBJECT)
+ return false
}
if (payload && typeof payload !== "object") {
- LOG._warn && LOG.warn(messages.PAYLOAD_IS_NOT_OBJECT);
- return false;
+ LOG._warn && LOG.warn(messages.PAYLOAD_IS_NOT_OBJECT)
+ return false
}
- return true;
+ return true
}
function readFile(filePath) {
- const resolvedPath = cds.utils.path.resolve(cds.root, filePath);
+ const resolvedPath = cds.utils.path.resolve(cds.root, filePath)
if (!existsSync(resolvedPath)) {
- LOG._warn && LOG.warn(messages.TYPES_FILE_NOT_EXISTS);
- return [];
+ LOG._warn && LOG.warn(messages.TYPES_FILE_NOT_EXISTS)
+ return []
}
- return JSON.parse(readFileSync(resolvedPath));
+ return JSON.parse(readFileSync(resolvedPath))
}
async function getNotificationDestination() {
- const destinationName = cds.env.requires.notifications?.destination ?? "SAP_Notifications";
- const notificationDestination = await getDestination({ destinationName, useCache: true });
+ const destinationName = cds.env.requires.notifications?.destination ?? "SAP_Notifications"
+ const notificationDestination = await getDestination({ destinationName, useCache: true })
if (!notificationDestination) {
// TODO: What to do if destination isn't found??
- throw new Error(messages.DESTINATION_NOT_FOUND + destinationName);
+ throw new Error(messages.DESTINATION_NOT_FOUND + destinationName)
}
- return notificationDestination;
+ return notificationDestination
}
function getRecipientKey(recipient) {
- const authenticationIdentifier = cds.env.requires.notifications?.authenticationIdentifier ?? 'auto';
- if (authenticationIdentifier === 'UserUUID') return 'GlobalUserId';
- if (authenticationIdentifier === 'RecipientId') return 'RecipientId';
+ const authenticationIdentifier = cds.env.requires.notifications?.authenticationIdentifier ?? 'auto'
+ if (authenticationIdentifier === 'UserUUID') return 'GlobalUserId'
+ if (authenticationIdentifier === 'RecipientId') return 'RecipientId'
// 'auto' (and any unrecognized value): detect format per recipient
- if (typeof recipient === 'string' && UUID_REGEX.test(recipient)) return 'GlobalUserId';
+ if (typeof recipient === 'string' && UUID_REGEX.test(recipient)) return 'GlobalUserId'
if (typeof recipient === 'string' && !EMAIL_REGEX.test(recipient)) {
- LOG._warn && LOG.warn(`Recipient '${recipient}' is neither a UUID nor an email format. Falling back to RecipientId.`);
+ LOG._warn && LOG.warn(`Recipient '${recipient}' is neither a UUID nor an email format. Falling back to RecipientId.`)
}
- return 'RecipientId';
+ return 'RecipientId'
}
let prefix // be filled in below...
@@ -143,8 +143,8 @@ function getPrefix() {
}
function getNotificationTypesKeyWithPrefix(notificationTypeKey) {
- const prefix = getPrefix();
- return `${prefix}/${notificationTypeKey}`;
+ const prefix = getPrefix()
+ return `${prefix}/${notificationTypeKey}`
}
function buildDefaultNotification(
@@ -168,7 +168,7 @@ function buildDefaultNotification(
Type: "String",
IsSensitive: false,
},
- ];
+ ]
return {
NotificationTypeKey: "Default",
@@ -176,7 +176,7 @@ function buildDefaultNotification(
Priority: priority,
Properties: properties,
Recipients: recipients.map((recipient) => ({ [getRecipientKey(recipient)]: recipient }))
- };
+ }
}
function buildCustomNotification(_) {
@@ -207,11 +207,11 @@ function buildCustomNotification(_) {
}
function buildNotification(notificationData) {
- let notification;
+ let notification
if (Object.keys(notificationData).length === 0) {
- LOG._warn && LOG.warn(messages.EMPTY_OBJECT_FOR_NOTIFY);
- return;
+ LOG._warn && LOG.warn(messages.EMPTY_OBJECT_FOR_NOTIFY)
+ return
}
if (notificationData.type) {
@@ -223,13 +223,13 @@ function buildNotification(notificationData) {
notificationData.priority,
notificationData.payload)
) {
- return;
+ return
}
- notification = buildCustomNotification(notificationData);
+ notification = buildCustomNotification(notificationData)
} else if (notificationData.NotificationTypeKey) {
- notificationData.NotificationTypeKey = getNotificationTypesKeyWithPrefix(notificationData.NotificationTypeKey);
- notification = notificationData;
+ notificationData.NotificationTypeKey = getNotificationTypesKeyWithPrefix(notificationData.NotificationTypeKey)
+ notification = notificationData
} else {
if (!validateDefaultNotifyParameters(
notificationData.recipients,
@@ -237,7 +237,7 @@ function buildNotification(notificationData) {
notificationData.title,
notificationData.description)
) {
- return;
+ return
}
notification = buildDefaultNotification(
@@ -245,10 +245,10 @@ function buildNotification(notificationData) {
notificationData.priority,
notificationData.title,
notificationData.description
- );
+ )
}
- return JSON.parse(JSON.stringify(notification));
+ return JSON.parse(JSON.stringify(notification))
}
module.exports = {
@@ -259,4 +259,4 @@ module.exports = {
getPrefix,
getNotificationTypesKeyWithPrefix,
buildNotification
-};
+}
diff --git a/srv/notifyToConsole.js b/srv/notifyToConsole.js
index bae1747..b10eb23 100644
--- a/srv/notifyToConsole.js
+++ b/srv/notifyToConsole.js
@@ -1,13 +1,14 @@
-const NotificationService = require('./service');
-const cds = require("@sap/cds");
-const LOG = cds.log('notifications');
+const NotificationService = require('./service')
+const cds = require("@sap/cds")
+const LOG = cds.log('notifications')
module.exports = class NotifyToConsole extends NotificationService {
async init() {
this.on("*", req => {
LOG._debug && LOG.debug('Handling notification event:', req.event)
- const notification = req.data; if (!notification) return
+ const notification = req.data
+ if (!notification) return
LOG.info (
'\n---------------------------------------------------------------\n' +
'Notification:', req.event,
@@ -21,14 +22,14 @@ module.exports = class NotifyToConsole extends NotificationService {
if (!(NotificationTypeKey in types)) {
LOG._warn && LOG.warn(
`Notification Type ${NotificationTypeKey} is not in the notification types file`
- );
- return;
+ )
+ return
}
if (!(NotificationTypeVersion in types[NotificationTypeKey])) {
LOG._warn && LOG.warn(
`Notification Type Version ${NotificationTypeVersion} for type ${NotificationTypeKey} is not in the notification types file`
- );
+ )
}
})
diff --git a/srv/notifyToRest.js b/srv/notifyToRest.js
index 1491a0a..28a21e0 100644
--- a/srv/notifyToRest.js
+++ b/srv/notifyToRest.js
@@ -1,11 +1,11 @@
const NotificationService = require("./service")
-const { buildHeadersForDestination } = require("@sap-cloud-sdk/connectivity");
-const { executeHttpRequest } = require("@sap-cloud-sdk/http-client");
-const { getNotificationDestination } = require("../lib/utils");
-const cds = require("@sap/cds");
-const LOG = cds.log('notifications');
-const NOTIFICATIONS_API_ENDPOINT = "v2/Notification.svc";
+const { buildHeadersForDestination } = require("@sap-cloud-sdk/connectivity")
+const { executeHttpRequest } = require("@sap-cloud-sdk/http-client")
+const { getNotificationDestination } = require("../lib/utils")
+const cds = require("@sap/cds")
+const LOG = cds.log('notifications')
+const NOTIFICATIONS_API_ENDPOINT = "v2/Notification.svc"
module.exports = exports = class NotifyToRest extends NotificationService {
@@ -15,37 +15,37 @@ module.exports = exports = class NotifyToRest extends NotificationService {
}
async postNotification(notificationData) {
- const notificationDestination = await getNotificationDestination();
+ const notificationDestination = await getNotificationDestination()
const csrfHeaders = await buildHeadersForDestination(notificationDestination, {
url: NOTIFICATIONS_API_ENDPOINT,
- });
+ })
try {
LOG._info && LOG.info(
`Sending notification of key: ${notificationData.NotificationTypeKey} and version: ${notificationData.NotificationTypeVersion}`
- );
+ )
const response = await executeHttpRequest(notificationDestination, {
url: `${NOTIFICATIONS_API_ENDPOINT}/Notifications`,
method: "post",
data: notificationData,
headers: { ...csrfHeaders, Accept: "application/json" },
- });
+ })
if (LOG._debug) {
LOG.debug("Notification sent", {
body: response?.data,
headers: response?.headers,
- });
+ })
}
- return response;
+ return response
} catch (err) {
- const message = err.response.data?.error?.message?.value ?? err.response.message;
- const error = new cds.error(message);
+ const message = err.response.data?.error?.message?.value ?? err.response.message
+ const error = new cds.error(message)
if (/^4\d\d$/.test(err.response?.status) && err.response?.status != 429) {
- error.unrecoverable = true;
+ error.unrecoverable = true
}
- throw error;
+ throw error
}
}
}
diff --git a/srv/service.js b/srv/service.js
index 8553124..168b74c 100644
--- a/srv/service.js
+++ b/srv/service.js
@@ -1,6 +1,6 @@
const { buildNotification, messages } = require("./../lib/utils")
const cds = require('@sap/cds')
-const LOG = cds.log('notifications');
+const LOG = cds.log('notifications')
class NotificationService extends cds.Service {
@@ -11,8 +11,8 @@ class NotificationService extends cds.Service {
*/
emit (event, message) {
if (!event) {
- LOG._warn && LOG.warn(messages.NO_OBJECT_FOR_NOTIFY);
- return;
+ LOG._warn && LOG.warn(messages.NO_OBJECT_FOR_NOTIFY)
+ return
}
// Outbox calls us with a req object, e.g. { event, data, headers }
if (event.event) return super.emit (event)
diff --git a/tests/bookshop/.gitignore b/tests/bookshop/.gitignore
index eb69605..b6e9f2d 100644
--- a/tests/bookshop/.gitignore
+++ b/tests/bookshop/.gitignore
@@ -17,6 +17,7 @@ target/
*_mta_build_tmp
*.mtar
mta_archives/
+*.tgz
# Other
.DS_Store
diff --git a/tests/bookshop/_i18n/i18n.properties b/tests/bookshop/_i18n/i18n.properties
new file mode 100644
index 0000000..60a6332
--- /dev/null
+++ b/tests/bookshop/_i18n/i18n.properties
@@ -0,0 +1,5 @@
+BOOK_ORDERED_DESCRIPTION=Book Ordered
+BOOK_ORDERED_TITLE=Book Ordered
+BOOK_ORDERED_PUBLIC_TITLE=Book Ordered
+BOOK_ORDERED_SUBTITLE={{buyer}} ordered {{title}}
+BOOK_ORDERED_GROUPED_TITLE=Bookshop Updates
diff --git a/tests/bookshop/app/admin-books/webapp/Component.js b/tests/bookshop/app/admin-books/webapp/Component.js
index e98677e..6d00206 100644
--- a/tests/bookshop/app/admin-books/webapp/Component.js
+++ b/tests/bookshop/app/admin-books/webapp/Component.js
@@ -1,8 +1,8 @@
sap.ui.define(["sap/fe/core/AppComponent"], function (AppComponent) {
- "use strict";
+ "use strict"
return AppComponent.extend("books.Component", {
metadata: { manifest: "json" }
- });
-});
+ })
+})
/* eslint no-undef:0 */
diff --git a/tests/bookshop/app/browse/webapp/Component.js b/tests/bookshop/app/browse/webapp/Component.js
index 4020679..0d7a80c 100644
--- a/tests/bookshop/app/browse/webapp/Component.js
+++ b/tests/bookshop/app/browse/webapp/Component.js
@@ -1,7 +1,7 @@
sap.ui.define(["sap/fe/core/AppComponent"], function(AppComponent) {
- "use strict";
+ "use strict"
return AppComponent.extend("bookshop.Component", {
metadata: { manifest: "json" }
- });
-});
+ })
+})
/* eslint no-undef:0 */
diff --git a/tests/bookshop/db/undeploy.json b/tests/bookshop/db/undeploy.json
new file mode 100644
index 0000000..aa14b9f
--- /dev/null
+++ b/tests/bookshop/db/undeploy.json
@@ -0,0 +1,7 @@
+[
+ "src/gen/**/*.hdbview",
+ "src/gen/**/*.hdbindex",
+ "src/gen/**/*.hdbconstraint",
+ "src/gen/**/*_drafts.hdbtable",
+ "src/gen/**/*.hdbcalculationview"
+]
diff --git a/tests/bookshop/mta.yaml b/tests/bookshop/mta.yaml
new file mode 100644
index 0000000..e120c55
--- /dev/null
+++ b/tests/bookshop/mta.yaml
@@ -0,0 +1,101 @@
+_schema-version: 3.3.0
+ID: bookshop-notify
+version: 1.0.0
+description: "A simple CAP project."
+parameters:
+ enable-parallel-deployments: true
+build-parameters:
+ before-all:
+ - builder: custom
+ commands:
+ - npm pack ../../
+ - sh -c 'TGZ_FILE="$(ls -1 | grep -E "^cap-js-notifications.*\.tgz$" | head -n 1)" && npm i "./${TGZ_FILE}"'
+ - npm ci
+ - npx -p @sap/cds-dk cds build --production
+ - sh -c 'cp "$(ls -1 | grep -E "^cap-js-notifications.*\.tgz$" | head -n 1)" ./gen/srv'
+modules:
+ - name: bookshop-notify-srv
+ type: nodejs
+ path: gen/srv
+ parameters:
+ instances: 1
+ buildpack: nodejs_buildpack
+ build-parameters:
+ builder: npm-ci
+ provides:
+ - name: srv-api # required by consumers of CAP services (e.g. approuter)
+ properties:
+ srv-url: ${default-url}
+ requires:
+ - name: bookshop-notify-auth
+ - name: bookshop-notify-db
+ - name: bookshop-notify-destination
+ - name: bookshop-notify-alert-notification
+
+ - name: bookshop-notify-db-deployer
+ type: hdb
+ path: gen/db
+ parameters:
+ buildpack: nodejs_buildpack
+ requires:
+ - name: bookshop-notify-db
+ - name: notification-content-deployment
+ type: nodejs
+ path: gen/srv
+ parameters:
+ no-route: true
+ no-start: true
+ memory: 256MB
+ disk-quota: 1GB
+ tasks:
+ - name: notification-content-deployment
+ command: "node node_modules/@cap-js/notifications/lib/content-deployment.js"
+ memory: 256MB
+ disk-quota: 1GB
+ requires:
+ - name: bookshop-notify-destination
+ - name: bookshop-notify-auth
+ - name: bookshop-notify-db
+ - name: bookshop-notify-alert-notification
+
+resources:
+ - name: bookshop-notify-auth
+ type: org.cloudfoundry.managed-service
+ parameters:
+ service: xsuaa
+ service-plan: application
+ path: ./xs-security.json
+ config:
+ xsappname: bookshop-notify-${org}-${space}
+ tenant-mode: dedicated
+ oauth2-configuration:
+ credential-types:
+ - "binding-secret"
+ - "x509"
+ role-collections:
+ - name: 'admin (bookshop-notify ${org}-${space})'
+ description: 'generated'
+ role-template-references:
+ - '$XSAPPNAME.admin'
+ - name: bookshop-notify-db
+ type: com.sap.xs.hdi-container
+ parameters:
+ service: hana
+ service-plan: hdi-shared
+ - name: bookshop-notify-destination
+ type: org.cloudfoundry.managed-service
+ parameters:
+ service: destination
+ service-plan: lite
+ - name: bookshop-notify-alert-notification
+ type: org.cloudfoundry.managed-service
+ parameters:
+ service: alert-notification
+ service-plan: business-notifications
+ config:
+ defaultEmailDeliveryConfig:
+ emailSenderAddress: cap-java@notifications.sap.com
+ emailSenderName: CAP Java
+ notificationDeliveryTrackingConfig:
+ enabledChannels:
+ - MAIL
\ No newline at end of file
diff --git a/tests/bookshop/package.json b/tests/bookshop/package.json
index 3f7b2a0..2d203e0 100644
--- a/tests/bookshop/package.json
+++ b/tests/bookshop/package.json
@@ -3,7 +3,12 @@
"version": "1.0.0",
"description": "A simple CAP project.",
"dependencies": {
- "@cap-js/notifications": "file:../.."
+ "@cap-js/hana": "^2",
+ "@cap-js/notifications": "file:../..",
+ "@sap-cloud-sdk/connectivity": "^4.7.0",
+ "@sap-cloud-sdk/http-client": "^4.7.0",
+ "@sap-cloud-sdk/resilience": "^4.7.0",
+ "@sap/xssec": "^4"
},
"scripts": {
"start": "cds-serve"
@@ -13,8 +18,15 @@
"notifications": {
"outbox": false,
"types": "srv/notification-types.json"
+ },
+ "[production]": {
+ "auth": "xsuaa"
}
}
},
- "private": true
+ "private": true,
+ "devDependencies": {
+ "@cap-js/sqlite": "^2.4.0",
+ "@sap/cds-dk": "^9"
+ }
}
diff --git a/tests/bookshop/srv/cat-service.js b/tests/bookshop/srv/cat-service.js
new file mode 100644
index 0000000..478d304
--- /dev/null
+++ b/tests/bookshop/srv/cat-service.js
@@ -0,0 +1,20 @@
+const cds = require('@sap/cds')
+module.exports = cds.service.impl(async function () {
+ const alert = await cds.connect.to('notifications')
+
+ this.on('submitOrder', async req => {
+ const { book: bookId, quantity } = req.data
+
+ const book = await SELECT.one.from('sap.capire.bookshop.Books').where({ ID: bookId })
+ if (!book) return req.error(404, `Book ${bookId} not found`)
+ if (book.stock < quantity) return req.error(400, `Not enough stock for book ${bookId}`)
+
+ await UPDATE('sap.capire.bookshop.Books').set({ stock: book.stock - quantity }).where({ ID: bookId })
+
+ await alert.notify('BookOrderedNotify', {
+ recipients: ['reader@bookshop.example'],
+ data: { title: book.title, buyer: req.user.id }
+ })
+ return { stock: book.stock - quantity }
+ })
+})
\ No newline at end of file
diff --git a/tests/bookshop/srv/notification-types.json b/tests/bookshop/srv/notification-types.json
index c3e7456..3cf9cf8 100644
--- a/tests/bookshop/srv/notification-types.json
+++ b/tests/bookshop/srv/notification-types.json
@@ -1,15 +1,24 @@
[
{
- "NotificationTypeKey": "BookOrdered",
+ "NotificationTypeKey": "BookReturned",
"NotificationTypeVersion": "1",
"Templates": [
{
"Language": "en",
- "TemplatePublic": "Book Ordered",
- "TemplateSensitive": "Book '{{title}}' Ordered",
- "TemplateGrouped": "Bookshop Updates",
- "TemplateLanguage": "mustache",
- "Subtitle": "{{buyer}} ordered {{title}}"
+ "TemplateSensitive": "Book '{{title}}' Returned",
+ "TemplatePublic": "Book Returned",
+ "TemplateGrouped": "Books Returned",
+ "TemplateLanguage": "MUSTACHE",
+ "EmailSubject": "Book Returned: {{title}}",
+ "EmailHtml": "The book {{title}} has been returned.
" + } + ], + "DeliveryChannels": [ + { + "Type": "MAIL", + "Enabled": true, + "DefaultPreference": true, + "EditablePreference": true } ] } diff --git a/tests/bookshop/srv/notifications.cds b/tests/bookshop/srv/notifications.cds new file mode 100644 index 0000000..d2c8666 --- /dev/null +++ b/tests/bookshop/srv/notifications.cds @@ -0,0 +1,21 @@ +service notificationService { + @description: '{i18n>BOOK_ORDERED_DESCRIPTION}' + @notification: { + template: { + title : '{i18n>BOOK_ORDERED_TITLE}', + publicTitle : '{i18n>BOOK_ORDERED_PUBLIC_TITLE}', + subtitle : '{i18n>BOOK_ORDERED_SUBTITLE}', + groupedTitle : '{i18n>BOOK_ORDERED_GROUPED_TITLE}', + email : { + subject: 'Book Ordered: {{title}}', + html : 'Hi {{buyer}},
Your order for {{title}} has been placed.
', + } + }, + deliveryChannels: [{ channel: 'MAIL', enabled: true, defaultPreference: true, editablePreference: true}] + } + event BookOrderedNotify { + title : String; + buyer : String; + recipients: array of String; + } +} diff --git a/tests/bookshop/test/http/AdminService.http b/tests/bookshop/test/http/AdminService.http new file mode 100644 index 0000000..466fd83 --- /dev/null +++ b/tests/bookshop/test/http/AdminService.http @@ -0,0 +1,162 @@ +@server=http://localhost:4004 +@username=alice +@password= + + +### Books +# @name Books_GET +GET {{server}}/odata/v4/admin/Books +Authorization: Basic {{username}}:{{password}} + + +### Books Drafts GET +# @name Books_Drafts_GET +GET {{server}}/odata/v4/admin/Books?$filter=(IsActiveEntity eq false) +Authorization: Basic {{username}}:{{password}} + + +### Books Draft POST +# @name Books_Draft_POST +POST {{server}}/odata/v4/admin/Books +Content-Type: application/json +Authorization: Basic {{username}}:{{password}} + +{ + "title": "title-30602831", + "descr": "descr-30602831", + "author": { + "ID": "13447978-02dc-4dc8-9078-0d9e22283daa" + }, + "genre": { + "ID": 31657298 + }, + "stock": 52, + "price": 89.22, + "currency": { + "code": "800" + }, + "createdAt": "2003-10-21T23:00:00.000Z", + "createdBy": "createdBy.i7xan@example.net", + "modifiedAt": "2013-10-21T23:00:00.000Z", + "modifiedBy": "modifiedBy.i7xan@example.com" +} + + +### Result from POST request above +@draftID={{Books_Draft_POST.response.body.$.ID}} + + +### Books Draft PATCH +# @name Books_Draft_Patch +PATCH {{server}}/odata/v4/admin/Books(ID={{draftID}},IsActiveEntity=false) +Content-Type: application/json +Authorization: Basic {{username}}:{{password}} + +{ + "title": "title-30602831", + "descr": "descr-30602831", + "author": { + "ID": "13447978-02dc-4dc8-9078-0d9e22283daa" + }, + "genre": { + "ID": 31657298 + }, + "stock": 52, + "price": 89.22, + "currency": { + "code": "800" + }, + "createdAt": "2003-10-21T23:00:00.000Z", + "createdBy": "createdBy.i7xan@example.net", + "modifiedAt": "2013-10-21T23:00:00.000Z", + "modifiedBy": "modifiedBy.i7xan@example.com" +} + + +### Books Draft Prepare +# @name Books_Draft_Prepare +POST {{server}}/odata/v4/admin/Books(ID={{draftID}},IsActiveEntity=false)/AdminService.draftPrepare +Content-Type: application/json +Authorization: Basic {{username}}:{{password}} + +{} + + +### Books Draft Activate +# @name Books_Draft_Activate +POST {{server}}/odata/v4/admin/Books(ID={{draftID}},IsActiveEntity=false)/AdminService.draftActivate +Content-Type: application/json +Authorization: Basic {{username}}:{{password}} + +{} + + +### Authors +# @name Authors_GET +GET {{server}}/odata/v4/admin/Authors +Authorization: Basic {{username}}:{{password}} + + +### Authors +# @name Authors_POST +POST {{server}}/odata/v4/admin/Authors +Content-Type: application/json +Authorization: Basic {{username}}:{{password}} + +{ + "ID": "13447978-02dc-4dc8-9078-0d9e22283daa", + "name": "name-13447978", + "dateOfBirth": "2007-09-05", + "dateOfDeath": "2023-01-05", + "placeOfBirth": "placeOfBirth-13447978", + "placeOfDeath": "placeOfDeath-13447978", + "createdAt": "2023-04-09T23:00:00.000Z", + "createdBy": "createdBy.808iy@example.org", + "modifiedAt": "2006-06-01T23:00:00.000Z", + "modifiedBy": "modifiedBy.808iy@example.net" +} + + +### Authors +# @name Authors_PATCH +PATCH {{server}}/odata/v4/admin/Authors/13447978-02dc-4dc8-9078-0d9e22283daa +Content-Type: application/json +Authorization: Basic {{username}}:{{password}} + +{ + "ID": "13447978-02dc-4dc8-9078-0d9e22283daa", + "name": "name-13447978", + "dateOfBirth": "2007-09-05", + "dateOfDeath": "2023-01-05", + "placeOfBirth": "placeOfBirth-13447978", + "placeOfDeath": "placeOfDeath-13447978", + "createdAt": "2023-04-09T23:00:00.000Z", + "createdBy": "createdBy.808iy@example.org", + "modifiedAt": "2006-06-01T23:00:00.000Z", + "modifiedBy": "modifiedBy.808iy@example.net" +} + + +### Authors +# @name Authors_DELETE +DELETE {{server}}/odata/v4/admin/Authors/13447978-02dc-4dc8-9078-0d9e22283daa +Content-Type: application/json +Authorization: Basic {{username}}:{{password}} + + +### Genres +# @name Genres_GET +GET {{server}}/odata/v4/admin/Genres +Authorization: Basic {{username}}:{{password}} + + +### Currencies +# @name Currencies_GET +GET {{server}}/odata/v4/admin/Currencies +Authorization: Basic {{username}}:{{password}} + + +### Languages +# @name Languages_GET +GET {{server}}/odata/v4/admin/Languages +Authorization: Basic {{username}}:{{password}} diff --git a/tests/bookshop/test/http/CatalogService.http b/tests/bookshop/test/http/CatalogService.http new file mode 100644 index 0000000..d804250 --- /dev/null +++ b/tests/bookshop/test/http/CatalogService.http @@ -0,0 +1,39 @@ +@server=http://localhost:4004 +@username=alice +@password= + + +### ListOfBooks +# @name ListOfBooks_GET +GET {{server}}/odata/v4/catalog/ListOfBooks +Authorization: Basic {{username}}:{{password}} + + +### Books +# @name Books_GET +GET {{server}}/odata/v4/catalog/Books +Authorization: Basic {{username}}:{{password}} + + +### Genres +# @name Genres_GET +GET {{server}}/odata/v4/catalog/Genres +Authorization: Basic {{username}}:{{password}} + + +### Currencies +# @name Currencies_GET +GET {{server}}/odata/v4/catalog/Currencies +Authorization: Basic {{username}}:{{password}} + + +### submitOrder +# @name submitOrder_POST +POST {{server}}/odata/v4/catalog/submitOrder +Content-Type: application/json +Authorization: Basic {{username}}:{{password}} + +{ + "book": "65402382-e029-4e04-8ea1-05e9264faa26", + "quantity": 94 +} diff --git a/tests/bookshop/xs-security.json b/tests/bookshop/xs-security.json new file mode 100644 index 0000000..c95ea73 --- /dev/null +++ b/tests/bookshop/xs-security.json @@ -0,0 +1,19 @@ +{ + "scopes": [ + { + "name": "$XSAPPNAME.admin", + "description": "admin" + } + ], + "attributes": [], + "role-templates": [ + { + "name": "admin", + "description": "generated", + "scope-references": [ + "$XSAPPNAME.admin" + ], + "attribute-references": [] + } + ] +} diff --git a/tests/integration/bookshop.test.js b/tests/integration/bookshop.test.js index 972e94e..a2e6165 100644 --- a/tests/integration/bookshop.test.js +++ b/tests/integration/bookshop.test.js @@ -1,62 +1,73 @@ -const cds = require("@sap/cds"); -const { join } = cds.utils.path; -const { messages } = require("../../lib/utils"); +const cds = require("@sap/cds") +const { join } = cds.utils.path +const { messages } = require("../../lib/utils") -cds.test(join(__dirname, "../bookshop")); +cds.test(join(__dirname, "../bookshop")) describe("Notifications Integration", () => { let log = cds.test.log() - let alert; + let alert beforeAll(async () => { - alert = await cds.connect.to("notifications"); - }); + alert = await cds.connect.to("notifications") + }) test("Notifications service resolves to console implementation in development", async () => { - expect(alert.constructor.name).toBe("NotifyToConsole"); - }); + expect(alert.constructor.name).toBe("NotifyToConsole") + }) test("Notification types are loaded into cds.notifications on startup", () => { - expect(cds.notifications?.local?.types).toBeDefined(); - expect(cds.notifications.local.types).toHaveProperty("bookshop/BookOrdered"); - }); + expect(cds.notifications?.local?.types).toBeDefined() + expect(cds.notifications.local.types).toHaveProperty("bookshop/BookOrderedNotify") + }) test("Sending a notification with unknown type key gives a warning", async () => { await alert.notify("UnknownType", { recipients: ["reader@bookshop.com"], data: { title: "test" } - }); + }) - expect(log.output).toContain("UnknownType is not in the notification types file"); - }); + expect(log.output).toContain("UnknownType is not in the notification types file") + }) test("Sending a default notification logs to console", async () => { await alert.notify({ recipients: ["reader@bookshop.com"], title: "New book arrived", description: "A new book has been added to the catalogue" - }); + }) - expect(log.output).toContain("Notification:"); - expect(log.output).toContain("NotificationTypeKey: 'Default'"); - expect(log.output).toContain("RecipientId: 'reader@bookshop.com'"); - expect(log.output).toContain("Value: 'New book arrived'"); - }); + expect(log.output).toContain("Notification:") + expect(log.output).toContain("NotificationTypeKey: 'Default'") + expect(log.output).toContain("RecipientId: 'reader@bookshop.com'") + expect(log.output).toContain("Value: 'New book arrived'") + }) test("Sending a notification with no arguments warns and does nothing", async () => { - await alert.notify(); + await alert.notify() - expect(log.output).toContain(messages.NO_OBJECT_FOR_NOTIFY); - expect(log.output).not.toContain("Notification:"); - }); + expect(log.output).toContain(messages.NO_OBJECT_FOR_NOTIFY) + expect(log.output).not.toContain("Notification:") + }) test("Custom typed notification uses prefixed type key from types file", async () => { - await alert.notify("BookOrdered", { + await alert.notify("BookOrderedNotify", { recipients: ["reader@bookshop.com"], data: { title: "Moby Dick", buyer: "reader@bookshop.com" } - }); + }) - expect(log.output).toContain("bookshop/BookOrdered"); - expect(log.output).not.toContain("is not in the notification types file"); - }); -}); \ No newline at end of file + expect(log.output).toContain("bookshop/BookOrderedNotify") + expect(log.output).not.toContain("is not in the notification types file") + }) + + test("Notification types from CDS and JSON are merged", () => { + expect(cds.notifications.local.types).toHaveProperty("bookshop/BookOrderedNotify") + expect(cds.notifications.local.types).toHaveProperty("bookshop/BookReturned") + }) + + test("Notification type templates have resolved i18n values", () => { + const type = cds.notifications.local.types["bookshop/BookOrderedNotify"]["1"] + expect(type.Templates[0].TemplateSensitive).toBe("Book Ordered") + expect(type.Templates[0].Subtitle).toBe("{{buyer}} ordered {{title}}") + }) +}) \ No newline at end of file diff --git a/tests/unit/lib/compile.test.js b/tests/unit/lib/compile.test.js new file mode 100644 index 0000000..3e0a6e9 --- /dev/null +++ b/tests/unit/lib/compile.test.js @@ -0,0 +1,245 @@ +const { notificationTypesFromModel } = require("../../../lib/compile") + +function makeModel(defs) { + return { definitions: Object.values(defs) } +} + +describe("notificationTypesFromModel", () => { + let originalI18nDescriptor + + beforeEach(() => { + originalI18nDescriptor = Object.getOwnPropertyDescriptor(require('@sap/cds'), 'i18n') + }) + + afterEach(() => { + if (originalI18nDescriptor) { + Object.defineProperty(require('@sap/cds'), 'i18n', originalI18nDescriptor) + } + }) + + test("Return empty array for null/undefined model", () => { + expect(notificationTypesFromModel(null)).toEqual([]) + expect(notificationTypesFromModel(undefined)).toEqual([]) + }) + + test("Return empty array when no events have @notification", () => { + const model = makeModel({ + "MyEntity": { kind: "entity", name: "MyEntity" }, + "PlainEvent": { kind: "event", name: "PlainEvent" } + }) + expect(notificationTypesFromModel(model)).toEqual([]) + }) + + test("Handle @notification with no template property", () => { + const model = makeModel({ + "E": { + kind: "event", + name: "E", + "@notification": {} + } + }) + + const [type] = notificationTypesFromModel(model) + expect(type.NotificationTypeKey).toBe("E") + expect(type.Templates[0].TemplateSensitive).toBeUndefined() + expect(type.Templates[0].Language).toBe("en") + }) + + test("Convert a fully annotated event to a notification type", () => { + const model = makeModel({ + "BookOrderedNotify": { + kind: "event", + name: "BookOrderedNotify", + "@description": "Book Ordered", + "@Common.SemanticObject": "Book", + "@Common.SemanticObjectAction": "display", + "@notification.template.title": "Book '{{title}}' Ordered", + "@notification.template.publicTitle": "Book Ordered", + "@notification.template.subtitle": "{{buyer}} ordered {{title}}", + "@notification.template.groupedTitle": "Bookshop Updates", + "@notification.template.email.subject": "Your order", + "@notification.deliveryChannels": [{ channel: { "=": "Mail" }, enabled: true }] + } + }) + + const [type] = notificationTypesFromModel(model) + + expect(type.NotificationTypeKey).toBe("BookOrderedNotify") + expect(type.NotificationTypeVersion).toBe("1") + expect(type.NavigationTargetObject).toBe("Book") + expect(type.NavigationTargetAction).toBe("display") + + const tmpl = type.Templates[0] + expect(tmpl.Language).toBe("en") + expect(tmpl.TemplateLanguage).toBe("mustache") + expect(tmpl.Description).toBe("Book Ordered") + expect(tmpl.TemplateSensitive).toBe("Book '{{title}}' Ordered") + expect(tmpl.TemplatePublic).toBe("Book Ordered") + expect(tmpl.Subtitle).toBe("{{buyer}} ordered {{title}}") + expect(tmpl.TemplateGrouped).toBe("Bookshop Updates") + expect(tmpl.EmailSubject).toBe("Your order") + + expect(type.DeliveryChannels).toEqual([{ Type: "MAIL", Enabled: true }]) + }) + + test("Handle minimal annotation (only @notification present)", () => { + const model = makeModel({ + "SimpleEvent": { + kind: "event", + name: "SimpleEvent", + "@notification.template.title": "Hello" + } + }) + + const [type] = notificationTypesFromModel(model) + + expect(type.NotificationTypeKey).toBe("SimpleEvent") + expect(type.NotificationTypeVersion).toBe("1") + expect(type.Templates[0].TemplateSensitive).toBe("Hello") + expect(type.Templates[0].TemplatePublic).toBeUndefined() + expect(type.NavigationTargetObject).toBeUndefined() + expect(type.DeliveryChannels).toBeUndefined() + }) + + test("Strip namespace prefix from event name", () => { + const model = makeModel({ + "CatalogService.BookOrderedNotify": { + kind: "event", + name: "CatalogService.BookOrderedNotify", + "@notification": { template: { title: "x" } } + } + }) + + const [type] = notificationTypesFromModel(model) + expect(type.NotificationTypeKey).toBe("BookOrderedNotify") + }) + + test("Unwrap hash-form enum references in deliveryChannels", () => { + const model = makeModel({ + "E": { + kind: "event", + name: "E", + "@notification.template.title": "t", + "@notification.deliveryChannels": [{ channel: { "#": "Mail" }, enabled: true }] + } + }) + + const [type] = notificationTypesFromModel(model) + expect(type.DeliveryChannels[0].Type).toBe("MAIL") + expect(type.DeliveryChannels[0].Enabled).toBe(true) + }) + + test("Unwrap plain string enum values in deliveryChannels", () => { + const model = makeModel({ + "E": { + kind: "event", + name: "E", + "@notification.template.title": "t", + "@notification.deliveryChannels": [{ channel: "Web", enabled: false }] + } + }) + + const [type] = notificationTypesFromModel(model) + expect(type.DeliveryChannels[0].Type).toBe("WEB") + expect(type.DeliveryChannels[0].Enabled).toBe(false) + }) + + test("Return all events with @notification from a mixed model", () => { + const model = makeModel({ + "A": { kind: "event", name: "A", "@notification": { template: { title: "a" } } }, + "B": { kind: "entity", name: "B" }, + "C": { kind: "event", name: "C", "@notification": { template: { title: "c" } } }, + "D": { kind: "event", name: "D" } + }) + + const types = notificationTypesFromModel(model) + expect(types).toHaveLength(2) + expect(types.map(t => t.NotificationTypeKey)).toEqual(expect.arrayContaining(["A", "C"])) + }) + + test("Include defaultPreference and editablePreference from deliveryChannels when present", () => { + const model = makeModel({ + "E": { + kind: "event", + name: "E", + "@notification.template.title": "t", + "@notification.deliveryChannels": [{ + channel: "Mail", + enabled: true, + defaultPreference: true, + editablePreference: false + }] + } + }) + + const [type] = notificationTypesFromModel(model) + expect(type.DeliveryChannels[0]).toEqual({ + Type: "MAIL", + Enabled: true, + DefaultPreference: true, + EditablePreference: false + }) + }) + + test("Skip deliveryChannel entry when channel is missing", () => { + const model = makeModel({ + "E": { + kind: "event", + name: "E", + "@notification.template.title": "t", + "@notification.deliveryChannels": [{ enabled: true }] + } + }) + + const [type] = notificationTypesFromModel(model) + expect(type.DeliveryChannels).toHaveLength(0) + }) + + test("Resolve {i18n>KEY} references to English labels", () => { + const cds = require('@sap/cds') + Object.defineProperty(cds, 'i18n', { + value: { labels: { at: (key, lang) => key === 'BOOK_ORDERED_TITLE' && lang === 'en' ? 'Book Ordered' : undefined } }, + configurable: true, + writable: true + }) + + const model = makeModel({ + "E": { kind: "event", name: "E", "@notification.template.title": "{i18n>BOOK_ORDERED_TITLE}" } + }) + + const [type] = notificationTypesFromModel(model) + expect(type.Templates[0].TemplateSensitive).toBe("Book Ordered") + }) + + test("Fall back to raw value when i18n key not found", () => { + const cds = require('@sap/cds') + cds.i18n = { labels: { at: () => undefined } } + + const model = makeModel({ + "E": { kind: "event", name: "E", "@notification.template.title": "{i18n>MISSING_KEY}" } + }) + + const [type] = notificationTypesFromModel(model) + expect(type.Templates[0].TemplateSensitive).toBe("{i18n>MISSING_KEY}") + }) + + test("Pass plain strings through i18n unchanged", () => { + const model = makeModel({ + "E": { kind: "event", name: "E", "@notification.template.title": "Plain Title" } + }) + const [type] = notificationTypesFromModel(model) + expect(type.Templates[0].TemplateSensitive).toBe("Plain Title") + }) + + test("Resolve {i18n>KEY} in subtitle field", () => { + Object.defineProperty(cds, 'i18n', { + value: { labels: { at: (key) => key === 'SUBTITLE_KEY' ? 'Resolved Subtitle' : undefined } }, + configurable: true, writable: true + }) + const model = makeModel({ + "E": { kind: "event", name: "E", "@notification.template.title": "t", "@notification.template.subtitle": "{i18n>SUBTITLE_KEY}" } + }) + const [type] = notificationTypesFromModel(model) + expect(type.Templates[0].Subtitle).toBe("Resolved Subtitle") + }) +}) diff --git a/tests/unit/lib/content-deployment.test.js b/tests/unit/lib/content-deployment.test.js index d50433f..80806f4 100644 --- a/tests/unit/lib/content-deployment.test.js +++ b/tests/unit/lib/content-deployment.test.js @@ -1,53 +1,56 @@ -const cds = require("@sap/cds"); -const { validateNotificationTypes, readFile } = require("../../../lib/utils"); -const { processNotificationTypes } = require("../../../lib/notificationTypes"); -const { setGlobalLogLevel } = require("@sap-cloud-sdk/util"); +const cds = require("@sap/cds") +const { validateNotificationTypes, readFile } = require("../../../lib/utils") +const { processNotificationTypes } = require("../../../lib/notificationTypes") +const { notificationTypesFromModel } = require("../../../lib/compile") +const { setGlobalLogLevel } = require("@sap-cloud-sdk/util") -jest.mock("../../../lib/utils"); -jest.mock("../../../lib/notificationTypes"); -jest.mock("@sap-cloud-sdk/util"); +jest.mock("../../../lib/utils") +jest.mock("../../../lib/notificationTypes") +jest.mock("../../../lib/compile") +jest.mock("@sap-cloud-sdk/util") -const contentDeployment = require("../../../lib/content-deployment"); +const { deployNotificationTypes } = require("../../../lib/content-deployment") describe("contentDeployment", () => { beforeEach(() => { - jest.clearAllMocks(); - setGlobalLogLevel.mockImplementation(() => undefined); - readFile.mockImplementation(() => []); - }); - - it("Set log level to error on startup", async () => { - validateNotificationTypes.mockReturnValue(false); - await contentDeployment.deployNotificationTypes(); - - expect(setGlobalLogLevel).toHaveBeenCalledWith("error"); - }); - - it("Process notification types when they are valid", async () => { - validateNotificationTypes.mockReturnValue(true); - processNotificationTypes.mockResolvedValue(); - await contentDeployment.deployNotificationTypes(); - - expect(validateNotificationTypes).toHaveBeenCalledWith([]); - expect(processNotificationTypes).toHaveBeenCalledWith([]); - }); - - it("Notification types are not processed when they are invalid", async () => { - validateNotificationTypes.mockReturnValue(false); - processNotificationTypes.mockResolvedValue(); - await contentDeployment.deployNotificationTypes(); - - expect(validateNotificationTypes).toHaveBeenCalledWith([]); - expect(processNotificationTypes).not.toHaveBeenCalled(); - }); - - it("Call readFile with empty string when notifications types path is not configured", async () => { - validateNotificationTypes.mockReturnValue(false); - const originalTypes = cds.env.requires.notifications.types; - delete cds.env.requires.notifications.types; - await contentDeployment.deployNotificationTypes(); - cds.env.requires.notifications.types = originalTypes; + jest.clearAllMocks() + setGlobalLogLevel.mockImplementation(() => undefined) + readFile.mockReturnValue([]) + notificationTypesFromModel.mockReturnValue([]) + }) + + test("Set log level to error on startup", async () => { + validateNotificationTypes.mockReturnValue(false) + await deployNotificationTypes() + + expect(setGlobalLogLevel).toHaveBeenCalledWith("error") + }) + + test("Process notification types when they are valid", async () => { + validateNotificationTypes.mockReturnValue(true) + processNotificationTypes.mockResolvedValue() + await deployNotificationTypes() + + expect(validateNotificationTypes).toHaveBeenCalledWith([]) + expect(processNotificationTypes).toHaveBeenCalledWith([]) + }) + + test("Notification types are not processed when they are invalid", async () => { + validateNotificationTypes.mockReturnValue(false) + processNotificationTypes.mockResolvedValue() + await deployNotificationTypes() + + expect(validateNotificationTypes).toHaveBeenCalledWith([]) + expect(processNotificationTypes).not.toHaveBeenCalled() + }) + + test("readFile is not called when notifications types path is not configured", async () => { + validateNotificationTypes.mockReturnValue(false) + const originalTypes = cds.env.requires.notifications.types + delete cds.env.requires.notifications.types + await deployNotificationTypes() + cds.env.requires.notifications.types = originalTypes - expect(readFile).toHaveBeenCalledWith(''); - }); -}); + expect(readFile).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/lib/notificationTypes.test.js b/tests/unit/lib/notificationTypes.test.js index 4a07602..9600a9a 100644 --- a/tests/unit/lib/notificationTypes.test.js +++ b/tests/unit/lib/notificationTypes.test.js @@ -1,11 +1,11 @@ -const utils = require("../../../lib/utils"); -const httpClient = require("@sap-cloud-sdk/http-client"); -const connectivity = require("@sap-cloud-sdk/connectivity"); -const notificationTypes = require("../../../lib/notificationTypes"); +const utils = require("../../../lib/utils") +const httpClient = require("@sap-cloud-sdk/http-client") +const connectivity = require("@sap-cloud-sdk/connectivity") +const notificationTypes = require("../../../lib/notificationTypes") -jest.mock("../../../lib/utils"); -jest.mock("@sap-cloud-sdk/http-client"); -jest.mock("@sap-cloud-sdk/connectivity"); +jest.mock("../../../lib/utils") +jest.mock("@sap-cloud-sdk/http-client") +jest.mock("@sap-cloud-sdk/connectivity") const defaultNotificationType = { NotificationTypeKey: "Default", @@ -21,7 +21,7 @@ const defaultNotificationType = { Subtitle: "{{description}}" } ] -}; +} const notificationTypeWithAllProperties = { NotificationTypeKey: "notificationTypeWithAllProperties", @@ -58,7 +58,7 @@ const notificationTypeWithAllProperties = { EditablePreference: true } ] -}; +} const notificationTypeWithoutVersion = { NotificationTypeKey: "notificationTypeWithoutVersion", @@ -94,18 +94,18 @@ const notificationTypeWithoutVersion = { EditablePreference: true } ] -}; +} const notificationTypeWithNullTemplatesActionsAndDeliveryChannels = { ...notificationTypeWithAllProperties, Templates: null, Actions: null, DeliveryChannels: null -}; +} -const testPrefix = "test-prefix"; +const testPrefix = "test-prefix" -const emptyResponseBody = { data: { d: { results: [] } } }; +const emptyResponseBody = { data: { d: { results: [] } } } const allExistingResponseBody = { data: { @@ -271,7 +271,7 @@ const allExistingResponseBody = { ] } } -}; +} const allExistingWithUndefinedTemplatesActionsAndDeliveryChannelsResponseBody = { data: { @@ -313,90 +313,90 @@ const allExistingWithUndefinedTemplatesActionsAndDeliveryChannelsResponseBody = ] } } -}; +} describe("Managing of Notification Types", () => { beforeEach(() => { - jest.clearAllMocks(); - utils.getNotificationTypesKeyWithPrefix.mockImplementation(str => `${testPrefix}/${str}`); - }); + jest.clearAllMocks() + utils.getNotificationTypesKeyWithPrefix.mockImplementation(str => `${testPrefix}/${str}`) + }) describe("Create Notification Types Map", () => { - it("Seed the Default type when isLocal is true", () => { - const result = notificationTypes.createNotificationTypesMap([], true); - expect(result).toHaveProperty("Default"); - expect(result["Default"]["1"]).toMatchObject(defaultNotificationType); - }); + test("Seed the Default type when isLocal is true", () => { + const result = notificationTypes.createNotificationTypesMap([], true) + expect(result).toHaveProperty("Default") + expect(result["Default"]["1"]).toMatchObject(defaultNotificationType) + }) - it("Store multiple versions of the same type under the same key", () => { - const typeV1 = { NotificationTypeKey: "MyType", NotificationTypeVersion: "1", Templates: [] }; - const typeV2 = { NotificationTypeKey: "MyType", NotificationTypeVersion: "2", Templates: [] }; - const result = notificationTypes.createNotificationTypesMap([typeV1, typeV2]); + test("Store multiple versions of the same type under the same key", () => { + const typeV1 = { NotificationTypeKey: "MyType", NotificationTypeVersion: "1", Templates: [] } + const typeV2 = { NotificationTypeKey: "MyType", NotificationTypeVersion: "2", Templates: [] } + const result = notificationTypes.createNotificationTypesMap([typeV1, typeV2]) - expect(Object.keys(result[`${testPrefix}/MyType`])).toEqual(["1", "2"]); - }); - }); + expect(Object.keys(result[`${testPrefix}/MyType`])).toEqual(["1", "2"]) + }) + }) describe("Process Notification Types", () => { beforeEach(() => { - utils.getNotificationDestination.mockReturnValue(undefined); - utils.getPrefix.mockReturnValue(testPrefix); - connectivity.buildHeadersForDestination.mockReturnValue({}); - }); + utils.getNotificationDestination.mockReturnValue(undefined) + utils.getPrefix.mockReturnValue(testPrefix) + connectivity.buildHeadersForDestination.mockReturnValue({}) + }) describe("Creating Types", () => { - it("Create Default and all new types when none exist in Work Zone", () => { - httpClient.executeHttpRequest.mockReturnValue(emptyResponseBody); + test("Create Default and all new types when none exist in Work Zone", () => { + httpClient.executeHttpRequest.mockReturnValue(emptyResponseBody) return notificationTypes.processNotificationTypes([structuredClone(notificationTypeWithAllProperties), structuredClone(notificationTypeWithoutVersion)]).then(() => { - const [, createDefault, createFirst, createSecond, extra] = httpClient.executeHttpRequest.mock.calls.map(c => c[1]); + const [, createDefault, createFirst, createSecond, extra] = httpClient.executeHttpRequest.mock.calls.map(c => c[1]) - expect(createDefault.method).toBe("post"); - expect(createDefault.data).toEqual(defaultNotificationType); + expect(createDefault.method).toBe("post") + expect(createDefault.data).toEqual(defaultNotificationType) - expect(createFirst.method).toBe("post"); - expect(createFirst.data).toEqual(toNTypeWithPrefixedKey(notificationTypeWithAllProperties)); + expect(createFirst.method).toBe("post") + expect(createFirst.data).toEqual(toNTypeWithPrefixedKey(notificationTypeWithAllProperties)) - expect(createSecond.method).toBe("post"); - expect(createSecond.data).toEqual(toNTypeWithPrefixedKey({ ...notificationTypeWithoutVersion, NotificationTypeVersion: "1" })); + expect(createSecond.method).toBe("post") + expect(createSecond.data).toEqual(toNTypeWithPrefixedKey({ ...notificationTypeWithoutVersion, NotificationTypeVersion: "1" })) - expect(extra).toBeUndefined(); - }); - }); + expect(extra).toBeUndefined() + }) + }) - it("Do not create Default type when it already exists in Work Zone", () => { - httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody); + test("Do not create Default type when it already exists in Work Zone", () => { + httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody) return notificationTypes.processNotificationTypes([structuredClone(notificationTypeWithAllProperties), structuredClone(notificationTypeWithoutVersion)]).then(() => { - const postCalls = httpClient.executeHttpRequest.mock.calls.filter(c => c[1].method === "post"); - expect(postCalls).toHaveLength(0); - }); - }); + const postCalls = httpClient.executeHttpRequest.mock.calls.filter(c => c[1].method === "post") + expect(postCalls).toHaveLength(0) + }) + }) - it("Create a missing version when another version of the same type exists", () => { - httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody); - const versionTwo = structuredClone(notificationTypeWithAllProperties); - versionTwo.NotificationTypeVersion = "2"; + test("Create a missing version when another version of the same type exists", () => { + httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody) + const versionTwo = structuredClone(notificationTypeWithAllProperties) + versionTwo.NotificationTypeVersion = "2" return notificationTypes.processNotificationTypes([structuredClone(notificationTypeWithAllProperties), versionTwo, structuredClone(notificationTypeWithoutVersion)]).then(() => { - const createCall = httpClient.executeHttpRequest.mock.calls[1][1]; - expect(createCall.method).toBe("post"); - expect(createCall.data.NotificationTypeVersion).toBe("2"); - expect(httpClient.executeHttpRequest.mock.calls[2]).toBeUndefined(); - }); - }); - - it("Fall back gracefully when create response has no data.d", () => { + const createCall = httpClient.executeHttpRequest.mock.calls[1][1] + expect(createCall.method).toBe("post") + expect(createCall.data.NotificationTypeVersion).toBe("2") + expect(httpClient.executeHttpRequest.mock.calls[2]).toBeUndefined() + }) + }) + + test("Fall back gracefully when create response has no data.d", () => { httpClient.executeHttpRequest .mockReturnValueOnce(emptyResponseBody) - .mockReturnValue({ status: 201 }); + .mockReturnValue({ status: 201 }) return notificationTypes.processNotificationTypes([structuredClone(notificationTypeWithAllProperties)]).then(() => { - expect(httpClient.executeHttpRequest.mock.calls[1][1].method).toBe("post"); - }); - }); + expect(httpClient.executeHttpRequest.mock.calls[1][1].method).toBe("post") + }) + }) - it("Create new types correctly when existing types use OData results format", () => { + test("Create new types correctly when existing types use OData results format", () => { httpClient.executeHttpRequest.mockReturnValue({ data: { d: { @@ -412,137 +412,137 @@ describe("Managing of Notification Types", () => { ] } } - }); + }) return notificationTypes.processNotificationTypes([structuredClone(notificationTypeWithAllProperties)]).then(() => { - expect(httpClient.executeHttpRequest.mock.calls[1][1].method).toBe("post"); - expect(httpClient.executeHttpRequest.mock.calls[2]).toBeUndefined(); - }); - }); - }); + expect(httpClient.executeHttpRequest.mock.calls[1][1].method).toBe("post") + expect(httpClient.executeHttpRequest.mock.calls[2]).toBeUndefined() + }) + }) + }) describe("Updating Types", () => { - it("Update all changed types", () => { - httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody); + test("Update all changed types", () => { + httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody) - const updatedWithAll = structuredClone(notificationTypeWithAllProperties); - updatedWithAll.Templates[0].Description = "New Description"; - const updatedWithoutVersion = structuredClone(notificationTypeWithoutVersion); - updatedWithoutVersion.Templates[0].Description = "New Description"; + const updatedWithAll = structuredClone(notificationTypeWithAllProperties) + updatedWithAll.Templates[0].Description = "New Description" + const updatedWithoutVersion = structuredClone(notificationTypeWithoutVersion) + updatedWithoutVersion.Templates[0].Description = "New Description" return notificationTypes.processNotificationTypes([structuredClone(updatedWithAll), structuredClone(updatedWithoutVersion)]).then(() => { - const [, updateFirst, updateSecond, extra] = httpClient.executeHttpRequest.mock.calls.map(c => c[1]); + const [, updateFirst, updateSecond, extra] = httpClient.executeHttpRequest.mock.calls.map(c => c[1]) - expect(updateFirst.url).toBe("v2/NotificationType.svc/NotificationTypes(guid'26f1fad0-de4c-4869-9b4e-62f445c8a7a8')"); - expect(updateFirst.method).toBe("patch"); - expect(updateFirst.data).toEqual(toNTypeWithPrefixedKey(updatedWithAll)); + expect(updateFirst.url).toBe("v2/NotificationType.svc/NotificationTypes(guid'26f1fad0-de4c-4869-9b4e-62f445c8a7a8')") + expect(updateFirst.method).toBe("patch") + expect(updateFirst.data).toEqual(toNTypeWithPrefixedKey(updatedWithAll)) - expect(updateSecond.url).toBe("v2/NotificationType.svc/NotificationTypes(guid'719d8f6a-1e07-4981-b2be-07197cec7492')"); - expect(updateSecond.method).toBe("patch"); - expect(updateSecond.data).toEqual(toNTypeWithPrefixedKey({ ...updatedWithoutVersion, NotificationTypeVersion: "1" })); + expect(updateSecond.url).toBe("v2/NotificationType.svc/NotificationTypes(guid'719d8f6a-1e07-4981-b2be-07197cec7492')") + expect(updateSecond.method).toBe("patch") + expect(updateSecond.data).toEqual(toNTypeWithPrefixedKey({ ...updatedWithoutVersion, NotificationTypeVersion: "1" })) - expect(extra).toBeUndefined(); - }); - }); + expect(extra).toBeUndefined() + }) + }) - it("Update type when an additional Template is added", () => { - httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody); + test("Update type when an additional Template is added", () => { + httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody) - const updated = structuredClone(notificationTypeWithAllProperties); - updated.Templates[1] = updated.Templates[0]; - updated.Templates[1].Language = "DE"; + const updated = structuredClone(notificationTypeWithAllProperties) + updated.Templates[1] = updated.Templates[0] + updated.Templates[1].Language = "DE" return notificationTypes.processNotificationTypes([structuredClone(updated), structuredClone(notificationTypeWithoutVersion)]).then(() => { - const [, updateCall, extra] = httpClient.executeHttpRequest.mock.calls.map(c => c[1]); - expect(updateCall.url).toBe("v2/NotificationType.svc/NotificationTypes(guid'26f1fad0-de4c-4869-9b4e-62f445c8a7a8')"); - expect(updateCall.method).toBe("patch"); - expect(updateCall.data).toEqual(toNTypeWithPrefixedKey(updated)); - expect(extra).toBeUndefined(); - }); - }); + const [, updateCall, extra] = httpClient.executeHttpRequest.mock.calls.map(c => c[1]) + expect(updateCall.url).toBe("v2/NotificationType.svc/NotificationTypes(guid'26f1fad0-de4c-4869-9b4e-62f445c8a7a8')") + expect(updateCall.method).toBe("patch") + expect(updateCall.data).toEqual(toNTypeWithPrefixedKey(updated)) + expect(extra).toBeUndefined() + }) + }) - it("Update type when an additional Action is added", () => { - httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody); + test("Update type when an additional Action is added", () => { + httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody) - const updated = structuredClone(notificationTypeWithAllProperties); - updated.Actions[1] = updated.Actions[0]; - updated.Actions[1].Language = "DE"; + const updated = structuredClone(notificationTypeWithAllProperties) + updated.Actions[1] = updated.Actions[0] + updated.Actions[1].Language = "DE" return notificationTypes.processNotificationTypes([structuredClone(updated), structuredClone(notificationTypeWithoutVersion)]).then(() => { - const [, updateCall, extra] = httpClient.executeHttpRequest.mock.calls.map(c => c[1]); - expect(updateCall.url).toBe("v2/NotificationType.svc/NotificationTypes(guid'26f1fad0-de4c-4869-9b4e-62f445c8a7a8')"); - expect(updateCall.method).toBe("patch"); - expect(updateCall.data).toEqual(toNTypeWithPrefixedKey(updated)); - expect(extra).toBeUndefined(); - }); - }); + const [, updateCall, extra] = httpClient.executeHttpRequest.mock.calls.map(c => c[1]) + expect(updateCall.url).toBe("v2/NotificationType.svc/NotificationTypes(guid'26f1fad0-de4c-4869-9b4e-62f445c8a7a8')") + expect(updateCall.method).toBe("patch") + expect(updateCall.data).toEqual(toNTypeWithPrefixedKey(updated)) + expect(extra).toBeUndefined() + }) + }) - it("Update type when an additional DeliveryChannel is added", () => { - httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody); + test("Update type when an additional DeliveryChannel is added", () => { + httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody) - const updated = structuredClone(notificationTypeWithAllProperties); - updated.DeliveryChannels[1] = updated.DeliveryChannels[0]; - updated.DeliveryChannels[1].Type = "MOBILE"; + const updated = structuredClone(notificationTypeWithAllProperties) + updated.DeliveryChannels[1] = updated.DeliveryChannels[0] + updated.DeliveryChannels[1].Type = "MOBILE" return notificationTypes.processNotificationTypes([structuredClone(updated), structuredClone(notificationTypeWithoutVersion)]).then(() => { - const [, updateCall, extra] = httpClient.executeHttpRequest.mock.calls.map(c => c[1]); - expect(updateCall.url).toBe("v2/NotificationType.svc/NotificationTypes(guid'26f1fad0-de4c-4869-9b4e-62f445c8a7a8')"); - expect(updateCall.method).toBe("patch"); - expect(updateCall.data).toEqual(toNTypeWithPrefixedKey(updated)); - expect(extra).toBeUndefined(); - }); - }); + const [, updateCall, extra] = httpClient.executeHttpRequest.mock.calls.map(c => c[1]) + expect(updateCall.url).toBe("v2/NotificationType.svc/NotificationTypes(guid'26f1fad0-de4c-4869-9b4e-62f445c8a7a8')") + expect(updateCall.method).toBe("patch") + expect(updateCall.data).toEqual(toNTypeWithPrefixedKey(updated)) + expect(extra).toBeUndefined() + }) + }) - it("Update type when any individual field has changed", async () => { - httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody); + test("Update type when any individual field has changed", async () => { + httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody) const mutate = (target, key, value) => { - if (typeof value === "string") target[key] = value + " UPDATED"; - else if (typeof value === "boolean") target[key] = !value; - else if (typeof value === "number") target[key] = value + 1; - else return false; - return true; - }; + if (typeof value === "string") target[key] = value + " UPDATED" + else if (typeof value === "boolean") target[key] = !value + else if (typeof value === "number") target[key] = value + 1 + else return false + return true + } const assertUpdateCall = (changed) => { - const [, updateCall, extra] = httpClient.executeHttpRequest.mock.calls.map(c => c[1]); - expect(updateCall.url).toBe("v2/NotificationType.svc/NotificationTypes(guid'26f1fad0-de4c-4869-9b4e-62f445c8a7a8')"); - expect(updateCall.method).toBe("patch"); - expect(updateCall.data).toEqual(toNTypeWithPrefixedKey(changed)); - expect(extra).toBeUndefined(); - }; + const [, updateCall, extra] = httpClient.executeHttpRequest.mock.calls.map(c => c[1]) + expect(updateCall.url).toBe("v2/NotificationType.svc/NotificationTypes(guid'26f1fad0-de4c-4869-9b4e-62f445c8a7a8')") + expect(updateCall.method).toBe("patch") + expect(updateCall.data).toEqual(toNTypeWithPrefixedKey(changed)) + expect(extra).toBeUndefined() + } for (const [key, value] of Object.entries(notificationTypeWithAllProperties)) { - if (key === "NotificationTypeKey" || key === "NotificationTypeVersion") continue; - const changed = structuredClone(notificationTypeWithAllProperties); - if (!mutate(changed, key, value)) continue; - await notificationTypes.processNotificationTypes([structuredClone(changed), structuredClone(notificationTypeWithoutVersion)]).then(() => assertUpdateCall(changed)); - jest.clearAllMocks(); + if (key === "NotificationTypeKey" || key === "NotificationTypeVersion") continue + const changed = structuredClone(notificationTypeWithAllProperties) + if (!mutate(changed, key, value)) continue + await notificationTypes.processNotificationTypes([structuredClone(changed), structuredClone(notificationTypeWithoutVersion)]).then(() => assertUpdateCall(changed)) + jest.clearAllMocks() } for (const [key, value] of Object.entries(notificationTypeWithAllProperties.Templates[0])) { - const changed = structuredClone(notificationTypeWithAllProperties); - if (!mutate(changed.Templates[0], key, value)) continue; - await notificationTypes.processNotificationTypes([structuredClone(changed), structuredClone(notificationTypeWithoutVersion)]).then(() => assertUpdateCall(changed)); - jest.clearAllMocks(); + const changed = structuredClone(notificationTypeWithAllProperties) + if (!mutate(changed.Templates[0], key, value)) continue + await notificationTypes.processNotificationTypes([structuredClone(changed), structuredClone(notificationTypeWithoutVersion)]).then(() => assertUpdateCall(changed)) + jest.clearAllMocks() } for (const [key, value] of Object.entries(notificationTypeWithAllProperties.Actions[0])) { - const changed = structuredClone(notificationTypeWithAllProperties); - if (!mutate(changed.Actions[0], key, value)) continue; - await notificationTypes.processNotificationTypes([structuredClone(changed), structuredClone(notificationTypeWithoutVersion)]).then(() => assertUpdateCall(changed)); - jest.clearAllMocks(); + const changed = structuredClone(notificationTypeWithAllProperties) + if (!mutate(changed.Actions[0], key, value)) continue + await notificationTypes.processNotificationTypes([structuredClone(changed), structuredClone(notificationTypeWithoutVersion)]).then(() => assertUpdateCall(changed)) + jest.clearAllMocks() } for (const [key, value] of Object.entries(notificationTypeWithAllProperties.DeliveryChannels[0])) { - const changed = structuredClone(notificationTypeWithAllProperties); - if (!mutate(changed.DeliveryChannels[0], key, value)) continue; - await notificationTypes.processNotificationTypes([structuredClone(changed), structuredClone(notificationTypeWithoutVersion)]).then(() => assertUpdateCall(changed)); - jest.clearAllMocks(); + const changed = structuredClone(notificationTypeWithAllProperties) + if (!mutate(changed.DeliveryChannels[0], key, value)) continue + await notificationTypes.processNotificationTypes([structuredClone(changed), structuredClone(notificationTypeWithoutVersion)]).then(() => assertUpdateCall(changed)) + jest.clearAllMocks() } - }); + }) - it("Update type when existing type has null inner results", () => { + test("Update type when existing type has null inner results", () => { httpClient.executeHttpRequest.mockReturnValue({ data: { d: { @@ -557,80 +557,80 @@ describe("Managing of Notification Types", () => { }] } } - }); + }) return notificationTypes.processNotificationTypes([structuredClone(notificationTypeWithAllProperties)]).then(() => { - const [, updateCall] = httpClient.executeHttpRequest.mock.calls.map(c => c[1]); - expect(updateCall.method).toBe("patch"); - }); - }); - }); + const [, updateCall] = httpClient.executeHttpRequest.mock.calls.map(c => c[1]) + expect(updateCall.method).toBe("patch") + }) + }) + }) describe("No Changed Needed", () => { - it("Do nothing when all types match exactly", () => { - httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody); + test("Do nothing when all types match exactly", () => { + httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody) return notificationTypes.processNotificationTypes([structuredClone(notificationTypeWithAllProperties), structuredClone(notificationTypeWithoutVersion)]).then(() => { - expect(httpClient.executeHttpRequest.mock.calls[1]).toBeUndefined(); - }); - }); + expect(httpClient.executeHttpRequest.mock.calls[1]).toBeUndefined() + }) + }) - it("Do nothing when input Templates/Actions/DeliveryChannels use OData results wrapper", () => { - httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody); + test("Do nothing when input Templates/Actions/DeliveryChannels use OData results wrapper", () => { + httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody) - const local = structuredClone(notificationTypeWithAllProperties); - local.Templates = { results: notificationTypeWithAllProperties.Templates }; - local.Actions = { results: notificationTypeWithAllProperties.Actions }; - local.DeliveryChannels = { results: notificationTypeWithAllProperties.DeliveryChannels }; + const local = structuredClone(notificationTypeWithAllProperties) + local.Templates = { results: notificationTypeWithAllProperties.Templates } + local.Actions = { results: notificationTypeWithAllProperties.Actions } + local.DeliveryChannels = { results: notificationTypeWithAllProperties.DeliveryChannels } return notificationTypes.processNotificationTypes([local, structuredClone(notificationTypeWithoutVersion)]).then(() => { - expect(httpClient.executeHttpRequest.mock.calls[1]).toBeUndefined(); - }); - }); + expect(httpClient.executeHttpRequest.mock.calls[1]).toBeUndefined() + }) + }) - it("Do nothing when IsGroupable is undefined (treated as true)", () => { - httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody); + test("Do nothing when IsGroupable is undefined (treated as true)", () => { + httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody) - const local = structuredClone(notificationTypeWithAllProperties); - local.IsGroupable = undefined; + const local = structuredClone(notificationTypeWithAllProperties) + local.IsGroupable = undefined return notificationTypes.processNotificationTypes([local, structuredClone(notificationTypeWithoutVersion)]).then(() => { - expect(httpClient.executeHttpRequest.mock.calls[1]).toBeUndefined(); - }); - }); + expect(httpClient.executeHttpRequest.mock.calls[1]).toBeUndefined() + }) + }) - it("Do nothing when Language fields are lowercase", () => { - httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody); + test("Do nothing when Language fields are lowercase", () => { + httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody) - const local = structuredClone(notificationTypeWithAllProperties); - local.Templates[0].Language = notificationTypeWithAllProperties.Templates[0].Language.toLowerCase(); - local.Actions[0].Language = notificationTypeWithAllProperties.Actions[0].Language.toLowerCase(); + const local = structuredClone(notificationTypeWithAllProperties) + local.Templates[0].Language = notificationTypeWithAllProperties.Templates[0].Language.toLowerCase() + local.Actions[0].Language = notificationTypeWithAllProperties.Actions[0].Language.toLowerCase() return notificationTypes.processNotificationTypes([local, structuredClone(notificationTypeWithoutVersion)]).then(() => { - expect(httpClient.executeHttpRequest.mock.calls[1]).toBeUndefined(); - }); - }); + expect(httpClient.executeHttpRequest.mock.calls[1]).toBeUndefined() + }) + }) - it("Do nothing when TemplateLanguage is lowercase", () => { - httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody); + test("Do nothing when TemplateLanguage is lowercase", () => { + httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody) - const local = structuredClone(notificationTypeWithAllProperties); - local.Templates[0].TemplateLanguage = notificationTypeWithAllProperties.Templates[0].TemplateLanguage.toLowerCase(); + const local = structuredClone(notificationTypeWithAllProperties) + local.Templates[0].TemplateLanguage = notificationTypeWithAllProperties.Templates[0].TemplateLanguage.toLowerCase() return notificationTypes.processNotificationTypes([local, structuredClone(notificationTypeWithoutVersion)]).then(() => { - expect(httpClient.executeHttpRequest.mock.calls[1]).toBeUndefined(); - }); - }); + expect(httpClient.executeHttpRequest.mock.calls[1]).toBeUndefined() + }) + }) - it("Do nothing when Templates, Actions and DeliveryChannels are null in both local and remote", () => { - httpClient.executeHttpRequest.mockReturnValue(allExistingWithUndefinedTemplatesActionsAndDeliveryChannelsResponseBody); + test("Do nothing when Templates, Actions and DeliveryChannels are null in both local and remote", () => { + httpClient.executeHttpRequest.mockReturnValue(allExistingWithUndefinedTemplatesActionsAndDeliveryChannelsResponseBody) return notificationTypes.processNotificationTypes([structuredClone(notificationTypeWithNullTemplatesActionsAndDeliveryChannels)]).then(() => { - expect(httpClient.executeHttpRequest.mock.calls[1]).toBeUndefined(); - }); - }); + expect(httpClient.executeHttpRequest.mock.calls[1]).toBeUndefined() + }) + }) - it("Do nothing when existing type matches local type exactly", () => { + test("Do nothing when existing type matches local type exactly", () => { httpClient.executeHttpRequest.mockReturnValue({ data: { d: { @@ -655,29 +655,29 @@ describe("Managing of Notification Types", () => { ] } } - }); + }) return notificationTypes.processNotificationTypes([structuredClone(notificationTypeWithAllProperties)]).then(() => { - expect(httpClient.executeHttpRequest.mock.calls[1]).toBeUndefined(); - }); - }); - }); + expect(httpClient.executeHttpRequest.mock.calls[1]).toBeUndefined() + }) + }) + }) - it("Deletes a type that is no longer in the local file", () => { - httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody); + test("Deletes a type that is no longer in the local file", () => { + httpClient.executeHttpRequest.mockReturnValue(allExistingResponseBody) return notificationTypes.processNotificationTypes([structuredClone(notificationTypeWithAllProperties)]).then(() => { - const [, deleteCall, extra] = httpClient.executeHttpRequest.mock.calls.map(c => c[1]); - expect(deleteCall.url).toBe("v2/NotificationType.svc/NotificationTypes(guid'719d8f6a-1e07-4981-b2be-07197cec7492')"); - expect(deleteCall.method).toBe("delete"); - expect(extra).toBeUndefined(); - }); - }); - }); -}); + const [, deleteCall, extra] = httpClient.executeHttpRequest.mock.calls.map(c => c[1]) + expect(deleteCall.url).toBe("v2/NotificationType.svc/NotificationTypes(guid'719d8f6a-1e07-4981-b2be-07197cec7492')") + expect(deleteCall.method).toBe("delete") + expect(extra).toBeUndefined() + }) + }) + }) +}) function toNTypeWithPrefixedKey(ntype) { - var prefixedNtype = structuredClone(ntype); - prefixedNtype.NotificationTypeKey = testPrefix + "/" + prefixedNtype.NotificationTypeKey; - return prefixedNtype; + var prefixedNtype = structuredClone(ntype) + prefixedNtype.NotificationTypeKey = testPrefix + "/" + prefixedNtype.NotificationTypeKey + return prefixedNtype } diff --git a/tests/unit/lib/notifications.test.js b/tests/unit/lib/notifications.test.js index 29e8bfc..b977f8d 100644 --- a/tests/unit/lib/notifications.test.js +++ b/tests/unit/lib/notifications.test.js @@ -1,11 +1,11 @@ -const cds = require('@sap/cds'); -const { getNotificationDestination } = require("../../../lib/utils"); -const { buildHeadersForDestination } = require("@sap-cloud-sdk/connectivity"); -const NotifyToRest = require("../../../srv/notifyToRest"); -const { executeHttpRequest } = require("@sap-cloud-sdk/http-client"); +const cds = require('@sap/cds') +const { getNotificationDestination } = require("../../../lib/utils") +const { buildHeadersForDestination } = require("@sap-cloud-sdk/connectivity") +const NotifyToRest = require("../../../srv/notifyToRest") +const { executeHttpRequest } = require("@sap-cloud-sdk/http-client") -jest.mock("../../../lib/utils"); -jest.mock("@sap-cloud-sdk/connectivity"); +jest.mock("../../../lib/utils") +jest.mock("@sap-cloud-sdk/connectivity") jest.mock("@sap-cloud-sdk/http-client") const expectedCustomNotification = { @@ -29,43 +29,43 @@ const expectedCustomNotification = { } ], Recipients: [{ RecipientId: "test.mail@mail.com" }] -}; +} describe("Test post notification", () => { let log = cds.test.log() - let alert; + let alert beforeEach(() => { - alert = new NotifyToRest; - getNotificationDestination.mockReturnValue(undefined); - buildHeadersForDestination.mockReturnValue(undefined); - }); + alert = new NotifyToRest + getNotificationDestination.mockReturnValue(undefined) + buildHeadersForDestination.mockReturnValue(undefined) + }) - it("Logs and sends when a valid notification object is posted", async () => { - executeHttpRequest.mockReturnValue(expectedCustomNotification); + test("Logs and sends when a valid notification object is posted", async () => { + executeHttpRequest.mockReturnValue(expectedCustomNotification) await alert.postNotification(expectedCustomNotification) - expect(log.output).toContain("Sending notification of key: Custom and version: 1"); - expect(executeHttpRequest).toHaveBeenCalled(); + expect(log.output).toContain("Sending notification of key: Custom and version: 1") + expect(executeHttpRequest).toHaveBeenCalled() }) - it.each([ + test.each([ [500, false], [404, true], [429, false], ])("HTTP %i error - unrecoverable is %s", async (status, unrecoverable) => { - expect.assertions(3); - const error = new Error(); - error.response = { message: "mocked error", status }; - executeHttpRequest.mockRejectedValue(error); + expect.assertions(3) + const error = new Error() + error.response = { message: "mocked error", status } + executeHttpRequest.mockRejectedValue(error) try { - await alert.postNotification(expectedCustomNotification); + await alert.postNotification(expectedCustomNotification) } catch (err) { - expect(!!err.unrecoverable).toBe(unrecoverable); + expect(!!err.unrecoverable).toBe(unrecoverable) } - expect(log.output).toContain("Sending notification of key: Custom and version: 1"); - expect(executeHttpRequest).toHaveBeenCalled(); - }); + expect(log.output).toContain("Sending notification of key: Custom and version: 1") + expect(executeHttpRequest).toHaveBeenCalled() + }) }) diff --git a/tests/unit/lib/plugin.test.js b/tests/unit/lib/plugin.test.js new file mode 100644 index 0000000..b821f48 --- /dev/null +++ b/tests/unit/lib/plugin.test.js @@ -0,0 +1,54 @@ +const cds = require("@sap/cds") +require("../../../cds-plugin") + +function makeModel(defs) { + return { definitions: defs } +} + +describe("Loaded hook - recipients injection", () => { + + test("Inject recipients into a notification event that has none", () => { + const model = makeModel({ + "MyEvent": { + kind: "event", + "@notification.template.title": "Test", + elements: {} + } + }) + cds.emit("loaded", model) + expect(model.definitions.MyEvent.elements.recipients).toEqual({ items: { type: "cds.String" } }) + }) + + test("Do not overwrite recipients already defined on the event", () => { + const existing = { items: { type: "cds.String" } } + const model = makeModel({ + "MyEvent": { + kind: "event", + "@notification.template.title": "Test", + elements: { recipients: existing } + } + }) + cds.emit("loaded", model) + expect(model.definitions.MyEvent.elements.recipients).toBe(existing) + }) + + test("Do not inject recipients on events without @notification", () => { + const model = makeModel({ + "PlainEvent": { kind: "event", elements: {} } + }) + cds.emit("loaded", model) + expect(model.definitions.PlainEvent.elements.recipients).toBeUndefined() + }) + + test("Create elements object if missing before injecting", () => { + const model = makeModel({ + "MyEvent": { + kind: "event", + "@notification": true + } + }) + cds.emit("loaded", model) + expect(model.definitions.MyEvent.elements.recipients).toEqual({ items: { type: "cds.String" } }) + }) + +}) \ No newline at end of file diff --git a/tests/unit/lib/utils.test.js b/tests/unit/lib/utils.test.js index a1665d7..4434077 100644 --- a/tests/unit/lib/utils.test.js +++ b/tests/unit/lib/utils.test.js @@ -1,10 +1,10 @@ -const cds = require("@sap/cds"); -const { buildNotification, validateNotificationTypes, readFile, getNotificationDestination } = require("../../../lib/utils"); -const { existsSync, readFileSync } = require("fs"); -const { getDestination } = require("@sap-cloud-sdk/connectivity"); +const cds = require("@sap/cds") +const { buildNotification, validateNotificationTypes, readFile, getNotificationDestination } = require("../../../lib/utils") +const { existsSync, readFileSync } = require("fs") +const { getDestination } = require("@sap-cloud-sdk/connectivity") -jest.mock("fs"); -jest.mock("@sap-cloud-sdk/connectivity"); +jest.mock("fs") +jest.mock("@sap-cloud-sdk/connectivity") describe("Test utils", () => { @@ -31,7 +31,7 @@ describe("Test utils", () => { } ], Recipients: [{ RecipientId: "test.mail@mail.com" }] - }; + } const expectedWithDescription = { ...expectedWithoutDescription, @@ -51,28 +51,28 @@ describe("Test utils", () => { Type: "String" } ] - }; + } - it("Build a default notification with priority", () => { + test("Build a default notification with priority", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], title: "Some Test Title", priority: "NEUTRAL" }) - ).toMatchObject(expectedWithoutDescription); - }); + ).toMatchObject(expectedWithoutDescription) + }) - it("Build a default notification without priority", () => { + test("Build a default notification without priority", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], title: "Some Test Title" }) - ).toMatchObject(expectedWithoutDescription); - }); + ).toMatchObject(expectedWithoutDescription) + }) - it("Build a default notification with description and priority", () => { + test("Build a default notification with description and priority", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], @@ -80,19 +80,19 @@ describe("Test utils", () => { priority: "NEUTRAL", description: "Some Test Description" }) - ).toMatchObject(expectedWithDescription); - }); + ).toMatchObject(expectedWithDescription) + }) - it("Build a default notification with description", () => { + test("Build a default notification with description", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], title: "Some Test Title", description: "Some Test Description" }) - ).toMatchObject(expectedWithDescription); - }); - }); + ).toMatchObject(expectedWithDescription) + }) + }) describe("Custom notifications", () => { const properties = [{ @@ -101,13 +101,13 @@ describe("Test utils", () => { Language: "en", Value: "Some Test Title", Type: "String" - }]; + }] const baseInput = { recipients: ["test.mail@mail.com"], type: "TestNotificationType", Properties: properties - }; + } const baseExpected = { NotificationTypeKey: "notifications/TestNotificationType", @@ -115,13 +115,13 @@ describe("Test utils", () => { Priority: "NEUTRAL", Properties: properties, Recipients: [{ RecipientId: "test.mail@mail.com" }] - }; + } - it("Build a custom notification with properties", () => { - expect(buildNotification(baseInput)).toMatchObject(baseExpected); - }); + test("Build a custom notification with properties", () => { + expect(buildNotification(baseInput)).toMatchObject(baseExpected) + }) - it("Build a custom notification with navigation targets", () => { + test("Build a custom notification with navigation targets", () => { expect(buildNotification({ ...baseInput, NavigationTargetAction: "TestTargetAction", @@ -130,20 +130,20 @@ describe("Test utils", () => { ...baseExpected, NavigationTargetAction: "TestTargetAction", NavigationTargetObject: "TestTargetObject" - }); - }); + }) + }) - it("Build a custom notification with a non-default priority", () => { + test("Build a custom notification with a non-default priority", () => { expect(buildNotification({ ...baseInput, priority: "HIGH" })).toMatchObject({ ...baseExpected, Priority: "HIGH" - }); - }); + }) + }) - it("Build a custom notification with a non-default priority and navigation targets", () => { + test("Build a custom notification with a non-default priority and navigation targets", () => { expect( buildNotification({ ...baseInput, @@ -156,10 +156,10 @@ describe("Test utils", () => { NavigationTargetAction: "TestTargetAction", NavigationTargetObject: "TestTargetObject", Priority: "HIGH" - }); - }); + }) + }) - it("Maps data object to Properties array", () => { + test("Maps data object to Properties array", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], @@ -174,10 +174,10 @@ describe("Test utils", () => { Language: "en", Type: "string" }] - }); - }); + }) + }) - it("Pass all low-level API fields through to the notification", () => { + test("Pass all low-level API fields through to the notification", () => { const lowLevelFields = { OriginId: "01234567-89ab-cdef-0123-456789abcdef", NotificationTypeId: "01234567-89ab-cdef-0123-456789abcdef", @@ -189,7 +189,7 @@ describe("Test utils", () => { ActorImageURL: "https://some-url", NotificationTypeTimestamp: "2022-03-15T09:58:42.807Z", TargetParameters: [{ Key: "string", Value: "string" }] - }; + } expect(buildNotification({ ...baseInput, @@ -199,10 +199,10 @@ describe("Test utils", () => { ...baseExpected, ...lowLevelFields, Priority: "HIGH" - }); - }); + }) + }) - it("Pass partial low-level API fields through to the notification", () => { + test("Pass partial low-level API fields through to the notification", () => { const partialLowLevelFields = { NotificationTypeId: "01234567-89ab-cdef-0123-456789abcdef", NavigationTargetAction: "TestTargetAction", @@ -213,7 +213,7 @@ describe("Test utils", () => { ActorImageURL: "https://some-url", NotificationTypeTimestamp: "2022-03-15T09:58:42.807Z", TargetParameters: [{ Key: "string", Value: "string" }] - }; + } expect( buildNotification({ @@ -225,56 +225,56 @@ describe("Test utils", () => { ...baseExpected, ...partialLowLevelFields, Priority: "HIGH" - }); - }); - }); + }) + }) + }) describe("Invalid inputs", () => { - it("Return falsy when an empty object is passed", () => { - expect(buildNotification({})).toBeFalsy(); - }); + test("Return falsy when an empty object is passed", () => { + expect(buildNotification({})).toBeFalsy() + }) describe("Default notification", () => { - it("Return falsy when title is missing", () => { + test("Return falsy when title is missing", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], priority: "NEUTRAL" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) - it("Return falsy when recipients is empty", () => { + test("Return falsy when recipients is empty", () => { expect( buildNotification({ recipients: [], title: "Some Test Title", priority: "NEUTRAL" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) - it("Return falsy when recipients is not an array", () => { + test("Return falsy when recipients is not an array", () => { expect( buildNotification({ recipients: "invalid", title: "Some Test Title", priority: "NEUTRAL" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) - it("Return falsy when priority is not a valid value", () => { + test("Return falsy when priority is not a valid value", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], title: "Some Test Title", priority: "INVALID" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) - it("Return falsy when description is not a string", () => { + test("Return falsy when description is not a string", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], @@ -282,58 +282,58 @@ describe("Test utils", () => { priority: "NEUTRAL", description: { invalid: "invalid" } }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) - it("Return falsy when title is not a string", () => { + test("Return falsy when title is not a string", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], title: { invalid: "invalid" }, priority: "NEUTRAL" }) - ).toBeFalsy(); - }); - }); + ).toBeFalsy() + }) + }) describe("Custom notification", () => { - it("Return falsy when recipients is missing", () => { + test("Return falsy when recipients is missing", () => { expect( buildNotification({ type: "TestNotificationType" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) - it("Return falsy when recipients is empty", () => { + test("Return falsy when recipients is empty", () => { expect( buildNotification({ recipients: [], type: "TestNotificationType" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) - it("Return falsy when recipients is not an array", () => { + test("Return falsy when recipients is not an array", () => { expect( buildNotification({ recipients: "invalid", type: "TestNotificationType" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) - it("Return falsy when priority is not a valid value", () => { + test("Return falsy when priority is not a valid value", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], type: "TestNotificationType", priority: "invalid" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) - it("Return falsy when properties is not an array", () => { + test("Return falsy when properties is not an array", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], @@ -341,10 +341,10 @@ describe("Test utils", () => { priority: "NEUTRAL", properties: "invalid" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) - it("Return falsy when navigation is not an object", () => { + test("Return falsy when navigation is not an object", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], @@ -352,10 +352,10 @@ describe("Test utils", () => { priority: "NEUTRAL", navigation: "invalid" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) - it("Return falsy when payload is not an object", () => { + test("Return falsy when payload is not an object", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], @@ -363,12 +363,12 @@ describe("Test utils", () => { priority: "NEUTRAL", payload: "invalid" }) - ).toBeFalsy(); - }); - }); - }); + ).toBeFalsy() + }) + }) + }) - it("Pass a raw notification object through with the prefix applied to the type key", () => { + test("Pass a raw notification object through with the prefix applied to the type key", () => { const rawNotification = { NotificationTypeKey: "TestNotificationType", NotificationTypeVersion: "1", @@ -390,166 +390,166 @@ describe("Test utils", () => { } ], Recipients: [{ RecipientId: "test.mail@mail.com" }] - }; + } expect( buildNotification({ ...rawNotification })) .toMatchObject({ ...rawNotification, NotificationTypeKey: "notifications/TestNotificationType" - }); - }); - }); + }) + }) + }) describe("Notification types validation", () => { - it("Return false when an entry is missing NotificationTypeKey", () => { - expect(validateNotificationTypes([{ NotificationTypeKey: "Test" }, { blabla: "Test2" }])).toEqual(false); - }); + test("Return false when an entry is missing NotificationTypeKey", () => { + expect(validateNotificationTypes([{ NotificationTypeKey: "Test" }, { blabla: "Test2" }])).toEqual(false) + }) - it("Return true for an empty array", () => { - expect(validateNotificationTypes([])).toBe(true); - }); + test("Return true for an empty array", () => { + expect(validateNotificationTypes([])).toBe(true) + }) - it("Return true when all entries have NotificationTypeKey", () => { - expect(validateNotificationTypes([{ NotificationTypeKey: "Test" }, { NotificationTypeKey: "Test2" }])).toEqual(true); - }); - }); + test("Return true when all entries have NotificationTypeKey", () => { + expect(validateNotificationTypes([{ NotificationTypeKey: "Test" }, { NotificationTypeKey: "Test2" }])).toEqual(true) + }) + }) describe("Read file", () => { - it("Return an empty array when the file does not exist", () => { - existsSync.mockReturnValue(false); - expect(readFile("test.json")).toMatchObject([]); - }); - - it("Return the parsed file contents when the file exists", () => { - existsSync.mockReturnValue(true); - readFileSync.mockReturnValue('[{ "test": "test" }]'); - expect(readFile("test.json")).toMatchObject([{ test: "test" }]); - }); - }); + test("Return an empty array when the file does not exist", () => { + existsSync.mockReturnValue(false) + expect(readFile("test.json")).toMatchObject([]) + }) + + test("Return the parsed file contents when the file exists", () => { + existsSync.mockReturnValue(true) + readFileSync.mockReturnValue('[{ "test": "test" }]') + expect(readFile("test.json")).toMatchObject([{ test: "test" }]) + }) + }) describe("Get notification destination", () => { - it("Return the destination when it exists", async () => { - getDestination.mockReturnValue({ "mock-destination": "mock-destination" }); - expect(await getNotificationDestination()).toMatchObject({ "mock-destination": "mock-destination" }); - }); + test("Return the destination when it exists", async () => { + getDestination.mockReturnValue({ "mock-destination": "mock-destination" }) + expect(await getNotificationDestination()).toMatchObject({ "mock-destination": "mock-destination" }) + }) - it("Throw an error when the destination is not found", async () => { - getDestination.mockReturnValue(undefined); - await expect(() => getNotificationDestination()).rejects.toThrow("Failed to get destination: SAP_Notifications"); - }); - }); + test("Throw an error when the destination is not found", async () => { + getDestination.mockReturnValue(undefined) + await expect(() => getNotificationDestination()).rejects.toThrow("Failed to get destination: SAP_Notifications") + }) + }) describe("Configuration", () => { - const log = cds.test.log(); - afterEach(() => { delete cds.env.requires.notifications?.authenticationIdentifier; }); + const log = cds.test.log() + afterEach(() => { delete cds.env.requires.notifications?.authenticationIdentifier }) it("Use GlobalUserId as the recipient key when authenticationIdentifier is set to UserUUID", () => { - cds.env.requires.notifications ??= {}; - cds.env.requires.notifications.authenticationIdentifier = "UserUUID"; + cds.env.requires.notifications ??= {} + cds.env.requires.notifications.authenticationIdentifier = "UserUUID" const result = buildNotification({ recipients: ["user-uuid-123"], title: "Test Title" - }); + }) - expect(result.Recipients[0]).toMatchObject({ GlobalUserId: "user-uuid-123" }); - }); + expect(result.Recipients[0]).toMatchObject({ GlobalUserId: "user-uuid-123" }) + }) it("Auto mode picks GlobalUserId for UUID recipients", () => { - cds.env.requires.notifications ??= {}; - cds.env.requires.notifications.authenticationIdentifier = "auto"; + cds.env.requires.notifications ??= {} + cds.env.requires.notifications.authenticationIdentifier = "auto" const result = buildNotification({ recipients: ["550e8400-e29b-41d4-a716-446655440000"], title: "Test Title" }); - expect(result.Recipients[0]).toMatchObject({ GlobalUserId: "550e8400-e29b-41d4-a716-446655440000" }); - }); + expect(result.Recipients[0]).toMatchObject({ GlobalUserId: "550e8400-e29b-41d4-a716-446655440000" }) + }) it("Auto mode picks RecipientId for email recipients", () => { - cds.env.requires.notifications ??= {}; - cds.env.requires.notifications.authenticationIdentifier = "auto"; + cds.env.requires.notifications ??= {} + cds.env.requires.notifications.authenticationIdentifier = "auto" const result = buildNotification({ recipients: ["test.mail@mail.com"], title: "Test Title" - }); + }) - expect(result.Recipients[0]).toMatchObject({ RecipientId: "test.mail@mail.com" }); - }); + expect(result.Recipients[0]).toMatchObject({ RecipientId: "test.mail@mail.com" }) + }) it("Auto mode supports mixed UUID and email recipients in one notification", () => { - cds.env.requires.notifications ??= {}; - cds.env.requires.notifications.authenticationIdentifier = "auto"; + cds.env.requires.notifications ??= {} + cds.env.requires.notifications.authenticationIdentifier = "auto" const result = buildNotification({ recipients: ["550e8400-e29b-41d4-a716-446655440000", "test.mail@mail.com"], title: "Test Title" - }); + }) expect(result.Recipients).toEqual([ { GlobalUserId: "550e8400-e29b-41d4-a716-446655440000" }, { RecipientId: "test.mail@mail.com" } - ]); - }); + ]) + }) it("Auto mode warns and falls back to RecipientId when value is neither UUID nor email", () => { - cds.env.requires.notifications ??= {}; - cds.env.requires.notifications.authenticationIdentifier = "auto"; - log.clear(); + cds.env.requires.notifications ??= {} + cds.env.requires.notifications.authenticationIdentifier = "auto" + log.clear() const result = buildNotification({ recipients: ["not-a-uuid-or-email"], title: "Test Title" - }); + }) - expect(result.Recipients[0]).toMatchObject({ RecipientId: "not-a-uuid-or-email" }); - expect(log.output).toContain("neither a UUID nor an email"); - }); + expect(result.Recipients[0]).toMatchObject({ RecipientId: "not-a-uuid-or-email" }) + expect(log.output).toContain("neither a UUID nor an email") + }) it("Auto is the default when authenticationIdentifier is not configured", () => { - cds.env.requires.notifications ??= {}; - delete cds.env.requires.notifications.authenticationIdentifier; + cds.env.requires.notifications ??= {} + delete cds.env.requires.notifications.authenticationIdentifier const result = buildNotification({ recipients: ["550e8400-e29b-41d4-a716-446655440000"], title: "Test Title" - }); + }) - expect(result.Recipients[0]).toMatchObject({ GlobalUserId: "550e8400-e29b-41d4-a716-446655440000" }); - }); + expect(result.Recipients[0]).toMatchObject({ GlobalUserId: "550e8400-e29b-41d4-a716-446655440000" }) + }) it("Explicit RecipientId mode never resolves to GlobalUserId even for UUID values", () => { - cds.env.requires.notifications ??= {}; - cds.env.requires.notifications.authenticationIdentifier = "RecipientId"; + cds.env.requires.notifications ??= {} + cds.env.requires.notifications.authenticationIdentifier = "RecipientId" const result = buildNotification({ recipients: ["550e8400-e29b-41d4-a716-446655440000"], title: "Test Title" - }); + }) - expect(result.Recipients[0]).toMatchObject({ RecipientId: "550e8400-e29b-41d4-a716-446655440000" }); - }); + expect(result.Recipients[0]).toMatchObject({ RecipientId: "550e8400-e29b-41d4-a716-446655440000" }) + }) it("Fall back to basename of cds.root as prefix when package.json cannot be read", () => { - let result; + let result jest.isolateModules(() => { - const cds = require("@sap/cds"); - const originalRoot = cds.root; - cds.env.requires.notifications ??= {}; - cds.env.requires.notifications.prefix = "$app-name"; - cds.root = "/nonexistent-path-for-testing"; + const cds = require("@sap/cds") + const originalRoot = cds.root + cds.env.requires.notifications ??= {} + cds.env.requires.notifications.prefix = "$app-name" + cds.root = "/nonexistent-path-for-testing" try { - const { getNotificationTypesKeyWithPrefix } = require("../../../lib/utils"); - result = getNotificationTypesKeyWithPrefix("TestType"); + const { getNotificationTypesKeyWithPrefix } = require("../../../lib/utils") + result = getNotificationTypesKeyWithPrefix("TestType") } finally { - cds.root = originalRoot; - delete cds.env.requires.notifications.prefix; + cds.root = originalRoot + delete cds.env.requires.notifications.prefix } - }); - expect(result).toBe("nonexistent-path-for-testing/TestType"); - }); - }); -}); + }) + expect(result).toBe("nonexistent-path-for-testing/TestType") + }) + }) +}) diff --git a/tests/unit/srv/notifyToRest.test.js b/tests/unit/srv/notifyToRest.test.js index 4166c01..3d2fb02 100644 --- a/tests/unit/srv/notifyToRest.test.js +++ b/tests/unit/srv/notifyToRest.test.js @@ -1,83 +1,83 @@ -const cds = require('@sap/cds'); -const { messages, buildNotification } = require("../../../lib/utils"); -const NotifyToRest = require("../../../srv/notifyToRest"); +const cds = require('@sap/cds') +const { messages, buildNotification } = require("../../../lib/utils") +const NotifyToRest = require("../../../srv/notifyToRest") describe("Notify to rest", () => { let log = cds.test.log() - let notifyToRest; + let notifyToRest beforeEach(() => { - notifyToRest = new NotifyToRest(); + notifyToRest = new NotifyToRest() }) describe("Warnings", () => { - it("No object is passed", async () => { - notifyToRest.notify(); - expect(log.output).toContain(messages.NO_OBJECT_FOR_NOTIFY); - }); + test("No object is passed", async () => { + notifyToRest.notify() + expect(log.output).toContain(messages.NO_OBJECT_FOR_NOTIFY) + }) - it("Empty object is passed", async () => { - notifyToRest.notify({}); - expect(log.output).toContain(messages.EMPTY_OBJECT_FOR_NOTIFY); - }); + test("Empty object is passed", async () => { + notifyToRest.notify({}) + expect(log.output).toContain(messages.EMPTY_OBJECT_FOR_NOTIFY) + }) - it("Recipients or title isn't passed in default notification", async () => { - notifyToRest.notify({ dummy: true }); - expect(log.output).toContain(messages.MANDATORY_PARAMETER_NOT_PASSED_FOR_DEFAULT_NOTIFICATION); - }); + test("Recipients or title isn't passed in default notification", async () => { + notifyToRest.notify({ dummy: true }) + expect(log.output).toContain(messages.MANDATORY_PARAMETER_NOT_PASSED_FOR_DEFAULT_NOTIFICATION) + }) - it("Title isn't a string in default notification", async () => { - notifyToRest.notify({ title: 1, recipients: ["abc@abc.com"] }); - expect(log.output).toContain(messages.TITLE_IS_NOT_STRING); - }); + test("Title isn't a string in default notification", async () => { + notifyToRest.notify({ title: 1, recipients: ["abc@abc.com"] }) + expect(log.output).toContain(messages.TITLE_IS_NOT_STRING) + }) - it("Priority isn't valid in default notification", async () => { - notifyToRest.notify({ title: "abc", recipients: ["abc@abc.com"], priority: "abc" }); - expect(log.output).toContain("Invalid priority abc. Allowed priorities are LOW, NEUTRAL, MEDIUM, HIGH"); - }); + test("Priority isn't valid in default notification", async () => { + notifyToRest.notify({ title: "abc", recipients: ["abc@abc.com"], priority: "abc" }) + expect(log.output).toContain("Invalid priority abc. Allowed priorities are LOW, NEUTRAL, MEDIUM, HIGH") + }) - it("Description isn't valid in default notification", async () => { - notifyToRest.notify({ title: "abc", recipients: ["abc@abc.com"], priority: "low", description: true }); - expect(log.output).toContain(messages.DESCRIPTION_IS_NOT_STRING); - }); - }); + test("Description isn't valid in default notification", async () => { + notifyToRest.notify({ title: "abc", recipients: ["abc@abc.com"], priority: "low", description: true }) + expect(log.output).toContain(messages.DESCRIPTION_IS_NOT_STRING) + }) + }) describe("Posting notifications", () => { - let postedNotification; + let postedNotification beforeEach(() => { - notifyToRest.postNotification = n => postedNotification = n; - notifyToRest.init(); - }); + notifyToRest.postNotification = n => postedNotification = n + notifyToRest.init() + }) - it("Correct body is sent the notification should be posted", async () => { - const body = { title: "abc", recipients: ["abc@abc.com"], priority: "low" }; - await notifyToRest.notify(body); - expect(postedNotification).toMatchObject(buildNotification(body)); - }); + test("Correct body is sent the notification should be posted", async () => { + const body = { title: "abc", recipients: ["abc@abc.com"], priority: "low" } + await notifyToRest.notify(body) + expect(postedNotification).toMatchObject(buildNotification(body)) + }) - it("Emit is called with an outbox request object", async () => { - const req = { event: "IncidentResolved", data: { NotificationTypeKey: "IncidentResolved", NotificationTypeVersion: "1", Priority: "NEUTRAL", Properties: [], Recipients: [] }, headers: {} }; - await notifyToRest.emit(req); - expect(postedNotification).toMatchObject(req.data); - }); + test("Emit is called with an outbox request object", async () => { + const req = { event: "IncidentResolved", data: { NotificationTypeKey: "IncidentResolved", NotificationTypeVersion: "1", Priority: "NEUTRAL", Properties: [], Recipients: [] }, headers: {} } + await notifyToRest.emit(req) + expect(postedNotification).toMatchObject(req.data) + }) - it("Notify is called with a single object-containing type", async () => { - const body = { type: "IncidentResolved", recipients: ["abc@abc.com"], data: { title: "test" } }; - await notifyToRest.notify(body); - expect(postedNotification).toMatchObject(buildNotification(body)); - }); + test("Notify is called with a single object-containing type", async () => { + const body = { type: "IncidentResolved", recipients: ["abc@abc.com"], data: { title: "test" } } + await notifyToRest.notify(body) + expect(postedNotification).toMatchObject(buildNotification(body)) + }) - it("Notify is called with type as first arg and message as second", async () => { - await notifyToRest.notify("IncidentResolved", { recipients: ["abc@abc.com"], data: { title: "test" } }); - expect(postedNotification).toMatchObject(buildNotification({ type: "IncidentResolved", recipients: ["abc@abc.com"], data: { title: "test" } })); - }); + test("Notify is called with type as first arg and message as second", async () => { + await notifyToRest.notify("IncidentResolved", { recipients: ["abc@abc.com"], data: { title: "test" } }) + expect(postedNotification).toMatchObject(buildNotification({ type: "IncidentResolved", recipients: ["abc@abc.com"], data: { title: "test" } })) + }) - it("Notify is called with a single object containing NotificationTypeKey and no type", async () => { - const body = { NotificationTypeKey: "IncidentResolved", NotificationTypeVersion: "1", Priority: "NEUTRAL", Properties: [], Recipients: [] }; - const expected = buildNotification({ ...body }); - await notifyToRest.notify(body); - expect(postedNotification).toMatchObject(expected); - }); - }); -}); + test("Notify is called with a single object containing NotificationTypeKey and no type", async () => { + const body = { NotificationTypeKey: "IncidentResolved", NotificationTypeVersion: "1", Priority: "NEUTRAL", Properties: [], Recipients: [] } + const expected = buildNotification({ ...body }) + await notifyToRest.notify(body) + expect(postedNotification).toMatchObject(expected) + }) + }) +})