diff --git a/README.md b/README.md index 7ff3be4..56a9713 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,9 @@ Any event with at least one `@notification` annotation (the bare `@notification` > [!Important] > The event must be contained within a service either by defining it directly inside a `service` or by using `extend service` / `using` to include it in an existing one. +> [!Note] +> Annotations can also be placed in a separate file using the standard CDS `annotate` directive. + **Common annotations:** ```cds @@ -346,6 +349,8 @@ event BookOrdered { ... } } ``` +> **Note:** `Enabled`, `DefaultPreference`, and `EditablePreference` are required ANS fields. Use the values as shown above without changing them. + ## Send Notifications There are two patterns for sending notifications. @@ -627,7 +632,7 @@ alert.notify({ ActorDisplayText: "ActorName", ActorImageURL: "https://some-url", NotificationTypeTimestamp: "2022-03-15T09:58:42.807Z", - TargetParameters: [{ Key: "string", Value: "string" }] + TargetParameters: [{ Key: "string", Value: "string" }] //tell Work Zone which record to open when the user clicks on the notification }) ``` diff --git a/cds-plugin.js b/cds-plugin.js index 56c0712..e8d6638 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -1,7 +1,7 @@ const cds = require("@sap/cds") if (!cds.env.requires?.notifications?.enabled) return -const { buildNotificationFromEvent } = require("./lib/utils") +const { buildNotificationFromEvent, buildNotificationFromEntity, resolveWhereXpr } = require("./lib/utils") cds.build?.register?.("notifications", require("./lib/build")) cds.on("loaded", m => { @@ -32,6 +32,40 @@ cds.on("serving", service => { } return next() }) + service.after("*", async (results, req) => { + const notificationsList = req.target?.["@notifications"] + if (!notificationsList?.length) return + const matching = notificationsList.filter(n => n.on?.includes(req.event)) + if (!matching.length) return + const notifications = await cds.connect.to("notifications") + for (const n of matching) { + if (n.where) { + const rawXpr = resolveWhereXpr(n.where) + if (!rawXpr) continue + const where = rawXpr.map(token => (token?.ref?.[0] === "$self" ? { ref: token.ref.slice(1) } : token)) + const keyParams = req.params?.[0] + let checkWhere = where + if (keyParams) { + const keyFilter = Object.entries(keyParams).flatMap(([k, v], i) => + (i > 0 ? ["and"] : []).concat([{ ref: [k] }, "=", { val: v }]) + ) + checkWhere = [...keyFilter, "and", ...where] + } + const exists = await SELECT.one.from(req.target).where(checkWhere) + if (!exists) continue + } + const entities = Array.isArray(results) ? results : [results] + for (const entity of entities) { + const notification = await buildNotificationFromEntity(n, entity) + try { + await notifications.notify(notification) + } catch (err) { + const LOG = cds.log("notifications") + LOG._error && LOG.error("Failed to send notification for entity", n.type, err) + } + } + } + }) }) cds.once("served", async () => { diff --git a/lib/compile.js b/lib/compile.js index a14bbe0..d88b6c7 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -75,6 +75,34 @@ function notificationTypesFromModel(model) { types.push(type) } + for (const def of model.definitions) { + if (def.kind !== "entity") continue + const notifications = def["@notifications"] + if (!notifications?.length) continue + for (const entry of notifications) { + if (!entry.type) continue + if (types.some(t => t.NotificationTypeKey === entry.type)) continue + const defaultChannels = cds.env.requires?.notifications?.channels ?? ["workzone"] + types.push({ + NotificationTypeKey: entry.type, + NotificationTypeVersion: "1", + Templates: [ + { + Language: defaultTexts, + TemplateLanguage: "mustache", + TemplateSensitive: entry.type, + TemplatePublic: entry.type, + TemplateGrouped: entry.type + } + ], + DeliveryChannels: defaultChannels.map(ch => { + const channelType = CHANNEL_MAP[ch.toLowerCase()] ?? ch.toUpperCase() + return { Type: channelType, Enabled: true, DefaultPreference: true, EditablePreference: true } + }) + }) + } + } + return types } diff --git a/lib/notificationTypes.js b/lib/notificationTypes.js index f422ced..0a9ceae 100644 --- a/lib/notificationTypes.js +++ b/lib/notificationTypes.js @@ -68,6 +68,15 @@ async function getNotificationTypes() { } async function createNotificationType(notificationType) { + const hasTemplate = notificationType.Templates?.some( + t => t.TemplateSensitive || t.TemplatePublic || t.TemplateGrouped + ) + if (!hasTemplate) { + throw new Error( + `Notification type '${notificationType.NotificationTypeKey}' has no template titles. At least one of TemplateSensitive, TemplatePublic, or TemplateGrouped must be provided in the @notification annotation.` + ) + } + const notificationDestination = await getNotificationDestination() const csrfHeaders = await buildHeadersForDestination(notificationDestination, { url: NOTIFICATION_TYPES_API_ENDPOINT @@ -329,5 +338,6 @@ async function processNotificationTypes(notificationTypesJSON) { module.exports = { createNotificationTypesMap, + createNotificationType, processNotificationTypes } diff --git a/lib/utils.js b/lib/utils.js index 32c1b70..32eaae3 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -337,6 +337,88 @@ async function buildNotificationFromEvent(eventDef, data = {}) { return notification } +function resolveRecipients(recipients, data) { + if (!recipients) return [] + if (typeof recipients === "object" && !recipients.ref) { + return Object.values(recipients).flatMap(item => resolveRecipients(item, data)) + } + if (recipients.ref) { + const prop = recipients.ref[0] === "$self" ? recipients.ref[1] : recipients.ref[0] + const rows = Array.isArray(data) ? data : [data] + return [...new Set(rows.map(row => row[prop]).filter(Boolean))] + } + return [String(recipients)] +} + +function resolveWhereXpr(where) { + if (!where) return null + return where.xpr ?? (Array.isArray(where) ? where : null) +} + +function resolveParamValue(value, data) { + if (value?.ref) { + const field = value.ref[0] === "$self" ? value.ref[1] : value.ref[0] + return data[field] + } + if (value?.["="]) { + const path = value["="] + const field = path.startsWith("$self.") ? path.slice("$self.".length) : path + return data[field] + } + return value?.val ?? resolveEnumValue(value) +} + +async function buildNotificationFromEntity(hook, data = {}) { + // hook = one entry from the @notifications array on the entity, e.g. { type, on, recipients, where, parameters, priority } + + const Recipients = resolveRecipients(hook.recipients, data).map(id => ({ [getRecipientKey(id)]: id })) + + const singleData = Array.isArray(data) ? (data[0] ?? {}) : data + // If hook.parameters is specified, use it directly; otherwise auto-map all entity data properties by name + const Properties = hook.parameters + ? Object.entries(hook.parameters).map(([key, value]) => ({ + Key: key, + Language: cds.env.i18n?.default_language ?? "en", + Value: String(resolveParamValue(value, singleData) ?? ""), + Type: "String", + IsSensitive: true + })) + : Object.entries(singleData) + .filter(([, value]) => String(value ?? "").length <= MAX_PROPERTY_VALUE_LENGTH) + .map(([key, value]) => ({ + Key: key, + Language: cds.env.i18n?.default_language ?? "en", + Value: String(value ?? ""), + Type: "String", + IsSensitive: true + })) + + // Priority lives inside the hook itself (not on the entity def like events) + const priorityAnnotation = hook.priority + let Priority = "NEUTRAL" + if (priorityAnnotation) { + if (priorityAnnotation.xpr) { + Priority = (await evaluateDynamicPriority({ xpr: priorityAnnotation.xpr }, data)) ?? "NEUTRAL" + } else { + const raw = resolveEnumValue(priorityAnnotation) + if (raw) { + const priority = String(raw).toUpperCase() + Priority = validatePriority(priority) ? priority : "NEUTRAL" + } + } + } + + const notification = { + NotificationTypeKey: hook.type, + NotificationTypeVersion: "1", + Priority, + Properties, + Recipients + } + + return notification +} + function applyValueLengthConstraints(notification) { if (!notification) return notification @@ -418,6 +500,9 @@ module.exports = { getNotificationTypesKeyWithPrefix, buildNotification, buildNotificationFromEvent, + buildNotificationFromEntity, + resolveRecipients, + resolveWhereXpr, mapCdsTypeToANSType, replaceRefsInExpr, applyValueLengthConstraints, diff --git a/tests/bookshop/db/data/sap.capire.bookshop-Authors.csv b/tests/bookshop/db/data/sap.capire.bookshop-Authors.csv index 9b418c1..93a4d57 100644 --- a/tests/bookshop/db/data/sap.capire.bookshop-Authors.csv +++ b/tests/bookshop/db/data/sap.capire.bookshop-Authors.csv @@ -1,5 +1,5 @@ ID,name,dateOfBirth,placeOfBirth,dateOfDeath,placeOfDeath 101,Emily Brontë,1818-07-30,"Thornton, Yorkshire",1848-12-19,"Haworth, Yorkshire" 107,Charlotte Brontë,1818-04-21,"Thornton, Yorkshire",1855-03-31,"Haworth, Yorkshire" -150,Edgar Allen Poe,1809-01-19,"Boston, Massachusetts",1849-10-07,"Baltimore, Maryland" +150,Edgar Allan Poe,1809-01-19,"Boston, Massachusetts",1849-10-07,"Baltimore, Maryland" 170,Richard Carpenter,1929-08-14,"King’s Lynn, Norfolk",2012-02-26,"Hertfordshire, England" diff --git a/tests/bookshop/srv/notifications.cds b/tests/bookshop/srv/notifications.cds index 77f544a..42b4a73 100644 --- a/tests/bookshop/srv/notifications.cds +++ b/tests/bookshop/srv/notifications.cds @@ -1,4 +1,5 @@ using { CatalogService } from './cat-service'; +using {sap.capire.bookshop as my} from '../db/schema'; extend service CatalogService with { @description: '{i18n>BOOK_ORDERED_DESCRIPTION}' @@ -34,3 +35,22 @@ extend service CatalogService with { recipients : array of String; } } + +service CatalogTest { + @notifications : [{ + type: 'MY_NOTIFICATION_TYPE', + on: ['READ'], + recipients: ($self.createdBy), + where: ($self.title = 'Wuthering Heights'), + priority: #Low, + }, { + type: 'MY_NOTIFICATION_TYPE', + on: ['READ'], + recipients: ($self.createdBy), + where: ($self.title = 'Jane Eyre'), + priority: #Low, + parameters: { bookTitle: $self.title, bookId: $self.ID } + }] + entity Books as projection on my.Books; + +} diff --git a/tests/integration/bookshop.test.js b/tests/integration/bookshop.test.js index 004c24b..459a28f 100644 --- a/tests/integration/bookshop.test.js +++ b/tests/integration/bookshop.test.js @@ -2,6 +2,7 @@ const cds = require("@sap/cds") const { join } = require("path") const { messages } = require("../../lib/utils") const { notificationTypesFromModel } = require("../../lib/compile") +const { createNotificationType } = require("../../lib/notificationTypes") const usesRestService = ["hybrid", "production"].includes(process.env.CDS_ENV) @@ -254,6 +255,17 @@ describe("Notifications Integration", () => { "Event 'OversizedEvent' has elements exceeding the maximum key length of 128 characters" ) }) + + test("Throws clear error when deploying a notification type with empty templates", async () => { + const emptyType = { + NotificationTypeKey: "EmptyType", + NotificationTypeVersion: "1", + Templates: [{ Language: "en", TemplateLanguage: "mustache" }] + } + await expect(createNotificationType(emptyType)).rejects.toThrow( + "At least one of TemplateSensitive, TemplatePublic, or TemplateGrouped must be provided in the @notification annotation." + ) + }) }) test("Batch of typed notifications logs each one to console", async () => { @@ -334,7 +346,7 @@ describe("Notifications Integration", () => { alert._handlers.before.splice(beforeHandlers) }) - test("is called before a notification is sent and receives msg.event and msg.data", async () => { + test("Is called before a notification is sent and receives msg.event and msg.data", async () => { let capturedEvent, capturedData alert.before("*", msg => { capturedEvent = msg.event @@ -353,7 +365,7 @@ describe("Notifications Integration", () => { }) }) - test("can suppress a notification by throwing", async () => { + test("Can suppress a notification by throwing", async () => { alert.before("*", () => { throw new cds.error("Recipient not eligible") }) diff --git a/tests/integration/entityNotification.test.js b/tests/integration/entityNotification.test.js new file mode 100644 index 0000000..dd56ac7 --- /dev/null +++ b/tests/integration/entityNotification.test.js @@ -0,0 +1,114 @@ +const cds = require("@sap/cds") +const { join } = require("path") + +const { GET } = cds.test(join(__dirname, "../bookshop")) + +describe("Entity @notifications", () => { + let alert + + beforeAll(async () => { + alert = await cds.connect.to("notifications") + }) + + describe("Startup", () => { + test("MY_NOTIFICATION_TYPE is registered in cds.notifications.local.types", () => { + expect(cds.notifications?.local?.types).toBeDefined() + expect(cds.notifications.local.types).toHaveProperty("bookshop/MY_NOTIFICATION_TYPE") + }) + + test("MY_NOTIFICATION_TYPE template fields are set to the type key", () => { + const type = cds.notifications.local.types["bookshop/MY_NOTIFICATION_TYPE"]["1"] + expect(type.Templates[0].TemplateSensitive).toBe("MY_NOTIFICATION_TYPE") + expect(type.Templates[0].TemplatePublic).toBe("MY_NOTIFICATION_TYPE") + expect(type.Templates[0].TemplateGrouped).toBe("MY_NOTIFICATION_TYPE") + }) + }) + + describe("READ event", () => { + test("Reading the matching book fires a notification", async () => { + const captured = [] + const handler = msg => captured.push(msg.data) + alert.before("*", handler) + + try { + await GET("/odata/v4/catalog-test/Books(201)") + expect(captured.length).toBeGreaterThan(0) + expect(captured[0].NotificationTypeKey).toContain("MY_NOTIFICATION_TYPE") + } finally { + alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) + } + }) + + test("Reading a non-matching book does NOT fire a notification", async () => { + const captured = [] + const handler = msg => captured.push(msg.data) + alert.before("*", handler) + + try { + await GET("/odata/v4/catalog-test/Books(251)") + expect(captured).toHaveLength(0) + } finally { + alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) + } + }) + + test("Notification for matching READ has Priority LOW", async () => { + const captured = [] + const handler = msg => captured.push(msg.data) + alert.before("*", handler) + + try { + await GET("/odata/v4/catalog-test/Books(201)") + expect(captured[0]?.Priority).toBe("LOW") + } finally { + alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) + } + }) + + test("Notification for matching READ has Properties from entity data", async () => { + const captured = [] + const handler = msg => captured.push(msg.data) + alert.before("*", handler) + + try { + await GET("/odata/v4/catalog-test/Books(201)") + const props = captured[0]?.Properties ?? [] + const titleProp = props.find(p => p.Key === "title") + expect(titleProp?.Value).toBe("Wuthering Heights") + } finally { + alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) + } + }) + }) + + describe("READ event with explicit parameters", () => { + test("Notification Properties contains only the explicitly specified parameters", async () => { + const captured = [] + const handler = msg => captured.push(msg.data) + alert.before("*", handler) + + try { + await GET("/odata/v4/catalog-test/Books(207)") + const props = captured[0]?.Properties ?? [] + expect(props.map(p => p.Key)).toEqual(["bookTitle", "bookId"]) + } finally { + alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) + } + }) + + test("Notification Properties values are resolved from entity data", async () => { + const captured = [] + const handler = msg => captured.push(msg.data) + alert.before("*", handler) + + try { + await GET("/odata/v4/catalog-test/Books(207)") + const props = captured[0]?.Properties ?? [] + expect(props.find(p => p.Key === "bookTitle")?.Value).toBe("Jane Eyre") + expect(props.find(p => p.Key === "bookId")?.Value).toBe("207") + } finally { + alert._handlers.before.splice(alert._handlers.before.indexOf(handler), 1) + } + }) + }) +}) diff --git a/tests/unit/lib/entityUnitNotify.test.js b/tests/unit/lib/entityUnitNotify.test.js new file mode 100644 index 0000000..ac538c2 --- /dev/null +++ b/tests/unit/lib/entityUnitNotify.test.js @@ -0,0 +1,340 @@ +const cds = require("@sap/cds") +const { buildNotificationFromEntity, resolveRecipients, resolveWhereXpr } = require("../../../lib/utils") +const { notificationTypesFromModel } = require("../../../lib/compile") + +function makeModel(defs) { + const definitions = { ...defs } + definitions[Symbol.iterator] = function* () { + yield* Object.values(this) + } + return { definitions } +} + +describe("resolveWhereXpr", () => { + test("Returns xpr array when where has xpr property", () => { + const where = { xpr: [{ ref: ["title"] }, "=", { val: "Wuthering Heights" }] } + expect(resolveWhereXpr(where)).toBe(where.xpr) + }) + + test("Returns where directly when it is a plain array", () => { + const where = [{ ref: ["title"] }, "=", { val: "Wuthering Heights" }] + expect(resolveWhereXpr(where)).toBe(where) + }) + + test("Returns null when where has no xpr and is not an array", () => { + expect(resolveWhereXpr({ someOtherShape: true })).toBeNull() + }) + + test("Returns null for null/undefined", () => { + expect(resolveWhereXpr(null)).toBeNull() + expect(resolveWhereXpr(undefined)).toBeNull() + }) +}) + +describe("resolveRecipients", () => { + test("Returns empty array for null", () => { + expect(resolveRecipients(null, {})).toEqual([]) + }) + + test("Returns empty array for undefined", () => { + expect(resolveRecipients(undefined, {})).toEqual([]) + }) + + test("Resolves $self.field ref from a single data object", () => { + const recipients = { ref: ["$self", "createdBy"] } + expect(resolveRecipients(recipients, { createdBy: "alice@example.com" })).toEqual(["alice@example.com"]) + }) + + test("Resolves plain field ref from a single data object", () => { + const recipients = { ref: ["createdBy"] } + expect(resolveRecipients(recipients, { createdBy: "alice@example.com" })).toEqual(["alice@example.com"]) + }) + + test("Resolves recipients across an array of rows and deduplicates", () => { + const recipients = { ref: ["$self", "createdBy"] } + const data = [ + { createdBy: "alice@example.com" }, + { createdBy: "bob@example.com" }, + { createdBy: "alice@example.com" } + ] + expect(resolveRecipients(recipients, data)).toEqual(["alice@example.com", "bob@example.com"]) + }) + + test("Filters out falsy values from data rows", () => { + const recipients = { ref: ["$self", "createdBy"] } + const data = [{ createdBy: "alice@example.com" }, { createdBy: null }, { createdBy: undefined }] + expect(resolveRecipients(recipients, data)).toEqual(["alice@example.com"]) + }) + + test("Returns string literal as a single-element array", () => { + expect(resolveRecipients("static@example.com", {})).toEqual(["static@example.com"]) + }) + + test("Recurses over object without .ref (e.g. CDS object-like annotation)", () => { + const recipients = { 0: { ref: ["createdBy"] }, 1: { ref: ["modifiedBy"] } } + const data = { createdBy: "alice@example.com", modifiedBy: "bob@example.com" } + const result = resolveRecipients(recipients, data) + expect(result).toContain("alice@example.com") + expect(result).toContain("bob@example.com") + }) +}) + +describe("buildNotificationFromEntity", () => { + const baseHook = { + type: "MY_NOTIFICATION_TYPE", + on: ["READ", "CREATE"], + recipients: { ref: ["$self", "createdBy"] } + } + + const baseData = { + ID: "201", + title: "Wuthering Heights", + createdBy: "alice@example.com" + } + + test("Sets NotificationTypeKey to hook.type (no prefix applied)", async () => { + const result = await buildNotificationFromEntity(baseHook, baseData) + expect(result.NotificationTypeKey).toBe("MY_NOTIFICATION_TYPE") + }) + + test("Sets NotificationTypeVersion to '1'", async () => { + const result = await buildNotificationFromEntity(baseHook, baseData) + expect(result.NotificationTypeVersion).toBe("1") + }) + + test("Auto-maps all entity data fields to Properties when hook.parameters is not set", async () => { + const result = await buildNotificationFromEntity(baseHook, baseData) + const keys = result.Properties.map(p => p.Key) + expect(keys).toContain("ID") + expect(keys).toContain("title") + expect(keys).toContain("createdBy") + }) + + test("Properties entries have correct shape with IsSensitive true", async () => { + const result = await buildNotificationFromEntity(baseHook, baseData) + expect(result.Properties).toContainEqual({ + Key: "title", + Language: "en", + Value: "Wuthering Heights", + Type: "String", + IsSensitive: true + }) + }) + + test("Converts null/undefined property values to empty string", async () => { + const data = { title: null, stock: undefined } + const result = await buildNotificationFromEntity(baseHook, data) + const titleProp = result.Properties.find(p => p.Key === "title") + expect(titleProp.Value).toBe("") + }) + + test("Auto-maps entity fields when data is an array (no hook.parameters)", async () => { + const data = [{ ID: "201", title: "Wuthering Heights", createdBy: "alice@example.com" }] + const result = await buildNotificationFromEntity(baseHook, data) + const keys = result.Properties.map(p => p.Key) + expect(keys).not.toContain("0") + expect(keys).toContain("title") + expect(result.Properties.find(p => p.Key === "title")?.Value).toBe("Wuthering Heights") + }) + + test("Uses explicit hook.parameters when provided, mapped by ref", async () => { + const hook = { + ...baseHook, + parameters: { + bookTitle: { ref: ["title"] } + } + } + const result = await buildNotificationFromEntity(hook, baseData) + expect(result.Properties).toHaveLength(1) + expect(result.Properties[0]).toMatchObject({ Key: "bookTitle", Value: "Wuthering Heights" }) + }) + + test("hook.parameters strips $self prefix from refs", async () => { + const hook = { + ...baseHook, + parameters: { + bookTitle: { ref: ["$self", "title"] } + } + } + const result = await buildNotificationFromEntity(hook, baseData) + expect(result.Properties[0]).toMatchObject({ Key: "bookTitle", Value: "Wuthering Heights" }) + }) + + test("hook.parameters resolves CDS '=' path expression format ({ '=': '$self.title' })", async () => { + const hook = { + ...baseHook, + parameters: { + bookTitle: { "=": "$self.title" }, + bookId: { "=": "$self.ID" } + } + } + const result = await buildNotificationFromEntity(hook, baseData) + expect(result.Properties.find(p => p.Key === "bookTitle")?.Value).toBe("Wuthering Heights") + expect(result.Properties.find(p => p.Key === "bookId")?.Value).toBe("201") + }) + + test("hook.parameters with array data uses first element instead of crashing", async () => { + const hook = { + ...baseHook, + parameters: { bookTitle: { ref: ["title"] } } + } + const data = [{ title: "Wuthering Heights", createdBy: "alice@example.com" }] + const result = await buildNotificationFromEntity(hook, data) + expect(result.Properties[0]).toMatchObject({ Key: "bookTitle", Value: "Wuthering Heights" }) + }) + + test("hook.parameters with a plain literal value (no ref) uses val instead of crashing", async () => { + const hook = { + ...baseHook, + parameters: { staticKey: { val: "hardcoded" } } + } + const result = await buildNotificationFromEntity(hook, baseData) + expect(result.Properties[0]).toMatchObject({ Key: "staticKey", Value: "hardcoded" }) + }) + + test("Defaults Priority to NEUTRAL when hook has no priority", async () => { + const result = await buildNotificationFromEntity(baseHook, baseData) + expect(result.Priority).toBe("NEUTRAL") + }) + + test("Resolves enum priority annotation (#Low -> LOW)", async () => { + const hook = { ...baseHook, priority: { "#": "Low" } } + const result = await buildNotificationFromEntity(hook, baseData) + expect(result.Priority).toBe("LOW") + }) + + test("Resolves enum priority annotation (#High -> HIGH)", async () => { + const hook = { ...baseHook, priority: { "#": "High" } } + const result = await buildNotificationFromEntity(hook, baseData) + expect(result.Priority).toBe("HIGH") + }) + + describe("Falls back to NEUTRAL for invalid priority", () => { + const log = cds.test.log() + beforeEach(() => log.clear()) + + test("warns and returns NEUTRAL", async () => { + const hook = { ...baseHook, priority: { "#": "CRITICAL" } } + const result = await buildNotificationFromEntity(hook, baseData) + expect(result.Priority).toBe("NEUTRAL") + expect(log.output).toMatch(/invalid|CRITICAL/i) + }) + }) + + test("Resolves recipients from $self.field ref in data", async () => { + const result = await buildNotificationFromEntity(baseHook, baseData) + expect(result.Recipients).toContainEqual({ RecipientId: "alice@example.com" }) + }) + + test("Returns empty Recipients array when hook.recipients is not set", async () => { + const hook = { type: "MY_NOTIFICATION_TYPE", on: ["READ"] } + const result = await buildNotificationFromEntity(hook, baseData) + expect(result.Recipients).toEqual([]) + }) + + test("Works with array data (multiple result rows)", async () => { + const data = [ + { title: "Book A", createdBy: "alice@example.com" }, + { title: "Book B", createdBy: "bob@example.com" } + ] + const result = await buildNotificationFromEntity(baseHook, data) + const recipientIds = result.Recipients.map(r => r.RecipientId) + expect(recipientIds).toContain("alice@example.com") + expect(recipientIds).toContain("bob@example.com") + }) +}) + +describe("notificationTypesFromModel — entity @notifications", () => { + test("Generates a type with TemplateSensitive/Public/Grouped set to entry.type", () => { + const model = makeModel({ + MyEntity: { + kind: "entity", + name: "MyEntity", + "@notifications": [{ type: "MY_TYPE", on: ["READ"] }] + } + }) + const types = notificationTypesFromModel(model) + const type = types.find(t => t.NotificationTypeKey === "MY_TYPE") + expect(type).toBeDefined() + expect(type.NotificationTypeVersion).toBe("1") + expect(type.Templates[0].TemplateSensitive).toBe("MY_TYPE") + expect(type.Templates[0].TemplatePublic).toBe("MY_TYPE") + expect(type.Templates[0].TemplateGrouped).toBe("MY_TYPE") + }) + + test("Includes default DeliveryChannels on entity-derived types", () => { + const model = makeModel({ + MyEntity: { + kind: "entity", + name: "MyEntity", + "@notifications": [{ type: "MY_TYPE", on: ["READ"] }] + } + }) + const types = notificationTypesFromModel(model) + const type = types.find(t => t.NotificationTypeKey === "MY_TYPE") + expect(type.DeliveryChannels).toBeDefined() + expect(type.DeliveryChannels.length).toBeGreaterThan(0) + expect(type.DeliveryChannels[0]).toMatchObject({ Type: "WEB", Enabled: true }) + }) + + test("Skips @notifications entries that have no type field", () => { + const model = makeModel({ + MyEntity: { + kind: "entity", + name: "MyEntity", + "@notifications": [{ on: ["READ"] }] + } + }) + const types = notificationTypesFromModel(model) + expect(types).toHaveLength(0) + }) + + test("Does not generate entity type when @notifications is empty", () => { + const model = makeModel({ + MyEntity: { + kind: "entity", + name: "MyEntity", + "@notifications": [] + } + }) + const types = notificationTypesFromModel(model) + expect(types).toHaveLength(0) + }) + + test("Does not add a duplicate when an event with the same type key already exists", () => { + const model = makeModel({ + "Svc.MY_TYPE": { + kind: "event", + name: "Svc.MY_TYPE", + "@notification.title": "My Type Title" + }, + MyEntity: { + kind: "entity", + name: "MyEntity", + "@notifications": [{ type: "MY_TYPE", on: ["READ"] }] + } + }) + const types = notificationTypesFromModel(model) + const matching = types.filter(t => t.NotificationTypeKey === "MY_TYPE") + expect(matching).toHaveLength(1) + // The event-derived entry should win (it has a real title) + expect(matching[0].Templates[0].TemplateSensitive).toBe("My Type Title") + }) + + test("Handles multiple @notifications entries on the same entity", () => { + const model = makeModel({ + MyEntity: { + kind: "entity", + name: "MyEntity", + "@notifications": [ + { type: "TYPE_A", on: ["READ"] }, + { type: "TYPE_B", on: ["CREATE"] } + ] + } + }) + const types = notificationTypesFromModel(model) + const keys = types.map(t => t.NotificationTypeKey) + expect(keys).toContain("TYPE_A") + expect(keys).toContain("TYPE_B") + }) +}) diff --git a/tests/unit/lib/notificationTypes.test.js b/tests/unit/lib/notificationTypes.test.js index 0b69b1d..a430e16 100644 --- a/tests/unit/lib/notificationTypes.test.js +++ b/tests/unit/lib/notificationTypes.test.js @@ -345,6 +345,18 @@ describe("Managing of Notification Types", () => { }) describe("Creating Types", () => { + test("Throws before any I/O when template has no title fields", async () => { + const emptyType = { + NotificationTypeKey: "EmptyType", + NotificationTypeVersion: "1", + Templates: [{ Language: "en", TemplateLanguage: "mustache" }] + } + await expect(notificationTypes.createNotificationType(emptyType)).rejects.toThrow( + "At least one of TemplateSensitive, TemplatePublic, or TemplateGrouped must be provided" + ) + expect(httpClient.executeHttpRequest).not.toHaveBeenCalled() + }) + test("Create Default and all new types when none exist in Work Zone", () => { httpClient.executeHttpRequest.mockReturnValue(emptyResponseBody)