From 6d107c99fff26324b98fea8ccb49a93bdbb0427a Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Thu, 7 May 2026 17:16:45 +0200 Subject: [PATCH 1/4] 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 2/4] 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 3/4] # 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 687ed93c1cdf531abbb6d13439cd0adaadc5989d Mon Sep 17 00:00:00 2001 From: Eric Peairs Date: Wed, 13 May 2026 17:02:53 +0200 Subject: [PATCH 4/4] Validate property names --- lib/compile.js | 10 +++- tests/integration/bookshop.test.js | 10 ++++ tests/unit/lib/compile.test.js | 80 ++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/lib/compile.js b/lib/compile.js index 643ecd3..e39d906 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -16,6 +16,14 @@ function notificationTypesFromModel(model) { if (def.kind !== 'event') continue if (!Object.keys(def).some(k => k === '@notification' || k.startsWith('@notification.'))) continue + const eventName = def.name.split('.').pop() + const violations = Object.keys(def.elements ?? {}).filter(name => name.length > 128) + if (violations.length) { + throw new Error( + `Event '${eventName}' has elements exceeding the maximum key length of 128 characters: ${violations.map(n => `'${n}'`).join(', ')}` + ) + } + const tmpl = { Language: 'en', TemplateLanguage: 'mustache' } if (def['@description']) tmpl.Description = resolveI18n(def['@description']) if (def['@notification.template.title']) tmpl.TemplateSensitive = resolveI18n(def['@notification.template.title']) @@ -26,7 +34,7 @@ function notificationTypesFromModel(model) { if (def['@notification.template.email.html']) tmpl.EmailHtml = resolveI18n(def['@notification.template.email.html']) const type = { - NotificationTypeKey: def.name.split('.').pop(), + NotificationTypeKey: eventName, NotificationTypeVersion: '1', Templates: [tmpl], } diff --git a/tests/integration/bookshop.test.js b/tests/integration/bookshop.test.js index ecadc78..7871334 100644 --- a/tests/integration/bookshop.test.js +++ b/tests/integration/bookshop.test.js @@ -1,6 +1,8 @@ const cds = require("@sap/cds") const { join } = cds.utils.path const { messages } = require("../../lib/utils") +const { notificationTypesFromModel } = require("../../lib/compile") + cds.test(join(__dirname, "../bookshop")) @@ -70,4 +72,12 @@ describe("Notifications Integration", () => { expect(type.Templates[0].TemplateSensitive).toBe("Book Ordered") expect(type.Templates[0].Subtitle).toBe("{{buyer}} ordered {{title}}") }) + + test("Throw when a notification event has an element name exceeding 128 characters", async () => { + const longName = 'a'.repeat(129) + const model = cds.parse.cdl(`@notification event OversizedEvent { ${longName}: String; }`) + expect(() => notificationTypesFromModel(model)).toThrow( + "Event 'OversizedEvent' has elements exceeding the maximum key length of 128 characters" + ) + }) }) \ No newline at end of file diff --git a/tests/unit/lib/compile.test.js b/tests/unit/lib/compile.test.js index 667a436..339bf5b 100644 --- a/tests/unit/lib/compile.test.js +++ b/tests/unit/lib/compile.test.js @@ -242,4 +242,84 @@ describe("notificationTypesFromModel", () => { const [type] = notificationTypesFromModel(model) expect(type.Templates[0].Subtitle).toBe("Resolved Subtitle") }) + + describe("Element name length validation", () => { + test("Throw when an element name exceeds 128 characters", () => { + const longName = 'a'.repeat(129) + const model = makeModel({ + "E": { + kind: "event", + name: "E", + "@notification": {}, + elements: { [longName]: { type: "cds.String" } } + } + }) + expect(() => notificationTypesFromModel(model)).toThrow(longName) + expect(() => notificationTypesFromModel(model)).toThrow("'E'") + }) + + test("No error for element names at exactly 128 characters", () => { + const exactName = 'a'.repeat(128) + const model = makeModel({ + "E": { + kind: "event", + name: "E", + "@notification": {}, + elements: { [exactName]: { type: "cds.String" } } + } + }) + expect(() => notificationTypesFromModel(model)).not.toThrow() + }) + + test("No error when all element names are within the 128-character limit", () => { + const model = makeModel({ + "E": { + kind: "event", + name: "E", + "@notification": {}, + elements: { title: { type: "cds.String" }, buyer: { type: "cds.String" } } + } + }) + expect(() => notificationTypesFromModel(model)).not.toThrow() + }) + + test("Report all violating element names in the error message", () => { + const b = 'b'.repeat(129) + const c = 'c'.repeat(130) + const model = makeModel({ + "E": { + kind: "event", + name: "E", + "@notification": {}, + elements: { + valid: { type: "cds.String" }, + [b]: { type: "cds.String" }, + [c]: { type: "cds.String" } + } + } + }) + expect(() => notificationTypesFromModel(model)).toThrow(b) + expect(() => notificationTypesFromModel(model)).toThrow(c) + }) + + test("Use stripped event name in error when event has a namespace prefix", () => { + const longName = 'x'.repeat(129) + const model = makeModel({ + "My.Namespace.OrderPlaced": { + kind: "event", + name: "My.Namespace.OrderPlaced", + "@notification": {}, + elements: { [longName]: { type: "cds.String" } } + } + }) + expect(() => notificationTypesFromModel(model)).toThrow("'OrderPlaced'") + }) + + test("No error when event has no elements", () => { + const model = makeModel({ + "E": { kind: "event", name: "E", "@notification": {} } + }) + expect(() => notificationTypesFromModel(model)).not.toThrow() + }) + }) })