-
Notifications
You must be signed in to change notification settings - Fork 6
Validate names #129
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Validate names #129
Changes from all commits
6d107c9
074a7e7
8bc21ce
076a625
d5ad1b6
687ed93
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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')) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,69 @@ | ||||||||||||||
| const cds = require('@sap/cds') | ||||||||||||||
|
|
||||||||||||||
| function resolveEnum(val) { | ||||||||||||||
| if (val && typeof val === 'object') { | ||||||||||||||
| if ('=' in val) return val['='] | ||||||||||||||
| if ('#' in val) return val['#'] | ||||||||||||||
| } | ||||||||||||||
| return val | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function notificationTypesFromModel(model) { | ||||||||||||||
| if (!model) return [] | ||||||||||||||
| const types = [] | ||||||||||||||
|
|
||||||||||||||
| for (const def of Object.values(cds.reflect(model).definitions)) { | ||||||||||||||
| 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']) | ||||||||||||||
| 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: eventName, | ||||||||||||||
| 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() } | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug:
Consider guarding with a
Suggested change
Double-check suggestion before committing. Edit this comment for amendments. Please provide feedback on the review comment by checking the appropriate box:
|
||||||||||||||
| if (ch.enabled !== undefined) entry.Enabled = ch.enabled | ||||||||||||||
| if (ch.defaultPreference !== undefined) entry.DefaultPreference = ch.defaultPreference | ||||||||||||||
| if (ch.editablePreference !== undefined) entry.EditablePreference = ch.editablePreference | ||||||||||||||
| return entry | ||||||||||||||
| }).filter(Boolean) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| types.push(type) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return types | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function resolveI18n(value) { | ||||||||||||||
| if (typeof value !== 'string') return value | ||||||||||||||
| const match = value.match(/^\{i18n>([^}]+)\}$/) | ||||||||||||||
| if (!match) return value | ||||||||||||||
| return cds.i18n?.labels?.at(match[1], 'en') ?? value | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| module.exports = { notificationTypesFromModel } | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| @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}', | ||
| } | ||
| } | ||
| event BookOrdered { | ||
| title : String; | ||
| buyer : String; | ||
| recipients: array of String; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,62 +1,83 @@ | ||
| 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") | ||
| const { notificationTypesFromModel } = require("../../lib/compile") | ||
|
|
||
| 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") | ||
| }) | ||
|
|
||
| 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") | ||
| }) | ||
|
|
||
| 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}}") | ||
| }) | ||
|
|
||
| expect(log.output).toContain("bookshop/BookOrdered"); | ||
| expect(log.output).not.toContain("is not in the notification types file"); | ||
| }); | ||
| }); | ||
| 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" | ||
| ) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: An error thrown by
notificationTypesFromModel(e.g. the new 128-character validation) or bycds.loadwill result in an unhandled promise rejection inside theasynccallback — there is no try/catch. This can crash the process or silently swallow startup failures depending on the Node.js version, making the problem very hard to diagnose.Consider wrapping the body in a try/catch and logging via
cds.log('notifications').Please provide feedback on the review comment by checking the appropriate box: