From 6d107c99fff26324b98fea8ccb49a93bdbb0427a Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 7 May 2026 17:16:45 +0200 Subject: [PATCH 01/17] Initial commit --- cds-plugin.js | 37 +- lib/build.js | 24 +- lib/compile.js | 51 +++ tests/bookshop/srv/notification-types.json | 9 +- tests/bookshop/srv/notifications.cds | 14 + tests/integration/bookshop.test.js | 65 +-- tests/unit/lib/compile.test.js | 171 ++++++++ tests/unit/lib/content-deployment.test.js | 8 +- tests/unit/lib/notificationTypes.test.js | 453 ++++++++++----------- tests/unit/lib/notifications.test.js | 4 +- tests/unit/lib/plugin.test.js | 54 +++ tests/unit/lib/utils.test.js | 70 ++-- tests/unit/srv/notifyToRest.test.js | 22 +- 13 files changed, 651 insertions(+), 331 deletions(-) create mode 100644 lib/compile.js create mode 100644 tests/bookshop/srv/notifications.cds create mode 100644 tests/unit/lib/compile.test.js create mode 100644 tests/unit/lib/plugin.test.js diff --git a/cds-plugin.js b/cds-plugin.js index dc41325..11d03d1 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -1,21 +1,40 @@ -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 srvPath = path.join(cds.root, cds.env.folders.srv) + const model = await cds.load(srvPath) + 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 } }; + 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..1bb69db --- /dev/null +++ b/lib/compile.js @@ -0,0 +1,51 @@ +const cds = require('@sap/cds') + +function resolveEnum(val) { + if (val && typeof val === 'object' && '=' in val) return val['='] + return val +} + +function notificationTypesFromModel(model) { + if (!model) return [] + const types = [] + + for (const def of Object.values(cds.reflect(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 = def['@description'] + if (def['@notification.template.title']) tmpl.TemplateSensitive = def['@notification.template.title'] + if (def['@notification.template.publicTitle']) tmpl.TemplatePublic = def['@notification.template.publicTitle'] + if (def['@notification.template.subtitle']) tmpl.Subtitle = def['@notification.template.subtitle'] + if (def['@notification.template.groupedTitle']) tmpl.TemplateGrouped = def['@notification.template.groupedTitle'] + if (def['@notification.template.email.subject']) tmpl.EmailSubject = def['@notification.template.email.subject'] + if (def['@notification.template.email.html']) tmpl.EmailHtml = 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 +} + +module.exports = { notificationTypesFromModel } diff --git a/tests/bookshop/srv/notification-types.json b/tests/bookshop/srv/notification-types.json index c3e7456..dc253f8 100644 --- a/tests/bookshop/srv/notification-types.json +++ b/tests/bookshop/srv/notification-types.json @@ -1,15 +1,12 @@ [ { - "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", + "TemplateLanguage": "mustache" } ] } diff --git a/tests/bookshop/srv/notifications.cds b/tests/bookshop/srv/notifications.cds new file mode 100644 index 0000000..795a561 --- /dev/null +++ b/tests/bookshop/srv/notifications.cds @@ -0,0 +1,14 @@ +@description: 'Book Ordered' +@notification: { + template: { + title : 'Book Ordered', + publicTitle : 'Book Ordered', + subtitle : '{{buyer}} ordered {{title}}', + groupedTitle : 'Bookshop Updates', + } +} +event BookOrdered { + title : String; + buyer : String; + recipients: array of String; +} diff --git a/tests/integration/bookshop.test.js b/tests/integration/bookshop.test.js index 972e94e..5f997da 100644 --- a/tests/integration/bookshop.test.js +++ b/tests/integration/bookshop.test.js @@ -1,62 +1,67 @@ -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/BookOrdered") + }) 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", { 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/BookOrdered") + 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/BookOrdered") + expect(cds.notifications.local.types).toHaveProperty("bookshop/BookReturned") + }) +}) \ 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..61a605b --- /dev/null +++ b/tests/unit/lib/compile.test.js @@ -0,0 +1,171 @@ +const { notificationTypesFromModel } = require("../../../lib/compile") + +function makeModel(defs) { + return { definitions: defs } +} + +describe("notificationTypesFromModel", () => { + + 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({ + "BookOrdered": { + kind: "event", + name: "BookOrdered", + "@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("BookOrdered") + 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.BookOrdered": { + kind: "event", + name: "CatalogService.BookOrdered", + "@notification": { template: { title: "x" } } + } + }) + + const [type] = notificationTypesFromModel(model) + expect(type.NotificationTypeKey).toBe("BookOrdered") + }) + + 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) + }) +}) diff --git a/tests/unit/lib/content-deployment.test.js b/tests/unit/lib/content-deployment.test.js index d50433f..fdc6334 100644 --- a/tests/unit/lib/content-deployment.test.js +++ b/tests/unit/lib/content-deployment.test.js @@ -16,14 +16,14 @@ describe("contentDeployment", () => { readFile.mockImplementation(() => []); }); - it("Set log level to error on startup", async () => { + test("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 () => { + test("Process notification types when they are valid", async () => { validateNotificationTypes.mockReturnValue(true); processNotificationTypes.mockResolvedValue(); await contentDeployment.deployNotificationTypes(); @@ -32,7 +32,7 @@ describe("contentDeployment", () => { expect(processNotificationTypes).toHaveBeenCalledWith([]); }); - it("Notification types are not processed when they are invalid", async () => { + test("Notification types are not processed when they are invalid", async () => { validateNotificationTypes.mockReturnValue(false); processNotificationTypes.mockResolvedValue(); await contentDeployment.deployNotificationTypes(); @@ -41,7 +41,7 @@ describe("contentDeployment", () => { expect(processNotificationTypes).not.toHaveBeenCalled(); }); - it("Call readFile with empty string when notifications types path is not configured", async () => { + test("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; diff --git a/tests/unit/lib/notificationTypes.test.js b/tests/unit/lib/notificationTypes.test.js index 9e29f11..9600a9a 100644 --- a/tests/unit/lib/notificationTypes.test.js +++ b/tests/unit/lib/notificationTypes.test.js @@ -1,12 +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 assert = require("chai"); +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", @@ -22,7 +21,7 @@ const defaultNotificationType = { Subtitle: "{{description}}" } ] -}; +} const notificationTypeWithAllProperties = { NotificationTypeKey: "notificationTypeWithAllProperties", @@ -59,7 +58,7 @@ const notificationTypeWithAllProperties = { EditablePreference: true } ] -}; +} const notificationTypeWithoutVersion = { NotificationTypeKey: "notificationTypeWithoutVersion", @@ -95,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: { @@ -272,7 +271,7 @@ const allExistingResponseBody = { ] } } -}; +} const allExistingWithUndefinedTemplatesActionsAndDeliveryChannelsResponseBody = { data: { @@ -314,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: { @@ -413,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: { @@ -558,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: { @@ -656,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 695b226..ffe573b 100644 --- a/tests/unit/lib/notifications.test.js +++ b/tests/unit/lib/notifications.test.js @@ -40,7 +40,7 @@ describe("Test post notification", () => { buildHeadersForDestination.mockReturnValue(undefined); }); - it("Logs and sends when a valid notification object is posted", async () => { + test("Logs and sends when a valid notification object is posted", async () => { executeHttpRequest.mockReturnValue(expectedCustomNotification); await alert.postNotification(expectedCustomNotification) @@ -48,7 +48,7 @@ describe("Test post notification", () => { expect(executeHttpRequest).toHaveBeenCalled(); }) - it.each([ + test.each([ [500, false], [404, true], [429, false], 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 109e1ce..6033a97 100644 --- a/tests/unit/lib/utils.test.js +++ b/tests/unit/lib/utils.test.js @@ -53,7 +53,7 @@ describe("Test utils", () => { ] }; - it("Build a default notification with priority", () => { + test("Build a default notification with priority", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], @@ -63,7 +63,7 @@ describe("Test utils", () => { ).toMatchObject(expectedWithoutDescription); }); - it("Build a default notification without priority", () => { + test("Build a default notification without priority", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], @@ -72,7 +72,7 @@ describe("Test utils", () => { ).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"], @@ -83,7 +83,7 @@ describe("Test utils", () => { ).toMatchObject(expectedWithDescription); }); - it("Build a default notification with description", () => { + test("Build a default notification with description", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], @@ -117,11 +117,11 @@ describe("Test utils", () => { Recipients: [{ RecipientId: "test.mail@mail.com" }] }; - it("Build a custom notification with properties", () => { + 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", @@ -133,7 +133,7 @@ describe("Test utils", () => { }); }); - 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" @@ -143,7 +143,7 @@ describe("Test utils", () => { }); }); - 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, @@ -159,7 +159,7 @@ describe("Test utils", () => { }); }); - it("Maps data object to Properties array", () => { + test("Maps data object to Properties array", () => { expect( buildNotification({ recipients: ["test.mail@mail.com"], @@ -177,7 +177,7 @@ describe("Test utils", () => { }); }); - 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", @@ -202,7 +202,7 @@ describe("Test utils", () => { }); }); - 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", @@ -230,12 +230,12 @@ describe("Test utils", () => { }); describe("Invalid inputs", () => { - it("Return falsy when an empty object is passed", () => { + 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"], @@ -244,7 +244,7 @@ describe("Test utils", () => { ).toBeFalsy(); }); - it("Return falsy when recipients is empty", () => { + test("Return falsy when recipients is empty", () => { expect( buildNotification({ recipients: [], @@ -254,7 +254,7 @@ describe("Test utils", () => { ).toBeFalsy(); }); - it("Return falsy when recipients is not an array", () => { + test("Return falsy when recipients is not an array", () => { expect( buildNotification({ recipients: "invalid", @@ -264,7 +264,7 @@ describe("Test utils", () => { ).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"], @@ -274,7 +274,7 @@ describe("Test utils", () => { ).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"], @@ -285,7 +285,7 @@ describe("Test utils", () => { ).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"], @@ -297,7 +297,7 @@ describe("Test utils", () => { }); describe("Custom notification", () => { - it("Return falsy when recipients is missing", () => { + test("Return falsy when recipients is missing", () => { expect( buildNotification({ type: "TestNotificationType" @@ -305,7 +305,7 @@ describe("Test utils", () => { ).toBeFalsy(); }); - it("Return falsy when recipients is empty", () => { + test("Return falsy when recipients is empty", () => { expect( buildNotification({ recipients: [], @@ -314,7 +314,7 @@ describe("Test utils", () => { ).toBeFalsy(); }); - it("Return falsy when recipients is not an array", () => { + test("Return falsy when recipients is not an array", () => { expect( buildNotification({ recipients: "invalid", @@ -323,7 +323,7 @@ describe("Test utils", () => { ).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"], @@ -333,7 +333,7 @@ describe("Test utils", () => { ).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"], @@ -344,7 +344,7 @@ describe("Test utils", () => { ).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"], @@ -355,7 +355,7 @@ describe("Test utils", () => { ).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"], @@ -368,7 +368,7 @@ describe("Test utils", () => { }); }); - 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", @@ -402,26 +402,26 @@ describe("Test utils", () => { }); describe("Notification types validation", () => { - it("Return false when an entry is missing NotificationTypeKey", () => { + test("Return false when an entry is missing NotificationTypeKey", () => { expect(validateNotificationTypes([{ NotificationTypeKey: "Test" }, { blabla: "Test2" }])).toEqual(false); }); - it("Return true for an empty array", () => { + test("Return true for an empty array", () => { expect(validateNotificationTypes([])).toBe(true); }); - it("Return true when all entries have NotificationTypeKey", () => { + 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", () => { + test("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", () => { + test("Return the parsed file contents when the file exists", () => { existsSync.mockReturnValue(true); readFileSync.mockReturnValue('[{ "test": "test" }]'); expect(readFile("test.json")).toMatchObject([{ test: "test" }]); @@ -429,19 +429,19 @@ describe("Test utils", () => { }); describe("Get notification destination", () => { - it("Return the destination when it exists", async () => { + 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 () => { + 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", () => { - it("Use GlobalUserId as the recipient key when authenticationIdentifier is set to UserUUID", () => { + test("Use GlobalUserId as the recipient key when authenticationIdentifier is set to UserUUID", () => { cds.env.requires.notifications ??= {}; cds.env.requires.notifications.authenticationIdentifier = "UserUUID"; @@ -454,7 +454,7 @@ describe("Test utils", () => { expect(result.Recipients[0]).toMatchObject({ GlobalUserId: "user-uuid-123" }); }); - it("Fall back to basename of cds.root as prefix when package.json cannot be read", () => { + test("Fall back to basename of cds.root as prefix when package.json cannot be read", () => { let result; jest.isolateModules(() => { const cds = require("@sap/cds"); diff --git a/tests/unit/srv/notifyToRest.test.js b/tests/unit/srv/notifyToRest.test.js index 887e3f9..0c76950 100644 --- a/tests/unit/srv/notifyToRest.test.js +++ b/tests/unit/srv/notifyToRest.test.js @@ -10,32 +10,32 @@ describe("Notify to rest", () => { }) describe("Warnings", () => { - it("No object is passed", async () => { + test("No object is passed", async () => { notifyToRest.notify(); expect(log.output).toContain(messages.NO_OBJECT_FOR_NOTIFY); }); - it("Empty object is passed", async () => { + 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 () => { + 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 () => { + 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 () => { + 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 () => { + 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); }); @@ -49,30 +49,30 @@ describe("Notify to rest", () => { notifyToRest.init(); }); - it("Correct body is sent the notification should be posted", async () => { + 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 () => { + 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 () => { + 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 () => { + 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 () => { + 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); From 8bc21ce5e2d2591d928fff110faebee3acec65eb Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Fri, 8 May 2026 14:16:23 +0200 Subject: [PATCH 02/17] Basic i18n support --- lib/compile.js | 21 ++++++--- tests/bookshop/srv/_i18n/i18n.properties | 5 ++ tests/bookshop/srv/notifications.cds | 10 ++-- tests/integration/bookshop.test.js | 6 +++ tests/unit/lib/compile.test.js | 59 ++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 tests/bookshop/srv/_i18n/i18n.properties diff --git a/lib/compile.js b/lib/compile.js index 1bb69db..a2a3d15 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -14,13 +14,13 @@ function notificationTypesFromModel(model) { if (!Object.keys(def).some(k => k === '@notification' || k.startsWith('@notification.'))) continue const tmpl = { Language: 'en', TemplateLanguage: 'mustache' } - if (def['@description']) tmpl.Description = def['@description'] - if (def['@notification.template.title']) tmpl.TemplateSensitive = def['@notification.template.title'] - if (def['@notification.template.publicTitle']) tmpl.TemplatePublic = def['@notification.template.publicTitle'] - if (def['@notification.template.subtitle']) tmpl.Subtitle = def['@notification.template.subtitle'] - if (def['@notification.template.groupedTitle']) tmpl.TemplateGrouped = def['@notification.template.groupedTitle'] - if (def['@notification.template.email.subject']) tmpl.EmailSubject = def['@notification.template.email.subject'] - if (def['@notification.template.email.html']) tmpl.EmailHtml = def['@notification.template.email.html'] + 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(), @@ -48,4 +48,11 @@ function notificationTypesFromModel(model) { 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/tests/bookshop/srv/_i18n/i18n.properties b/tests/bookshop/srv/_i18n/i18n.properties new file mode 100644 index 0000000..60a6332 --- /dev/null +++ b/tests/bookshop/srv/_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/srv/notifications.cds b/tests/bookshop/srv/notifications.cds index 795a561..5ba74fa 100644 --- a/tests/bookshop/srv/notifications.cds +++ b/tests/bookshop/srv/notifications.cds @@ -1,10 +1,10 @@ -@description: 'Book Ordered' +@description: '{i18n>BOOK_ORDERED_DESCRIPTION}' @notification: { template: { - title : 'Book Ordered', - publicTitle : 'Book Ordered', - subtitle : '{{buyer}} ordered {{title}}', - groupedTitle : 'Bookshop Updates', + title : '{i18n>BOOK_ORDERED_TITLE}', + publicTitle : '{i18n>BOOK_ORDERED_PUBLIC_TITLE}', + subtitle : '{i18n>BOOK_ORDERED_SUBTITLE}', + groupedTitle : '{i18n>BOOK_ORDERED_GROUPED_TITLE}', } } event BookOrdered { diff --git a/tests/integration/bookshop.test.js b/tests/integration/bookshop.test.js index 5f997da..ecadc78 100644 --- a/tests/integration/bookshop.test.js +++ b/tests/integration/bookshop.test.js @@ -64,4 +64,10 @@ describe("Notifications Integration", () => { expect(cds.notifications.local.types).toHaveProperty("bookshop/BookOrdered") expect(cds.notifications.local.types).toHaveProperty("bookshop/BookReturned") }) + + test("Notification type templates have resolved i18n values", () => { + const type = cds.notifications.local.types["bookshop/BookOrdered"]["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 index 61a605b..95434c1 100644 --- a/tests/unit/lib/compile.test.js +++ b/tests/unit/lib/compile.test.js @@ -5,6 +5,17 @@ function makeModel(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([]) @@ -168,4 +179,52 @@ describe("notificationTypesFromModel", () => { 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") + }) }) From d5ad1b67ab424e6f27ffdc2903b044ea03f9a93c Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Tue, 12 May 2026 13:28:26 +0200 Subject: [PATCH 03/17] # handling --- lib/compile.js | 5 ++++- tests/unit/lib/compile.test.js | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/compile.js b/lib/compile.js index a2a3d15..643ecd3 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -1,7 +1,10 @@ const cds = require('@sap/cds') function resolveEnum(val) { - if (val && typeof val === 'object' && '=' in val) return val['='] + if (val && typeof val === 'object') { + if ('=' in val) return val['='] + if ('#' in val) return val['#'] + } return val } diff --git a/tests/unit/lib/compile.test.js b/tests/unit/lib/compile.test.js index 95434c1..667a436 100644 --- a/tests/unit/lib/compile.test.js +++ b/tests/unit/lib/compile.test.js @@ -114,6 +114,21 @@ describe("notificationTypesFromModel", () => { expect(type.NotificationTypeKey).toBe("BookOrdered") }) + 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": { From 4d161bf89569af69cf731f9ad1a3ddf78518fa6d Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Tue, 26 May 2026 15:44:49 +0200 Subject: [PATCH 04/17] fix: email functioning and automatic deployment --- cds-plugin.js | 6 +- lib/content-deployment.js | 25 +- lib/notificationTypes.js | 169 ++++++------- lib/utils.js | 122 +++++----- srv/notifyToConsole.js | 15 +- srv/notifyToRest.js | 32 +-- srv/service.js | 6 +- .../app/admin-books/webapp/Component.js | 6 +- tests/bookshop/app/browse/webapp/Component.js | 6 +- tests/bookshop/srv/cat-service.js | 20 ++ tests/bookshop/srv/notification-types.json | 14 +- tests/bookshop/srv/notifications.cds | 7 +- tests/unit/lib/content-deployment.test.js | 72 +++--- tests/unit/lib/notifications.test.js | 50 ++-- tests/unit/lib/utils.test.js | 230 +++++++++--------- tests/unit/srv/notifyToRest.test.js | 100 ++++---- 16 files changed, 466 insertions(+), 414 deletions(-) create mode 100644 tests/bookshop/srv/cat-service.js diff --git a/cds-plugin.js b/cds-plugin.js index 11d03d1..f67f736 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -32,7 +32,11 @@ else cds.once("served", async () => { ] if (validateNotificationTypes(notificationTypes)) { - if (!production) { + const kind = cds.env.requires?.notifications?.kind + 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/content-deployment.js b/lib/content-deployment.js index 522506c..b52c091 100644 --- a/lib/content-deployment.js +++ b/lib/content-deployment.js @@ -1,21 +1,28 @@ -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 filePath = cds.env.requires?.notifications?.types ?? '' + const srvPath = cds.utils.path.join(cds.root, cds.env.folders.srv) + const model = await cds.load(srvPath) + + const notificationTypes = [ + ...notificationTypesFromModel(model), + ...readFile(filePath) + ] if (validateNotificationTypes(notificationTypes)) { - await processNotificationTypes(notificationTypes); + await processNotificationTypes(notificationTypes) } } -deployNotificationTypes(); +deployNotificationTypes() module.exports = { deployNotificationTypes diff --git a/lib/notificationTypes.js b/lib/notificationTypes.js index 2b00b18..ce61f20 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,149 @@ 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; + }).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 +171,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 +207,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 +241,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 +269,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 a377927..0bedd21 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,9 +1,9 @@ -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 { 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 messages = { TYPES_FILE_NOT_EXISTS: "Notification Types file path is incorrect.", @@ -19,110 +19,110 @@ 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() { - const authenticationIdentifier = cds.env.requires.notifications?.authenticationIdentifier; - var recipientKey = 'RecipientId'; // Email + const authenticationIdentifier = cds.env.requires.notifications?.authenticationIdentifier + var recipientKey = 'RecipientId' // Email if (authenticationIdentifier === "UserUUID") { - recipientKey = 'GlobalUserId'; + recipientKey = 'GlobalUserId' } - return recipientKey; + return recipientKey } let prefix // be filled in below... @@ -138,8 +138,8 @@ function getPrefix() { } function getNotificationTypesKeyWithPrefix(notificationTypeKey) { - const prefix = getPrefix(); - return `${prefix}/${notificationTypeKey}`; + const prefix = getPrefix() + return `${prefix}/${notificationTypeKey}` } function buildDefaultNotification( @@ -163,7 +163,7 @@ function buildDefaultNotification( Type: "String", IsSensitive: false, }, - ]; + ] return { NotificationTypeKey: "Default", @@ -171,7 +171,7 @@ function buildDefaultNotification( Priority: priority, Properties: properties, Recipients: recipients.map((recipient) => ({ [getRecipientKey()]: recipient })) - }; + } } function buildCustomNotification(_) { @@ -202,11 +202,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) { @@ -218,13 +218,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, @@ -232,7 +232,7 @@ function buildNotification(notificationData) { notificationData.title, notificationData.description) ) { - return; + return } notification = buildDefaultNotification( @@ -240,10 +240,10 @@ function buildNotification(notificationData) { notificationData.priority, notificationData.title, notificationData.description - ); + ) } - return JSON.parse(JSON.stringify(notification)); + return JSON.parse(JSON.stringify(notification)) } module.exports = { @@ -254,4 +254,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/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/srv/cat-service.js b/tests/bookshop/srv/cat-service.js new file mode 100644 index 0000000..289fe67 --- /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('BookOrdered', { + recipients: ['eric.peairs@sap.com'], + 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 dc253f8..3cf9cf8 100644 --- a/tests/bookshop/srv/notification-types.json +++ b/tests/bookshop/srv/notification-types.json @@ -6,7 +6,19 @@ { "Language": "en", "TemplateSensitive": "Book '{{title}}' Returned", - "TemplateLanguage": "mustache" + "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 index 5ba74fa..b0b6a6e 100644 --- a/tests/bookshop/srv/notifications.cds +++ b/tests/bookshop/srv/notifications.cds @@ -5,7 +5,12 @@ 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 BookOrdered { title : String; diff --git a/tests/unit/lib/content-deployment.test.js b/tests/unit/lib/content-deployment.test.js index fdc6334..efdd4e6 100644 --- a/tests/unit/lib/content-deployment.test.js +++ b/tests/unit/lib/content-deployment.test.js @@ -1,53 +1,53 @@ -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 { 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("@sap-cloud-sdk/util") -const contentDeployment = require("../../../lib/content-deployment"); +const contentDeployment = require("../../../lib/content-deployment") describe("contentDeployment", () => { beforeEach(() => { - jest.clearAllMocks(); - setGlobalLogLevel.mockImplementation(() => undefined); - readFile.mockImplementation(() => []); - }); + jest.clearAllMocks() + setGlobalLogLevel.mockImplementation(() => undefined) + readFile.mockImplementation(() => []) + }) test("Set log level to error on startup", async () => { - validateNotificationTypes.mockReturnValue(false); - await contentDeployment.deployNotificationTypes(); + validateNotificationTypes.mockReturnValue(false) + await contentDeployment.deployNotificationTypes() - expect(setGlobalLogLevel).toHaveBeenCalledWith("error"); - }); + expect(setGlobalLogLevel).toHaveBeenCalledWith("error") + }) test("Process notification types when they are valid", async () => { - validateNotificationTypes.mockReturnValue(true); - processNotificationTypes.mockResolvedValue(); - await contentDeployment.deployNotificationTypes(); + validateNotificationTypes.mockReturnValue(true) + processNotificationTypes.mockResolvedValue() + await contentDeployment.deployNotificationTypes() - expect(validateNotificationTypes).toHaveBeenCalledWith([]); - expect(processNotificationTypes).toHaveBeenCalledWith([]); - }); + expect(validateNotificationTypes).toHaveBeenCalledWith([]) + expect(processNotificationTypes).toHaveBeenCalledWith([]) + }) test("Notification types are not processed when they are invalid", async () => { - validateNotificationTypes.mockReturnValue(false); - processNotificationTypes.mockResolvedValue(); - await contentDeployment.deployNotificationTypes(); + validateNotificationTypes.mockReturnValue(false) + processNotificationTypes.mockResolvedValue() + await contentDeployment.deployNotificationTypes() - expect(validateNotificationTypes).toHaveBeenCalledWith([]); - expect(processNotificationTypes).not.toHaveBeenCalled(); - }); + expect(validateNotificationTypes).toHaveBeenCalledWith([]) + expect(processNotificationTypes).not.toHaveBeenCalled() + }) test("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; + 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 - expect(readFile).toHaveBeenCalledWith(''); - }); -}); + expect(readFile).toHaveBeenCalledWith('') + }) +}) diff --git a/tests/unit/lib/notifications.test.js b/tests/unit/lib/notifications.test.js index 1e2cc79..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,24 +29,24 @@ 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) + }) test("Logs and sends when a valid notification object is posted", async () => { - executeHttpRequest.mockReturnValue(expectedCustomNotification); + 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() }) test.each([ @@ -54,18 +54,18 @@ describe("Test post notification", () => { [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/utils.test.js b/tests/unit/lib/utils.test.js index 6033a97..7f62916 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,7 +51,7 @@ describe("Test utils", () => { Type: "String" } ] - }; + } test("Build a default notification with priority", () => { expect( @@ -60,8 +60,8 @@ describe("Test utils", () => { title: "Some Test Title", priority: "NEUTRAL" }) - ).toMatchObject(expectedWithoutDescription); - }); + ).toMatchObject(expectedWithoutDescription) + }) test("Build a default notification without priority", () => { expect( @@ -69,8 +69,8 @@ describe("Test utils", () => { recipients: ["test.mail@mail.com"], title: "Some Test Title" }) - ).toMatchObject(expectedWithoutDescription); - }); + ).toMatchObject(expectedWithoutDescription) + }) test("Build a default notification with description and priority", () => { expect( @@ -80,8 +80,8 @@ describe("Test utils", () => { priority: "NEUTRAL", description: "Some Test Description" }) - ).toMatchObject(expectedWithDescription); - }); + ).toMatchObject(expectedWithDescription) + }) test("Build a default notification with description", () => { expect( @@ -90,9 +90,9 @@ describe("Test utils", () => { 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,11 +115,11 @@ describe("Test utils", () => { Priority: "NEUTRAL", Properties: properties, Recipients: [{ RecipientId: "test.mail@mail.com" }] - }; + } test("Build a custom notification with properties", () => { - expect(buildNotification(baseInput)).toMatchObject(baseExpected); - }); + expect(buildNotification(baseInput)).toMatchObject(baseExpected) + }) test("Build a custom notification with navigation targets", () => { expect(buildNotification({ @@ -130,8 +130,8 @@ describe("Test utils", () => { ...baseExpected, NavigationTargetAction: "TestTargetAction", NavigationTargetObject: "TestTargetObject" - }); - }); + }) + }) test("Build a custom notification with a non-default priority", () => { expect(buildNotification({ @@ -140,8 +140,8 @@ describe("Test utils", () => { })).toMatchObject({ ...baseExpected, Priority: "HIGH" - }); - }); + }) + }) test("Build a custom notification with a non-default priority and navigation targets", () => { expect( @@ -156,8 +156,8 @@ describe("Test utils", () => { NavigationTargetAction: "TestTargetAction", NavigationTargetObject: "TestTargetObject", Priority: "HIGH" - }); - }); + }) + }) test("Maps data object to Properties array", () => { expect( @@ -174,8 +174,8 @@ describe("Test utils", () => { Language: "en", Type: "string" }] - }); - }); + }) + }) test("Pass all low-level API fields through to the notification", () => { const lowLevelFields = { @@ -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,8 +199,8 @@ describe("Test utils", () => { ...baseExpected, ...lowLevelFields, Priority: "HIGH" - }); - }); + }) + }) test("Pass partial low-level API fields through to the notification", () => { const partialLowLevelFields = { @@ -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,14 +225,14 @@ describe("Test utils", () => { ...baseExpected, ...partialLowLevelFields, Priority: "HIGH" - }); - }); - }); + }) + }) + }) describe("Invalid inputs", () => { test("Return falsy when an empty object is passed", () => { - expect(buildNotification({})).toBeFalsy(); - }); + expect(buildNotification({})).toBeFalsy() + }) describe("Default notification", () => { test("Return falsy when title is missing", () => { @@ -241,8 +241,8 @@ describe("Test utils", () => { recipients: ["test.mail@mail.com"], priority: "NEUTRAL" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) test("Return falsy when recipients is empty", () => { expect( @@ -251,8 +251,8 @@ describe("Test utils", () => { title: "Some Test Title", priority: "NEUTRAL" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) test("Return falsy when recipients is not an array", () => { expect( @@ -261,8 +261,8 @@ describe("Test utils", () => { title: "Some Test Title", priority: "NEUTRAL" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) test("Return falsy when priority is not a valid value", () => { expect( @@ -271,8 +271,8 @@ describe("Test utils", () => { title: "Some Test Title", priority: "INVALID" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) test("Return falsy when description is not a string", () => { expect( @@ -282,8 +282,8 @@ describe("Test utils", () => { priority: "NEUTRAL", description: { invalid: "invalid" } }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) test("Return falsy when title is not a string", () => { expect( @@ -292,9 +292,9 @@ describe("Test utils", () => { title: { invalid: "invalid" }, priority: "NEUTRAL" }) - ).toBeFalsy(); - }); - }); + ).toBeFalsy() + }) + }) describe("Custom notification", () => { test("Return falsy when recipients is missing", () => { @@ -302,8 +302,8 @@ describe("Test utils", () => { buildNotification({ type: "TestNotificationType" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) test("Return falsy when recipients is empty", () => { expect( @@ -311,8 +311,8 @@ describe("Test utils", () => { recipients: [], type: "TestNotificationType" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) test("Return falsy when recipients is not an array", () => { expect( @@ -320,8 +320,8 @@ describe("Test utils", () => { recipients: "invalid", type: "TestNotificationType" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) test("Return falsy when priority is not a valid value", () => { expect( @@ -330,8 +330,8 @@ describe("Test utils", () => { type: "TestNotificationType", priority: "invalid" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) test("Return falsy when properties is not an array", () => { expect( @@ -341,8 +341,8 @@ describe("Test utils", () => { priority: "NEUTRAL", properties: "invalid" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) test("Return falsy when navigation is not an object", () => { expect( @@ -352,8 +352,8 @@ describe("Test utils", () => { priority: "NEUTRAL", navigation: "invalid" }) - ).toBeFalsy(); - }); + ).toBeFalsy() + }) test("Return falsy when payload is not an object", () => { expect( @@ -363,10 +363,10 @@ describe("Test utils", () => { priority: "NEUTRAL", payload: "invalid" }) - ).toBeFalsy(); - }); - }); - }); + ).toBeFalsy() + }) + }) + }) test("Pass a raw notification object through with the prefix applied to the type key", () => { const rawNotification = { @@ -390,87 +390,87 @@ describe("Test utils", () => { } ], Recipients: [{ RecipientId: "test.mail@mail.com" }] - }; + } expect( buildNotification({ ...rawNotification })) .toMatchObject({ ...rawNotification, NotificationTypeKey: "notifications/TestNotificationType" - }); - }); - }); + }) + }) + }) describe("Notification types validation", () => { test("Return false when an entry is missing NotificationTypeKey", () => { - expect(validateNotificationTypes([{ NotificationTypeKey: "Test" }, { blabla: "Test2" }])).toEqual(false); - }); + expect(validateNotificationTypes([{ NotificationTypeKey: "Test" }, { blabla: "Test2" }])).toEqual(false) + }) test("Return true for an empty array", () => { - expect(validateNotificationTypes([])).toBe(true); - }); + expect(validateNotificationTypes([])).toBe(true) + }) test("Return true when all entries have NotificationTypeKey", () => { - expect(validateNotificationTypes([{ NotificationTypeKey: "Test" }, { NotificationTypeKey: "Test2" }])).toEqual(true); - }); - }); + expect(validateNotificationTypes([{ NotificationTypeKey: "Test" }, { NotificationTypeKey: "Test2" }])).toEqual(true) + }) + }) describe("Read file", () => { test("Return an empty array when the file does not exist", () => { - existsSync.mockReturnValue(false); - expect(readFile("test.json")).toMatchObject([]); - }); + 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" }]); - }); - }); + existsSync.mockReturnValue(true) + readFileSync.mockReturnValue('[{ "test": "test" }]') + expect(readFile("test.json")).toMatchObject([{ test: "test" }]) + }) + }) describe("Get notification destination", () => { test("Return the destination when it exists", async () => { - getDestination.mockReturnValue({ "mock-destination": "mock-destination" }); - expect(await getNotificationDestination()).toMatchObject({ "mock-destination": "mock-destination" }); - }); + getDestination.mockReturnValue({ "mock-destination": "mock-destination" }) + expect(await getNotificationDestination()).toMatchObject({ "mock-destination": "mock-destination" }) + }) 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"); - }); - }); + getDestination.mockReturnValue(undefined) + await expect(() => getNotificationDestination()).rejects.toThrow("Failed to get destination: SAP_Notifications") + }) + }) describe("Configuration", () => { test("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" - }); + }) - delete cds.env.requires.notifications.authenticationIdentifier; - expect(result.Recipients[0]).toMatchObject({ GlobalUserId: "user-uuid-123" }); - }); + delete cds.env.requires.notifications.authenticationIdentifier + expect(result.Recipients[0]).toMatchObject({ GlobalUserId: "user-uuid-123" }) + }) test("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 09575de..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", () => { test("No object is passed", async () => { - notifyToRest.notify(); - expect(log.output).toContain(messages.NO_OBJECT_FOR_NOTIFY); - }); + notifyToRest.notify() + expect(log.output).toContain(messages.NO_OBJECT_FOR_NOTIFY) + }) test("Empty object is passed", async () => { - notifyToRest.notify({}); - expect(log.output).toContain(messages.EMPTY_OBJECT_FOR_NOTIFY); - }); + notifyToRest.notify({}) + expect(log.output).toContain(messages.EMPTY_OBJECT_FOR_NOTIFY) + }) 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); - }); + notifyToRest.notify({ dummy: true }) + expect(log.output).toContain(messages.MANDATORY_PARAMETER_NOT_PASSED_FOR_DEFAULT_NOTIFICATION) + }) 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); - }); + notifyToRest.notify({ title: 1, recipients: ["abc@abc.com"] }) + expect(log.output).toContain(messages.TITLE_IS_NOT_STRING) + }) 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"); - }); + 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("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); - }); - }); + 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() + }) 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)); - }); + const body = { title: "abc", recipients: ["abc@abc.com"], priority: "low" } + await notifyToRest.notify(body) + expect(postedNotification).toMatchObject(buildNotification(body)) + }) 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); - }); + const req = { event: "IncidentResolved", data: { NotificationTypeKey: "IncidentResolved", NotificationTypeVersion: "1", Priority: "NEUTRAL", Properties: [], Recipients: [] }, headers: {} } + await notifyToRest.emit(req) + expect(postedNotification).toMatchObject(req.data) + }) 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)); - }); + 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 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" } })); - }); + 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 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); - }); - }); -}); + const body = { NotificationTypeKey: "IncidentResolved", NotificationTypeVersion: "1", Priority: "NEUTRAL", Properties: [], Recipients: [] } + const expected = buildNotification({ ...body }) + await notifyToRest.notify(body) + expect(postedNotification).toMatchObject(expected) + }) + }) +}) From 43b0cde7cdd805ffe48ae0afa047ff179aa5f1fd Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Tue, 26 May 2026 15:57:14 +0200 Subject: [PATCH 05/17] Changelog --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d32b1..3be35d2 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. ## Version 0.3.0 From 7c1e25e6058358c98c9cb5e991a01c3dd9ab2fe3 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Tue, 26 May 2026 17:00:08 +0200 Subject: [PATCH 06/17] README --- README.md | 194 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 151 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 1fb8532..ae58489 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 and by email. ### 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,143 @@ 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. + +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` | -Sample: If you want to send the notification when the incident is resolved, you can modify the `srv/notification-types.json` as below: +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 { ... } +``` + +**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 +201,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. Notify to 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 iori notifications icon! Sample Application Demo +#### 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 @@ -144,13 +251,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", @@ -165,7 +271,7 @@ alert.notify({ "Value": "string" } ] - }); + }) ``` #### Passing the whole notification object @@ -174,37 +280,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). From 5db9fd3663655ef54583d4420d29dbccc4249743 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Tue, 26 May 2026 17:13:56 +0200 Subject: [PATCH 07/17] fix: error handling --- lib/content-deployment.js | 9 +++++++-- lib/notificationTypes.js | 17 ++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/lib/content-deployment.js b/lib/content-deployment.js index b52c091..fa38cd8 100644 --- a/lib/content-deployment.js +++ b/lib/content-deployment.js @@ -10,11 +10,16 @@ async function deployNotificationTypes() { // read notification types const filePath = cds.env.requires?.notifications?.types ?? '' const srvPath = cds.utils.path.join(cds.root, cds.env.folders.srv) - const model = await cds.load(srvPath) + let model + try { + model = await cds.load(srvPath) + } catch (e) { + if (e.code !== 'MODEL_NOT_FOUND') throw e + } const notificationTypes = [ ...notificationTypesFromModel(model), - ...readFile(filePath) + ...readFile(filePath) ?? [] ] if (validateNotificationTypes(notificationTypes)) { diff --git a/lib/notificationTypes.js b/lib/notificationTypes.js index ce61f20..cc58d00 100644 --- a/lib/notificationTypes.js +++ b/lib/notificationTypes.js @@ -77,15 +77,18 @@ async function createNotificationType(notificationType) { `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 - }).catch(err => { + 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 } From 8af38c0e2a60763a009212c71e271521cb71be9a Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 28 May 2026 15:32:02 +0200 Subject: [PATCH 08/17] ReadMe clarification --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0615c15..0a68652 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,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. From f353ef392d2de8df1441d2bf1c72756a434e7e35 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Fri, 29 May 2026 10:59:11 +0200 Subject: [PATCH 09/17] fix: bot suggestions --- README.md | 2 +- cds-plugin.js | 5 ++++- lib/content-deployment.js | 2 +- tests/bookshop/srv/cat-service.js | 2 +- tests/unit/lib/content-deployment.test.js | 13 ++++++++++--- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0a68652..403e9f6 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ As a pre-requisite to publish the notification, you need to have a [destination] #### Integrate with SAP Build Work Zone -Once application is deployed and integrated with SAP Build Work Zone, you can see the notification under iori notifications icon! +Once application is deployed and integrated with SAP Build Work Zone, you can see the notification under Fiori notifications icon! Sample Application Demo diff --git a/cds-plugin.js b/cds-plugin.js index f67f736..e32cdf9 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -24,6 +24,10 @@ else cds.once("served", async () => { 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 srvPath = path.join(cds.root, cds.env.folders.srv) const model = await cds.load(srvPath) const notificationTypes = [ @@ -32,7 +36,6 @@ else cds.once("served", async () => { ] if (validateNotificationTypes(notificationTypes)) { - const kind = cds.env.requires?.notifications?.kind if (kind === 'notify-to-rest') { const { processNotificationTypes } = require('./lib/notificationTypes') await processNotificationTypes(notificationTypes) diff --git a/lib/content-deployment.js b/lib/content-deployment.js index fa38cd8..54cb26c 100644 --- a/lib/content-deployment.js +++ b/lib/content-deployment.js @@ -19,7 +19,7 @@ async function deployNotificationTypes() { const notificationTypes = [ ...notificationTypesFromModel(model), - ...readFile(filePath) ?? [] + ...(filePath ? readFile(filePath) : []) ] if (validateNotificationTypes(notificationTypes)) { diff --git a/tests/bookshop/srv/cat-service.js b/tests/bookshop/srv/cat-service.js index 289fe67..04cad1f 100644 --- a/tests/bookshop/srv/cat-service.js +++ b/tests/bookshop/srv/cat-service.js @@ -12,7 +12,7 @@ module.exports = cds.service.impl(async function () { await UPDATE('sap.capire.bookshop.Books').set({ stock: book.stock - quantity }).where({ ID: bookId }) await alert.notify('BookOrdered', { - recipients: ['eric.peairs@sap.com'], + recipients: ['reader@bookshop.example'], data: { title: book.title, buyer: req.user.id } }) return { stock: book.stock - quantity } diff --git a/tests/unit/lib/content-deployment.test.js b/tests/unit/lib/content-deployment.test.js index efdd4e6..056442a 100644 --- a/tests/unit/lib/content-deployment.test.js +++ b/tests/unit/lib/content-deployment.test.js @@ -1,19 +1,26 @@ 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("../../../lib/compile") jest.mock("@sap-cloud-sdk/util") +// Set defaults before require — the module calls deployNotificationTypes() on load +readFile.mockReturnValue([]) +notificationTypesFromModel.mockReturnValue([]) + const contentDeployment = require("../../../lib/content-deployment") describe("contentDeployment", () => { beforeEach(() => { jest.clearAllMocks() setGlobalLogLevel.mockImplementation(() => undefined) - readFile.mockImplementation(() => []) + readFile.mockReturnValue([]) + notificationTypesFromModel.mockReturnValue([]) }) test("Set log level to error on startup", async () => { @@ -41,13 +48,13 @@ describe("contentDeployment", () => { expect(processNotificationTypes).not.toHaveBeenCalled() }) - test("Call readFile with empty string when notifications types path is not configured", async () => { + 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 contentDeployment.deployNotificationTypes() cds.env.requires.notifications.types = originalTypes - expect(readFile).toHaveBeenCalledWith('') + expect(readFile).not.toHaveBeenCalled() }) }) From 9cb590231c09cc6333972db7aaf2639e148fc075 Mon Sep 17 00:00:00 2001 From: Stefan Rudi Date: Tue, 2 Jun 2026 16:14:40 +0200 Subject: [PATCH 10/17] test: make bookshop sample deployment ready --- tests/bookshop/.gitignore | 1 + tests/bookshop/db/undeploy.json | 7 +++ tests/bookshop/mta.yaml | 101 ++++++++++++++++++++++++++++++++ tests/bookshop/package.json | 16 ++++- tests/bookshop/xs-security.json | 19 ++++++ 5 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 tests/bookshop/db/undeploy.json create mode 100644 tests/bookshop/mta.yaml create mode 100644 tests/bookshop/xs-security.json 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/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/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": [] + } + ] +} From a9b8df42e9e69d6568dc9e42dd53b1d1c2d7acd6 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Tue, 2 Jun 2026 17:12:24 +0200 Subject: [PATCH 11/17] feat: moved i18n + added http files --- .../bookshop/{srv => }/_i18n/i18n.properties | 0 tests/bookshop/test/http/AdminService.http | 162 ++++++++++++++++++ tests/bookshop/test/http/CatalogService.http | 39 +++++ 3 files changed, 201 insertions(+) rename tests/bookshop/{srv => }/_i18n/i18n.properties (100%) create mode 100644 tests/bookshop/test/http/AdminService.http create mode 100644 tests/bookshop/test/http/CatalogService.http diff --git a/tests/bookshop/srv/_i18n/i18n.properties b/tests/bookshop/_i18n/i18n.properties similarity index 100% rename from tests/bookshop/srv/_i18n/i18n.properties rename to tests/bookshop/_i18n/i18n.properties 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 +} From e3ba2aedec1846e6360fafdd916e9b1820593064 Mon Sep 17 00:00:00 2001 From: Eric P Date: Tue, 2 Jun 2026 17:36:46 +0200 Subject: [PATCH 12/17] Update README.md Co-authored-by: Stefan Rudi <54106627+stefanrudi@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 403e9f6..cdc73cc 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 Work Zone and by email. +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 From 76b51c271c12489eaef3ee46e225eb781a0d9c28 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Tue, 2 Jun 2026 17:47:26 +0200 Subject: [PATCH 13/17] fix: suggestion edits --- cds-plugin.js | 3 +-- lib/compile.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/cds-plugin.js b/cds-plugin.js index e32cdf9..98118cd 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -28,8 +28,7 @@ else cds.once("served", async () => { const needsProcessing = kind === 'notify-to-rest' || !production if (!needsProcessing) return - const srvPath = path.join(cds.root, cds.env.folders.srv) - const model = await cds.load(srvPath) + const model = cds.context?.model ?? cds.model const notificationTypes = [ ...notificationTypesFromModel(model), ...( typesPath ? readFile(typesPath) : [] ) diff --git a/lib/compile.js b/lib/compile.js index 643ecd3..281c4fa 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -12,7 +12,7 @@ function notificationTypesFromModel(model) { if (!model) return [] const types = [] - for (const def of Object.values(cds.reflect(model).definitions)) { + for (const def of Object.values(model.definitions)) { if (def.kind !== 'event') continue if (!Object.keys(def).some(k => k === '@notification' || k.startsWith('@notification.'))) continue From e049c7f7c88b7b49809faa25d257a0b125d57869 Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 3 Jun 2026 10:11:28 +0200 Subject: [PATCH 14/17] fix: revert --- cds-plugin.js | 3 ++- lib/compile.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cds-plugin.js b/cds-plugin.js index 98118cd..e32cdf9 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -28,7 +28,8 @@ else cds.once("served", async () => { const needsProcessing = kind === 'notify-to-rest' || !production if (!needsProcessing) return - const model = cds.context?.model ?? cds.model + const srvPath = path.join(cds.root, cds.env.folders.srv) + const model = await cds.load(srvPath) const notificationTypes = [ ...notificationTypesFromModel(model), ...( typesPath ? readFile(typesPath) : [] ) diff --git a/lib/compile.js b/lib/compile.js index 281c4fa..643ecd3 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -12,7 +12,7 @@ function notificationTypesFromModel(model) { if (!model) return [] const types = [] - for (const def of Object.values(model.definitions)) { + for (const def of Object.values(cds.reflect(model).definitions)) { if (def.kind !== 'event') continue if (!Object.keys(def).some(k => k === '@notification' || k.startsWith('@notification.'))) continue From e516c02ee83b55249d8b6770bae45f2f79c1dfee Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 3 Jun 2026 12:51:40 +0200 Subject: [PATCH 15/17] fix: small adjustments --- README.md | 2 ++ cds-plugin.js | 3 +- lib/compile.js | 2 +- lib/content-deployment.js | 2 +- tests/bookshop/srv/cat-service.js | 2 +- tests/bookshop/srv/notifications.cds | 38 ++++++++++++----------- tests/integration/bookshop.test.js | 10 +++--- tests/unit/lib/compile.test.js | 14 ++++----- tests/unit/lib/content-deployment.test.js | 14 +++------ 9 files changed, 43 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index cdc73cc..fdeb54a 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ Any event with at least one `@notification` annotation (the bare `@notification` > **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 | diff --git a/cds-plugin.js b/cds-plugin.js index e32cdf9..98118cd 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -28,8 +28,7 @@ else cds.once("served", async () => { const needsProcessing = kind === 'notify-to-rest' || !production if (!needsProcessing) return - const srvPath = path.join(cds.root, cds.env.folders.srv) - const model = await cds.load(srvPath) + const model = cds.context?.model ?? cds.model const notificationTypes = [ ...notificationTypesFromModel(model), ...( typesPath ? readFile(typesPath) : [] ) diff --git a/lib/compile.js b/lib/compile.js index 643ecd3..426d809 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -12,7 +12,7 @@ function notificationTypesFromModel(model) { if (!model) return [] const types = [] - for (const def of Object.values(cds.reflect(model).definitions)) { + for (const def of model.definitions) { if (def.kind !== 'event') continue if (!Object.keys(def).some(k => k === '@notification' || k.startsWith('@notification.'))) continue diff --git a/lib/content-deployment.js b/lib/content-deployment.js index 54cb26c..e0ebad4 100644 --- a/lib/content-deployment.js +++ b/lib/content-deployment.js @@ -27,7 +27,7 @@ async function deployNotificationTypes() { } } -deployNotificationTypes() +if (require.main === module) deployNotificationTypes() module.exports = { deployNotificationTypes diff --git a/tests/bookshop/srv/cat-service.js b/tests/bookshop/srv/cat-service.js index 04cad1f..478d304 100644 --- a/tests/bookshop/srv/cat-service.js +++ b/tests/bookshop/srv/cat-service.js @@ -11,7 +11,7 @@ module.exports = cds.service.impl(async function () { await UPDATE('sap.capire.bookshop.Books').set({ stock: book.stock - quantity }).where({ ID: bookId }) - await alert.notify('BookOrdered', { + await alert.notify('BookOrderedNotify', { recipients: ['reader@bookshop.example'], data: { title: book.title, buyer: req.user.id } }) diff --git a/tests/bookshop/srv/notifications.cds b/tests/bookshop/srv/notifications.cds index b0b6a6e..d2c8666 100644 --- a/tests/bookshop/srv/notifications.cds +++ b/tests/bookshop/srv/notifications.cds @@ -1,19 +1,21 @@ -@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 BookOrdered { - title : String; - buyer : String; - recipients: array of String; +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/integration/bookshop.test.js b/tests/integration/bookshop.test.js index ecadc78..a2e6165 100644 --- a/tests/integration/bookshop.test.js +++ b/tests/integration/bookshop.test.js @@ -18,7 +18,7 @@ describe("Notifications Integration", () => { 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).toHaveProperty("bookshop/BookOrderedNotify") }) test("Sending a notification with unknown type key gives a warning", async () => { @@ -51,22 +51,22 @@ describe("Notifications Integration", () => { }) 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).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/BookOrdered") + 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/BookOrdered"]["1"] + 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}}") }) diff --git a/tests/unit/lib/compile.test.js b/tests/unit/lib/compile.test.js index 667a436..3e0a6e9 100644 --- a/tests/unit/lib/compile.test.js +++ b/tests/unit/lib/compile.test.js @@ -1,7 +1,7 @@ const { notificationTypesFromModel } = require("../../../lib/compile") function makeModel(defs) { - return { definitions: defs } + return { definitions: Object.values(defs) } } describe("notificationTypesFromModel", () => { @@ -47,9 +47,9 @@ describe("notificationTypesFromModel", () => { test("Convert a fully annotated event to a notification type", () => { const model = makeModel({ - "BookOrdered": { + "BookOrderedNotify": { kind: "event", - name: "BookOrdered", + name: "BookOrderedNotify", "@description": "Book Ordered", "@Common.SemanticObject": "Book", "@Common.SemanticObjectAction": "display", @@ -64,7 +64,7 @@ describe("notificationTypesFromModel", () => { const [type] = notificationTypesFromModel(model) - expect(type.NotificationTypeKey).toBe("BookOrdered") + expect(type.NotificationTypeKey).toBe("BookOrderedNotify") expect(type.NotificationTypeVersion).toBe("1") expect(type.NavigationTargetObject).toBe("Book") expect(type.NavigationTargetAction).toBe("display") @@ -103,15 +103,15 @@ describe("notificationTypesFromModel", () => { test("Strip namespace prefix from event name", () => { const model = makeModel({ - "CatalogService.BookOrdered": { + "CatalogService.BookOrderedNotify": { kind: "event", - name: "CatalogService.BookOrdered", + name: "CatalogService.BookOrderedNotify", "@notification": { template: { title: "x" } } } }) const [type] = notificationTypesFromModel(model) - expect(type.NotificationTypeKey).toBe("BookOrdered") + expect(type.NotificationTypeKey).toBe("BookOrderedNotify") }) test("Unwrap hash-form enum references in deliveryChannels", () => { diff --git a/tests/unit/lib/content-deployment.test.js b/tests/unit/lib/content-deployment.test.js index 056442a..80806f4 100644 --- a/tests/unit/lib/content-deployment.test.js +++ b/tests/unit/lib/content-deployment.test.js @@ -9,11 +9,7 @@ jest.mock("../../../lib/notificationTypes") jest.mock("../../../lib/compile") jest.mock("@sap-cloud-sdk/util") -// Set defaults before require — the module calls deployNotificationTypes() on load -readFile.mockReturnValue([]) -notificationTypesFromModel.mockReturnValue([]) - -const contentDeployment = require("../../../lib/content-deployment") +const { deployNotificationTypes } = require("../../../lib/content-deployment") describe("contentDeployment", () => { beforeEach(() => { @@ -25,7 +21,7 @@ describe("contentDeployment", () => { test("Set log level to error on startup", async () => { validateNotificationTypes.mockReturnValue(false) - await contentDeployment.deployNotificationTypes() + await deployNotificationTypes() expect(setGlobalLogLevel).toHaveBeenCalledWith("error") }) @@ -33,7 +29,7 @@ describe("contentDeployment", () => { test("Process notification types when they are valid", async () => { validateNotificationTypes.mockReturnValue(true) processNotificationTypes.mockResolvedValue() - await contentDeployment.deployNotificationTypes() + await deployNotificationTypes() expect(validateNotificationTypes).toHaveBeenCalledWith([]) expect(processNotificationTypes).toHaveBeenCalledWith([]) @@ -42,7 +38,7 @@ describe("contentDeployment", () => { test("Notification types are not processed when they are invalid", async () => { validateNotificationTypes.mockReturnValue(false) processNotificationTypes.mockResolvedValue() - await contentDeployment.deployNotificationTypes() + await deployNotificationTypes() expect(validateNotificationTypes).toHaveBeenCalledWith([]) expect(processNotificationTypes).not.toHaveBeenCalled() @@ -52,7 +48,7 @@ describe("contentDeployment", () => { validateNotificationTypes.mockReturnValue(false) const originalTypes = cds.env.requires.notifications.types delete cds.env.requires.notifications.types - await contentDeployment.deployNotificationTypes() + await deployNotificationTypes() cds.env.requires.notifications.types = originalTypes expect(readFile).not.toHaveBeenCalled() From 6b92885ea9bfb5ac061c2072cc353e20791763cc Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 3 Jun 2026 13:54:34 +0200 Subject: [PATCH 16/17] fix: remove unnecessary function call --- lib/content-deployment.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/content-deployment.js b/lib/content-deployment.js index e0ebad4..53e8cd9 100644 --- a/lib/content-deployment.js +++ b/lib/content-deployment.js @@ -27,8 +27,6 @@ async function deployNotificationTypes() { } } -if (require.main === module) deployNotificationTypes() - module.exports = { deployNotificationTypes } From 8011f0ff304e9608a4dd1a5354917ab3a7d7d859 Mon Sep 17 00:00:00 2001 From: Eric P Date: Wed, 3 Jun 2026 14:03:26 +0200 Subject: [PATCH 17/17] Update lib/content-deployment.js Co-authored-by: Stefan Rudi <54106627+stefanrudi@users.noreply.github.com> --- lib/content-deployment.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/content-deployment.js b/lib/content-deployment.js index 53e8cd9..2d66802 100644 --- a/lib/content-deployment.js +++ b/lib/content-deployment.js @@ -8,14 +8,8 @@ async function deployNotificationTypes() { setGlobalLogLevel("error") // read notification types + const model = await cds.load('*') const filePath = cds.env.requires?.notifications?.types ?? '' - const srvPath = cds.utils.path.join(cds.root, cds.env.folders.srv) - let model - try { - model = await cds.load(srvPath) - } catch (e) { - if (e.code !== 'MODEL_NOT_FOUND') throw e - } const notificationTypes = [ ...notificationTypesFromModel(model),