diff --git a/CHANGELOG.md b/CHANGELOG.md index bba5dbe..fc2d28d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). - CAP Java plugin for integrating SAP Alert Notification Service (ANS) with CAP Java applications - `@notification` annotation on CDS events to define notification types and standalone notification templates - Support for email templates via `template.email.subject` and `template.email.html` fields -- Mustache syntax (`{{variableName}}`) for dynamic content substitution in templates +- Mustache syntax (`{{variableName}}`) for dynamic content substitution in notification templates and notification type translations - i18n support via `{i18n>KEY}` placeholders in all template fields with automatic language detection per recipient -- Automatic notification type provisioning to ANS at application startup (`NotificationTypeAutoProvisionerHandler`) +- Automatic notification type provisioning to ANS at application startup +- `NotificationTemplate` visibility defaults to `PRIVATE` when `@notification.customizable` annotation is absent - Local mode operation: logs notifications to console without requiring an ANS service binding (`LocalHandler`) - Production mode operation: sends notifications to SAP Alert Notification Service (`ProductionHandler`) -- Mode toggled via `cds.environment.production.enabled` configuration property +- Mode toggled via `cds.environment.production.enabled` configuration property; defaults to local mode when property is not set or is `null` - Programmatic notification emission by injecting the generated `NotificationService` in CAP event handlers - Declarative notification triggering via `@notifications` annotation on CDS entities for `CREATE`, `UPDATE`, and `DELETE` events - Support for static and dynamic notification priorities (`#LOW`, `#NEUTRAL`, `#MEDIUM`, `#HIGH`) evaluated via CDS expressions at runtime diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/NotificationServiceConfiguration.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/NotificationServiceConfiguration.java index 8219c35..a5ab934 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/NotificationServiceConfiguration.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/NotificationServiceConfiguration.java @@ -96,7 +96,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { OutboxService.PERSISTENT_ORDERED_NAME); } - if (environment.getProduction().isEnabled()) { + if (Boolean.TRUE.equals(environment.getProduction().isEnabled())) { logger.info("Production mode enabled - using ProductionHandler"); configurer.eventHandler(new ProductionHandler(outboxedSvc, configurer.getCdsRuntime())); // Register handler for auto-provisioning standalone templates on application prepared event diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTemplateAssembler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTemplateAssembler.java index de2f69f..e66c0fa 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTemplateAssembler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTemplateAssembler.java @@ -73,12 +73,9 @@ private Optional extractTemplateFromEvent(CdsEvent event) NotificationTemplates template = Struct.create(NotificationTemplates.class); template.setKey(key); - // Visibility - from @notification.customizable annotation (ANS defaults to PRIVATE) - // @notification.customizable: true → PUBLIC, absent or false → PRIVATE (default) - String visibility = extractVisibility(event); - if (visibility != null) { - template.setVisibility(visibility); - } + // Visibility - from @notification.customizable annotation + // @notification.customizable: true → PUBLIC, absent or false → PRIVATE + template.setVisibility(extractVisibility(event)); // PropertiesSchema - auto-generated from event elements String propertiesSchema = buildPropertiesSchema(event); @@ -90,7 +87,7 @@ private Optional extractTemplateFromEvent(CdsEvent event) List tags = buildTags(source, eventName); template.setTags(tags); - Set locales = i18nHelper.getAvailableLocales(); + Set locales = i18nHelper.getAvailableLocalesForEvent(event); logger.debug("Creating translations for {} discovered i18n locales", locales.size()); List translations = new ArrayList<>(); @@ -116,10 +113,9 @@ private Translations createTranslation( translation.setLanguage(lang); translation.setSyntax(DEFAULT_SYNTAX); - // Source, Event, DisplayName - for admin UI filtering and display + // Source, Event - for admin UI filtering and display translation.setSource(source); translation.setEvent(eventName); - translation.setDisplayName(eventName); // Title (required) - from @notification.template.title String title = @@ -203,9 +199,11 @@ private Email buildEmail(CdsEvent event, Map i18nTexts) { } private String extractVisibility(CdsEvent event) { - return Boolean.TRUE.equals(event.getAnnotationValue("notification.customizable", Boolean.FALSE)) - ? "PUBLIC" - : null; + var annotation = event.findAnnotation("notification.customizable"); + if (annotation.isEmpty()) { + return "PRIVATE"; // absent → explicit PRIVATE (same as ANS default) + } + return Boolean.TRUE.equals(annotation.get().getValue()) ? "PUBLIC" : "PRIVATE"; } /** diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java index ce4e19a..1fea014 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/assemblers/NotificationTypeAssembler.java @@ -5,7 +5,9 @@ import cds.gen.notificationtypeproviderservice.DeliveryChannels; import cds.gen.notificationtypeproviderservice.NotificationTypes; +import cds.gen.notificationtypeproviderservice.Translations; import com.sap.cds.Struct; +import com.sap.cds.notifications.helpers.I18nHelper; import com.sap.cds.reflect.CdsEvent; import com.sap.cds.reflect.CdsModel; import com.sap.cds.services.runtime.CdsRuntime; @@ -19,9 +21,11 @@ public class NotificationTypeAssembler { private static final Logger logger = LoggerFactory.getLogger(NotificationTypeAssembler.class); private final CdsRuntime runtime; + private final I18nHelper i18nHelper; public NotificationTypeAssembler(CdsRuntime runtime) { this.runtime = runtime; + this.i18nHelper = new I18nHelper(runtime); } /** Build all notification types from CDS model event annotations. */ @@ -46,16 +50,84 @@ private Optional extractNotificationTypeFromEvent(CdsEvent ev nt.setNotificationTypeKey(key); nt.setNotificationTypeVersion("1"); + // Extract translations (required by ANS — at least Translations or Templates must be present) + List translations = extractTranslations(event); + nt.setTranslations(translations); + // Extract delivery channels List deliveryChannels = extractDeliveryChannels(event); if (!deliveryChannels.isEmpty()) { nt.setDeliveryChannels(deliveryChannels); } - logger.debug("Extracted NotificationType: {}", key); + logger.debug("Extracted NotificationType: {} with {} translation(s)", key, translations.size()); return Optional.of(nt); } + /** + * Extract Translations from CDS event annotations for all available i18n locales. + * + *

Mapping: + * + *

    + *
  • {@code @notification.template.publicTitle} → DisplayName (non-sensitive, shown in user + * preferences) + *
  • {@code @notification.template.groupedTitle} → GroupTitle (required) + *
  • {@code @description} → Description (optional) + *
+ */ + private List extractTranslations(CdsEvent event) { + Set locales = i18nHelper.getAvailableLocalesForEvent(event); + List translations = new ArrayList<>(); + + for (Locale locale : locales) { + Map i18nTexts = i18nHelper.getI18nTexts(locale); + + Translations translation = Struct.create(Translations.class); + translation.setLanguage(locale.toLanguageTag()); + + // DisplayName — from @notification.template.publicTitle (non-sensitive, shown in user + // preferences) + String displayName = + i18nHelper.resolveAnnotationValue(event, "notification.template.publicTitle", i18nTexts); + if (displayName == null || displayName.isBlank()) { + throw new IllegalStateException( + String.format( + "Missing required annotation: @notification.template.publicTitle for event '%s'.", + event.getName())); + } + translation.setDisplayName(displayName); + + // GroupTitle — from @notification.template.groupedTitle (required) + String groupTitle = + i18nHelper.resolveAnnotationValue(event, "notification.template.groupedTitle", i18nTexts); + if (groupTitle == null || groupTitle.isBlank()) { + throw new IllegalStateException( + String.format( + "Missing required annotation: @notification.template.groupedTitle for event '%s'.", + event.getName())); + } + translation.setGroupTitle(groupTitle); + + translation.setSyntax("MUSTACHE"); + + // Description — from @description (optional) + String description = i18nHelper.resolveAnnotationValue(event, "description", i18nTexts); + if (description != null && !description.isBlank()) { + translation.setDescription(description); + } + + translations.add(translation); + logger.debug( + "Created NotificationType translation: lang={}, displayName={}, groupTitle={}", + locale.toLanguageTag(), + displayName, + groupTitle); + } + + return translations; + } + @SuppressWarnings("unchecked") private List extractDeliveryChannels(CdsEvent event) { List deliveryChannels = new ArrayList<>(); diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalHandler.java index c673335..6e42d54 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalHandler.java @@ -5,8 +5,9 @@ import cds.gen.notificationproviderservice.NotificationProperties; import cds.gen.notificationproviderservice.Notifications; -import cds.gen.notificationproviderservice.Recipients; import com.sap.cds.notifications.assemblers.NotificationAssembler; +import com.sap.cds.notifications.helpers.I18nHelper; +import com.sap.cds.reflect.CdsEvent; import com.sap.cds.services.EventContext; import com.sap.cds.services.cds.ApplicationService; import com.sap.cds.services.handler.EventHandler; @@ -14,6 +15,9 @@ import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.runtime.CdsRuntime; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,9 +26,11 @@ public class LocalHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(LocalHandler.class); private final NotificationAssembler notificationBuilder; + private final I18nHelper i18nHelper; public LocalHandler(CdsRuntime runtime) { this.notificationBuilder = new NotificationAssembler(runtime); + this.i18nHelper = new I18nHelper(runtime); } @On(event = "*") @@ -35,43 +41,95 @@ public void postNotifications(EventContext context) { return; } - String eventName = results.get(0).eventName(); - logger.info( - "=== Processing {} notification(s) (LOCAL MODE) for event: {} ===", - results.size(), - eventName); - for (int i = 0; i < results.size(); i++) { - Notifications notification = results.get(i).notification(); + NotificationAssembler.NotificationBuildResult result = results.get(i); + Notifications notification = result.notification(); + CdsEvent event = result.event(); - logger.info("---------------------------------------------------------------"); - if (results.size() > 1) { - logger.info("Notification {}/{} (Local Mode - Not Sent to ANS)", i + 1, results.size()); - } else { - logger.info("Notification (Local Mode - Not Sent to ANS)"); - } - logger.info(" Event: {}", eventName); - logger.info(" NotificationTypeKey: {}", notification.getNotificationTypeKey()); - logger.info(" NotificationTypeVersion: {}", notification.getNotificationTypeVersion()); + Map props = + notification.getProperties().stream() + .collect( + Collectors.toMap( + NotificationProperties::getKey, + p -> p.getValue() != null ? p.getValue() : "")); + + String title = renderTemplate(event, "notification.template.title", props); + String subtitle = renderTemplate(event, "notification.template.subtitle", props); + String emailSubject = renderTemplate(event, "notification.template.email.subject", props); + String emailBodyText = renderTemplate(event, "notification.template.email.text", props); + String emailBodyHtml = renderTemplate(event, "notification.template.email.html", props); + + String recipientList = + notification.getRecipients().stream() + .map(r -> r.getRecipientId() != null ? r.getRecipientId() : r.getGlobalUserId()) + .collect(Collectors.joining(", ")); + + String priority = notification.getPriority() != null ? notification.getPriority() : "NEUTRAL"; + + String index = results.size() > 1 ? " (" + (i + 1) + "/" + results.size() + ")" : ""; + + logger.info("┌──────────────────────────────────────────────────────────────┐"); + logger.info("│ LOCAL NOTIFICATION{} (not sent to ANS)", index); + logger.info("├──────────────────────────────────────────────────────────────┤"); + logger.info("│ From: noreply@notifications.local"); + logger.info("│ To: {}", recipientList); logger.info( - " Priority: {}", - notification.getPriority() != null ? notification.getPriority() : "NEUTRAL"); - logger.info(" Recipients:"); - for (Recipients recipient : notification.getRecipients()) { - String recipientInfo = - recipient.getRecipientId() != null - ? recipient.getRecipientId() - : "GlobalUserId=" + recipient.getGlobalUserId(); - logger.info(" - {}", recipientInfo); + "│ Subject: {}", + emailSubject != null ? emailSubject : (title != null ? title : result.eventName())); + logger.info("│ Priority: {}", priority); + logger.info("├──────────────────────────────────────────────────────────────┤"); + if (subtitle != null) { + logger.info("│ {}", subtitle); + } + if (emailBodyText != null || emailBodyHtml != null) { + logger.info("│"); + logger.info("│ Email:"); + if (emailBodyText != null) { + logger.info("│ Body (text): {}", emailBodyText); + } + if (emailBodyHtml != null) { + String htmlContent = + emailBodyHtml.endsWith(".html") + ? i18nHelper.loadHtmlFromClasspath( + emailBodyHtml, i18nHelper.getI18nTexts(Locale.ENGLISH)) + : emailBodyHtml; + if (htmlContent != null) { + for (Map.Entry entry : props.entrySet()) { + htmlContent = htmlContent.replace("{{" + entry.getKey() + "}}", entry.getValue()); + } + int bodyStart = htmlContent.toLowerCase().indexOf(""); + String bodyContent = + (bodyStart >= 0 && bodyEnd > bodyStart) + ? htmlContent.substring(htmlContent.indexOf('>', bodyStart) + 1, bodyEnd) + : htmlContent; + logger.info( + "│ Body: {}", + bodyContent.replaceAll("<[^>]+>", " ").replaceAll("\\s+", " ").trim()); + } + } } - logger.info(" Properties ({}):", notification.getProperties().size()); - for (NotificationProperties prop : notification.getProperties()) { - logger.info(" - {}: {}", prop.getKey(), prop.getValue()); + logger.info("│"); + logger.info("│ Notification Type: {}", result.eventName()); + if (!props.isEmpty()) { + logger.info("│ Parameters:"); + props.forEach((key, value) -> logger.info("│ - {} = {}", key, value)); } - logger.info("---------------------------------------------------------------"); + logger.info("└──────────────────────────────────────────────────────────────┘"); } - logger.info("{} notification(s) logged successfully for event: {}", results.size(), eventName); context.setCompleted(); } + + private String renderTemplate(CdsEvent event, String annotationPath, Map props) { + Map i18nTexts = i18nHelper.getI18nTexts(Locale.ENGLISH); + String template = i18nHelper.resolveAnnotationValue(event, annotationPath, i18nTexts); + if (template == null) { + return null; + } + for (Map.Entry entry : props.entrySet()) { + template = template.replace("{{" + entry.getKey() + "}}", entry.getValue()); + } + return template; + } } diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTemplateAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTemplateAutoProvisionerHandler.java index fefeec7..7364d9b 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTemplateAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTemplateAutoProvisionerHandler.java @@ -11,7 +11,10 @@ import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.runtime.CdsRuntime; +import java.util.ArrayList; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,12 +36,8 @@ public LocalNotificationTemplateAutoProvisionerHandler(CdsRuntime runtime) { @On(event = ApplicationLifecycleService.EVENT_APPLICATION_PREPARED) public void onApplicationPrepared() { - logger.info( - "Auto-provisioning standalone NotificationTemplates from CDS annotations" - + " (LOCAL MODE - Logging Only)..."); try { provisionNotificationTemplates(); - logger.info("Standalone NotificationTemplate auto-provisioning completed (LOCAL MODE)"); } catch (Exception e) { logger.error("Standalone NotificationTemplate auto-provisioning failed", e); } @@ -49,66 +48,76 @@ private void provisionNotificationTemplates() { notificationTemplateBuilder.buildAllNotificationTemplates(); if (templates.isEmpty()) { - logger.info("No standalone NotificationTemplates found in CDS model (LOCAL MODE)"); + logger.info("No standalone NotificationTemplates found in CDS model"); return; } + logger.info("╔══════════════════════════════════════════════════════════════╗"); + logger.info("║ NOTIFICATION TEMPLATES (Local Mode — Not Sent to ANS) ║"); + logger.info("╚══════════════════════════════════════════════════════════════╝"); + for (NotificationTemplates template : templates) { logTemplate(template); } } private void logTemplate(NotificationTemplates template) { - logger.info("==============================================================="); - logger.info("Standalone NotificationTemplate (Local Mode - Not Sent to ANS)"); + int translationCount = + template.getTranslations() != null ? template.getTranslations().size() : 0; + + logger.info("┌──────────────────────────────────────────────────────────────┐"); logger.info( - """ - Key: {} - Visibility: {} - Translations: {}""", + "│ Template: '{}' | visibility={} | translations={}", template.getKey(), template.getVisibility(), - template.getTranslations() != null ? template.getTranslations().size() : 0); + translationCount); + + if (template.getPropertiesSchema() != null) { + logger.info("│ Properties: {}", extractPropertyNames(template.getPropertiesSchema())); + } + + logger.info("├──────────────────────────────────────────────────────────────┤"); if (template.getTranslations() != null) { for (Translations translation : template.getTranslations()) { - logger.info( - """ - - Language: {} - Syntax: {} - Title: {} - Preview: {} - Body: {} - Description: {}""", - translation.getLanguage(), - translation.getSyntax(), - translation.getTitle(), - translation.getPreview(), - translation.getBody(), - translation.getDescription()); + logger.info("│ [{}]", translation.getLanguage()); + logger.info("│ title: {}", translation.getTitle()); + logger.info("│ preview: {}", translation.getPreview()); + logger.info("│ body: {}", translation.getBody()); + logger.info("│ description: {}", translation.getDescription()); if (translation.getEmail() != null) { + logger.info("│ email subject: {}", translation.getEmail().getSubject()); logger.info( - """ - Email Subject: {} - Email BodyHtml: {} - Email BodyText: {}""", - translation.getEmail().getSubject(), - translation.getEmail().getBodyHtml() != null - ? translation - .getEmail() - .getBodyHtml() - .substring( - 0, Math.min(100, translation.getEmail().getBodyHtml().length())) - + "..." - : "null", - translation.getEmail().getBodyText()); + "│ email bodyText: {}", + translation.getEmail().getBodyText() != null + ? translation.getEmail().getBodyText() + : "(not set)"); + if (translation.getEmail().getBodyHtml() != null) { + logger.info( + "│ email bodyHtml: {}", + translation + .getEmail() + .getBodyHtml() + .replaceAll("<[^>]+>", " ") + .replaceAll("\\s+", " ") + .trim()); + } else { + logger.info("│ email bodyHtml: (not set)"); + } } + logger.info("│"); } } + logger.info("└──────────────────────────────────────────────────────────────┘"); + } - logger.info("==============================================================="); - logger.info( - "Standalone template '{}' logged (LOCAL MODE - not sent to ANS)", template.getKey()); + private String extractPropertyNames(String propertiesSchema) { + List names = new ArrayList<>(); + Matcher matcher = Pattern.compile("\"([^\"]+)\":\\{\"type\"").matcher(propertiesSchema); + while (matcher.find()) { + names.add(matcher.group(1)); + } + return names.isEmpty() ? "(none)" : String.join(", ", names); } } diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTypeAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTypeAutoProvisionerHandler.java index cf67bce..41c58a4 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTypeAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/LocalNotificationTypeAutoProvisionerHandler.java @@ -5,7 +5,7 @@ import cds.gen.notificationtypeproviderservice.DeliveryChannels; import cds.gen.notificationtypeproviderservice.NotificationTypes; -import cds.gen.notificationtypeproviderservice.Templates; +import cds.gen.notificationtypeproviderservice.Translations; import com.sap.cds.notifications.assemblers.NotificationTypeAssembler; import com.sap.cds.services.application.ApplicationLifecycleService; import com.sap.cds.services.handler.EventHandler; @@ -13,6 +13,7 @@ import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.runtime.CdsRuntime; import java.util.List; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,84 +31,70 @@ public LocalNotificationTypeAutoProvisionerHandler(CdsRuntime runtime) { @On(event = ApplicationLifecycleService.EVENT_APPLICATION_PREPARED) public void onApplicationPrepared() { - logger.info( - "Auto-provisioning NotificationTypes from CDS annotations (LOCAL MODE - Logging Only)..."); try { provisionNotificationTypes(); - logger.info("Auto-provisioning completed (LOCAL MODE)"); } catch (Exception e) { - logger.error("Auto-provisioning failed", e); + logger.error("NotificationType auto-provisioning failed", e); } } private void provisionNotificationTypes() { List notificationTypes = notificationTypeBuilder.buildAllNotificationTypes(); + if (notificationTypes.isEmpty()) { + logger.info("No NotificationTypes found in CDS model"); + return; + } + + logger.info("╔══════════════════════════════════════════════════════════════╗"); + logger.info("║ NOTIFICATION TYPES (Local Mode — Not Sent to ANS) ║"); + logger.info("╚══════════════════════════════════════════════════════════════╝"); + for (NotificationTypes notificationType : notificationTypes) { logNotificationType(notificationType); } } private void logNotificationType(NotificationTypes notificationType) { - // Log to console instead of sending to ANS - System.out.println("\n==============================================================="); - System.out.println("NotificationType (Local Mode - Not Sent to ANS)"); - System.out.println(" Key: " + notificationType.getNotificationTypeKey()); - System.out.println(" Version: " + notificationType.getNotificationTypeVersion()); - System.out.println( - " Templates (" - + (notificationType.getTemplates() != null ? notificationType.getTemplates().size() : 0) - + "):"); + int translationCount = + notificationType.getTranslations() != null ? notificationType.getTranslations().size() : 0; + int channelCount = + notificationType.getDeliveryChannels() != null + ? notificationType.getDeliveryChannels().size() + : 0; - if (notificationType.getTemplates() != null) { - for (Templates template : notificationType.getTemplates()) { - System.out.println( - " - Language: " - + template.getLanguage() - + "\n" - + " Public Title: " - + template.getTemplatePublic() - + "\n" - + " Sensitive Title: " - + template.getTemplateSensitive() - + "\n" - + " Grouped Title: " - + template.getTemplateGrouped() - + "\n" - + " Subtitle: " - + template.getSubtitle() - + "\n" - + " Email Subject: " - + template.getEmailSubject() - + "\n" - + " Email HTML: " - + (template.getEmailHtml() != null - ? template - .getEmailHtml() - .substring(0, Math.min(100, template.getEmailHtml().length())) - + "..." - : "null")); - } + logger.info("┌──────────────────────────────────────────────────────────────┐"); + logger.info( + "│ Type: '{}' | translations={} | channels={}", + notificationType.getNotificationTypeKey(), + translationCount, + channelCount); + logger.info("├──────────────────────────────────────────────────────────────┤"); + + if (notificationType.getTranslations() != null + && !notificationType.getTranslations().isEmpty()) { + List translations = notificationType.getTranslations(); + String languages = + translations.stream().map(Translations::getLanguage).collect(Collectors.joining(", ")); + Translations display = + translations.stream() + .filter(t -> "en".equals(t.getLanguage())) + .findFirst() + .orElse(translations.get(0)); + logger.info("│ displayName: {} [{}]", display.getDisplayName(), languages); + logger.info("│ groupTitle: {} [{}]", display.getGroupTitle(), languages); } if (notificationType.getDeliveryChannels() != null) { - System.out.println( - " Delivery Channels (" + notificationType.getDeliveryChannels().size() + "):"); + logger.info("│ Delivery Channels:"); for (DeliveryChannels channel : notificationType.getDeliveryChannels()) { - System.out.println( - " - Type: " - + channel.getType() - + ", Enabled: " - + channel.getEnabled() - + ", DefaultPreference: " - + channel.getDefaultPreference()); + logger.info( + "│ - {} (enabled={}, defaultPreference={})", + channel.getType(), + channel.getEnabled(), + channel.getDefaultPreference()); } } - - System.out.println("===============================================================\n"); - - logger.info( - "NotificationType '{}' logged (LOCAL MODE - not sent to ANS)", - notificationType.getNotificationTypeKey()); + logger.info("└──────────────────────────────────────────────────────────────┘"); } } diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java index 9e1ff12..8581747 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTemplateAutoProvisionerHandler.java @@ -7,9 +7,9 @@ import cds.gen.notificationtemplateproviderservice.NotificationTemplates; import cds.gen.notificationtemplateproviderservice.NotificationTemplates_; import com.sap.cds.notifications.assemblers.NotificationTemplateAssembler; +import com.sap.cds.ql.Delete; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; -import com.sap.cds.ql.Update; import com.sap.cds.services.application.ApplicationLifecycleService; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.On; @@ -105,24 +105,25 @@ private void createTemplate(NotificationTemplates template) { String errorMsg = e.getMessage() != null ? e.getMessage() : ""; if (errorMsg.contains("409")) { - // Race condition: template was created between our GET and INSERT - logger.debug( - "Standalone template '{}' was created concurrently (409). Attempting update...", + // Template was created concurrently (race condition) — skip to avoid an + // update loop (updateTemplate would DELETE+CREATE, and if DELETE keeps + // failing the CREATE would 409 again, causing an infinite loop). + logger.warn( + "Standalone template '{}' already exists (409). Skipping to avoid update loop.", template.getKey()); - updateTemplate(template); return; } if (errorMsg.contains("400")) { logger.error( "ANS rejected standalone template '{}' with 400 Bad Request. " - + "Check that all required fields (Title in Translation) are set. Error: {}", + + "Check that all required fields are set and field values do not exceed ANS length limits. Error: {}", template.getKey(), errorMsg); throw new IllegalStateException( String.format( "ANS rejected standalone template '%s' with 400 Bad Request. " - + "Ensure @notification.template annotations are properly configured. Error: %s", + + "Check that all required fields are set and field values do not exceed ANS length limits. Error: %s", template.getKey(), errorMsg), e); } @@ -133,31 +134,20 @@ private void createTemplate(NotificationTemplates template) { } private void updateTemplate(NotificationTemplates template) { - logger.debug("Updating standalone template '{}'", template.getKey()); + logger.debug("Updating standalone template '{}' (delete + re-create)", template.getKey()); try { notificationTemplateProviderService.run( - Update.entity(NotificationTemplates_.CDS_NAME).data(template)); + Delete.from(NotificationTemplates_.class).where(nt -> nt.Key().eq(template.getKey()))); - logger.debug( - "Standalone NotificationTemplate '{}' updated in ANS successfully", template.getKey()); + logger.debug("Deleted existing standalone template '{}'", template.getKey()); } catch (Exception e) { - String errorMsg = e.getMessage() != null ? e.getMessage() : ""; - - if (errorMsg.contains("400")) { - logger.error( - "ANS rejected standalone template update '{}' with 400 Bad Request. Error: {}", - template.getKey(), - errorMsg); - throw new IllegalStateException( - String.format( - "ANS rejected standalone template update '%s' with 400 Bad Request. Error: %s", - template.getKey(), errorMsg), - e); - } - - logger.error("Failed to update standalone template '{}' in ANS", template.getKey(), e); - throw e; + logger.warn( + "Could not delete existing standalone template '{}': {}", + template.getKey(), + e.getMessage()); } + + createTemplate(template); } } diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTypeAutoProvisionerHandler.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTypeAutoProvisionerHandler.java index 52bffab..044a6c9 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTypeAutoProvisionerHandler.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/handlers/NotificationTypeAutoProvisionerHandler.java @@ -137,15 +137,14 @@ private void createNotificationType(NotificationTypes notificationType) { if (errorMsg.contains("400")) { logger.error( "ANS rejected NotificationType '{}' with 400 Bad Request. " - + "This usually means required fields are missing or invalid. " - + "Check that all required fields (publicTitle, title, groupedTitle, subtitle) are set. " + + "Check that all required fields are set and field values do not exceed ANS length limits. " + "Error: {}", notificationType.getNotificationTypeKey(), errorMsg); throw new IllegalStateException( String.format( "ANS rejected NotificationType '%s' with 400 Bad Request. " - + "Ensure all required template fields are properly configured in your CDS model and i18n files. Error: %s", + + "Check that all required fields are set and field values do not exceed ANS length limits. Error: %s", notificationType.getNotificationTypeKey(), errorMsg), e); } @@ -160,13 +159,13 @@ private void createNotificationType(NotificationTypes notificationType) { private void updateNotificationType( NotificationTypes notificationType, String notificationTypeId) { - notificationType.setNotificationTypeId(notificationTypeId); logger.debug( - "Updating NotificationType '{}' (id={})", + "Updating NotificationType '{}' (id={}) via PATCH", notificationType.getNotificationTypeKey(), notificationTypeId); try { + notificationType.setNotificationTypeId(notificationTypeId); notificationTypeProviderService.run( Update.entity(NotificationTypes_.CDS_NAME).data(notificationType)); @@ -175,23 +174,20 @@ private void updateNotificationType( notificationType.getNotificationTypeKey()); } catch (Exception e) { String errorMsg = e.getMessage() != null ? e.getMessage() : ""; - if (errorMsg.contains("400")) { logger.error( "ANS rejected NotificationType update '{}' with 400 Bad Request. " - + "This usually means required fields are missing or invalid. " - + "Check that all required fields (publicTitle, title, groupedTitle, subtitle) are set. " + + "Check that all required fields are set and field values do not exceed ANS length limits. " + "Error: {}", notificationType.getNotificationTypeKey(), errorMsg); throw new IllegalStateException( String.format( "ANS rejected NotificationType update '%s' with 400 Bad Request. " - + "Ensure all required template fields are properly configured in your CDS model and i18n files. Error: %s", + + "Check that all required fields are set and field values do not exceed ANS length limits. Error: %s", notificationType.getNotificationTypeKey(), errorMsg), e); } - logger.error( "Failed to update NotificationType '{}' (id={})", notificationType.getNotificationTypeKey(), diff --git a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/I18nHelper.java b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/I18nHelper.java index ab707a7..727d7d0 100644 --- a/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/I18nHelper.java +++ b/cds-feature-notifications/src/main/java/com/sap/cds/notifications/helpers/I18nHelper.java @@ -54,6 +54,48 @@ public Set getAvailableLocales() { return Set.of(Locale.ENGLISH); } + /** + * Get locales that have actual notification translations for the given event. Compares each + * locale's resolved {@code notification.template.title} against the English value. If they + * differ, the locale has a real translation and is included. English is always included. + * + *

This filters out locales that only exist because of {@code @sap/cds/common} framework + * translations (e.g. "Created By" in 37 languages) but have no app-specific notification texts. + */ + public Set getAvailableLocalesForEvent(CdsEvent event) { + Set allLocales = getAvailableLocales(); + Map enTexts = getI18nTexts(Locale.ENGLISH); + String enTitle = resolveAnnotationValue(event, "notification.template.title", enTexts); + + Set filtered = new LinkedHashSet<>(); + for (Locale locale : allLocales) { + // Locale.ROOT produces "und" (undefined) in BCP-47, which represents the default + // i18n.properties file. Map it to English since the content is the same and "und" + // is not a valid ANS language code. + if (locale.getLanguage().isEmpty()) { + filtered.add(Locale.ENGLISH); + continue; + } + if ("en".equals(locale.getLanguage())) { + filtered.add(locale); + continue; + } + Map localeTexts = getI18nTexts(locale); + String localeTitle = + resolveAnnotationValue(event, "notification.template.title", localeTexts); + if (!Objects.equals(enTitle, localeTitle)) { + filtered.add(locale); + } + } + + logger.debug( + "Filtered locales for '{}': {} (skipped {} without notification translations)", + event.getName(), + filtered, + allLocales.size() - filtered.size()); + return filtered; + } + /** * Get i18n texts for a given locale from the EdmxI18nProvider. The provider reads from * edmx/_i18n/i18n.json which is generated by the CDS compiler from _i18n/i18n*.properties files diff --git a/cds-feature-notifications/src/test/java/com/sap/cds/notifications/builders/NotificationBuilderTest.java b/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationAssemblerTest.java similarity index 98% rename from cds-feature-notifications/src/test/java/com/sap/cds/notifications/builders/NotificationBuilderTest.java rename to cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationAssemblerTest.java index 32dc04f..9d40c60 100644 --- a/cds-feature-notifications/src/test/java/com/sap/cds/notifications/builders/NotificationBuilderTest.java +++ b/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationAssemblerTest.java @@ -1,12 +1,11 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. */ -package com.sap.cds.notifications.builders; +package com.sap.cds.notifications.assemblers; import static org.junit.jupiter.api.Assertions.*; import cds.gen.notificationproviderservice.Recipients; -import com.sap.cds.notifications.assemblers.NotificationAssembler; import com.sap.cds.ql.CQL; import com.sap.cds.ql.cqn.CqnComparisonPredicate; import com.sap.cds.ql.cqn.CqnContainmentTest; @@ -22,7 +21,7 @@ * Unit tests for recipient auto-detection logic in {@link NotificationAssembler}. Covers isUUID, * isEmail, and createRecipientFromId including edge cases (malformed, null, empty). */ -class NotificationBuilderTest { +class NotificationAssemblerTest { // ── isUUID ──────────────────────────────────────────────────────────────── diff --git a/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java b/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java new file mode 100644 index 0000000..c10a1e8 --- /dev/null +++ b/cds-feature-notifications/src/test/java/com/sap/cds/notifications/assemblers/NotificationTypeAssemblerTest.java @@ -0,0 +1,127 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-notifications contributors. + */ +package com.sap.cds.notifications.assemblers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import cds.gen.notificationtypeproviderservice.NotificationTypes; +import cds.gen.notificationtypeproviderservice.Translations; +import com.sap.cds.adapter.edmx.EdmxI18nProvider; +import com.sap.cds.reflect.CdsAnnotation; +import com.sap.cds.reflect.CdsEvent; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.runtime.CdsRuntime; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class NotificationTypeAssemblerTest { + + private CdsRuntime runtime; + private EdmxI18nProvider i18nProvider; + + @BeforeEach + void setUp() { + runtime = mock(CdsRuntime.class); + i18nProvider = mock(EdmxI18nProvider.class); + when(runtime.getProvider(EdmxI18nProvider.class)).thenReturn(i18nProvider); + when(i18nProvider.getLocales()).thenReturn(Set.of(Locale.ENGLISH)); + when(i18nProvider.getTexts(Locale.ENGLISH)).thenReturn(Collections.emptyMap()); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private CdsEvent mockEvent(String name, String title, String groupedTitle) { + CdsEvent event = mock(CdsEvent.class); + when(event.getName()).thenReturn(name); + + CdsAnnotation titleAnno = mock(CdsAnnotation.class); + when(titleAnno.getValue()).thenReturn(title != null ? title : ""); + // @notification.template.title is used to filter events in buildAllNotificationTypes + when(event.findAnnotation("notification.template.title")) + .thenReturn(Optional.of(mock(CdsAnnotation.class))); + // @notification.template.publicTitle is used as DisplayName in Translations + when(event.findAnnotation("notification.template.publicTitle")) + .thenReturn(title != null ? Optional.of(titleAnno) : Optional.empty()); + + if (groupedTitle != null) { + CdsAnnotation groupAnno = mock(CdsAnnotation.class); + when(groupAnno.getValue()).thenReturn(groupedTitle); + when(event.findAnnotation("notification.template.groupedTitle")) + .thenReturn(Optional.of(groupAnno)); + } else { + when(event.findAnnotation("notification.template.groupedTitle")).thenReturn(Optional.empty()); + } + + when(event.findAnnotation("description")).thenReturn(Optional.empty()); + when(event.findAnnotation("notification.deliveryChannels")).thenReturn(Optional.empty()); + return event; + } + + private List build(CdsEvent event) { + CdsModel model = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(model); + when(model.events()).thenReturn(Stream.of(event)); + return new NotificationTypeAssembler(runtime).buildAllNotificationTypes(); + } + + @Test + void happyPath_translationBuiltCorrectly() { + CdsEvent event = mockEvent("BookOrdered", "New book order", "{{_group_count}} new orders"); + List result = build(event); + + assertEquals(1, result.size()); + Translations t = result.get(0).getTranslations().get(0); + assertEquals("New book order", t.getDisplayName()); + assertEquals("{{_group_count}} new orders", t.getGroupTitle()); + assertEquals("MUSTACHE", t.getSyntax()); + assertEquals("en", t.getLanguage()); + } + + @Test + void missingGroupedTitle_throwsIllegalStateException() { + CdsEvent event = mockEvent("BookOrdered", "New book order", null); + assertThrows(IllegalStateException.class, () -> build(event)); + } + + @Test + void missingPublicTitle_throwsIllegalStateException() { + CdsEvent event = mockEvent("BookOrdered", null, "{{_group_count}} new orders"); + assertThrows(IllegalStateException.class, () -> build(event)); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void description_setWhenPresent() { + CdsEvent event = mockEvent("BookOrdered", "New book order", "{{_group_count}} new orders"); + CdsAnnotation descAnno = mock(CdsAnnotation.class); + when(descAnno.getValue()).thenReturn("Handles book orders"); + when(event.findAnnotation("description")).thenReturn(Optional.of(descAnno)); + + Translations t = build(event).get(0).getTranslations().get(0); + assertEquals("Handles book orders", t.getDescription()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void deliveryChannels_parsedCorrectly() { + CdsEvent event = mockEvent("BookOrdered", "New book order", "{{_group_count}} new orders"); + CdsAnnotation channelsAnno = mock(CdsAnnotation.class); + when(channelsAnno.getValue()) + .thenReturn(List.of(Map.of("channel", "MAIL", "enabled", true, "defaultPreference", true))); + when(event.findAnnotation("notification.deliveryChannels")) + .thenReturn(Optional.of(channelsAnno)); + + List result = build(event); + assertNotNull(result.get(0).getDeliveryChannels()); + assertEquals(1, result.get(0).getDeliveryChannels().size()); + assertEquals("MAIL", result.get(0).getDeliveryChannels().get(0).getType()); + } +} diff --git a/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTemplateProviderServiceMockHandler.java b/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTemplateProviderServiceMockHandler.java index 6e3ba8c..fc44ebc 100644 --- a/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTemplateProviderServiceMockHandler.java +++ b/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTemplateProviderServiceMockHandler.java @@ -6,6 +6,7 @@ import cds.gen.notificationtemplateproviderservice.NotificationTemplates; import cds.gen.notificationtemplateproviderservice.NotificationTemplates_; import com.sap.cds.services.cds.CdsCreateEventContext; +import com.sap.cds.services.cds.CdsDeleteEventContext; import com.sap.cds.services.cds.CdsReadEventContext; import com.sap.cds.services.cds.CdsUpdateEventContext; import com.sap.cds.services.cds.CqnService; @@ -38,6 +39,9 @@ public class NotificationTemplateProviderServiceMockHandler implements EventHand // Tracks how many times each template key has been updated private static final Map updateCountByKey = new ConcurrentHashMap<>(); + // Tracks how many times each template key has been deleted + private static final Map deleteCountByKey = new ConcurrentHashMap<>(); + @On(event = CqnService.EVENT_CREATE, entity = NotificationTemplates_.CDS_NAME) public void interceptCreate(CdsCreateEventContext context) { logger.debug( @@ -76,6 +80,36 @@ public void interceptCreate(CdsCreateEventContext context) { context.setCompleted(); } + @On(event = CqnService.EVENT_DELETE, entity = NotificationTemplates_.CDS_NAME) + public void interceptDelete(CdsDeleteEventContext context) { + logger.debug("MockHandler intercepting NotificationTemplates DELETE"); + + context + .getCqn() + .where() + .ifPresent( + where -> { + String whereStr = where.toString(); + templateStore + .entrySet() + .removeIf( + entry -> { + boolean matches = whereStr.contains(entry.getKey()); + if (matches) { + deleteCountByKey + .computeIfAbsent(entry.getKey(), k -> new AtomicInteger(0)) + .incrementAndGet(); + logger.debug( + "MockHandler deleted notification template: Key={}", entry.getKey()); + } + return matches; + }); + }); + + context.setResult(Collections.emptyList()); + context.setCompleted(); + } + @On(event = CqnService.EVENT_READ, entity = NotificationTemplates_.CDS_NAME) public void interceptRead(CdsReadEventContext context) { logger.debug("MockHandler intercepting NotificationTemplates READ"); @@ -145,6 +179,7 @@ public static List getAllTemplates() { public static void clearAllTemplates() { templateStore.clear(); updateCountByKey.clear(); + deleteCountByKey.clear(); logger.debug("Mock NotificationTemplateProviderService: Cleared all templates"); } @@ -177,4 +212,9 @@ public static int getUpdateCount(String key) { AtomicInteger count = updateCountByKey.get(key); return count != null ? count.get() : 0; } + + public static int getDeleteCount(String key) { + AtomicInteger count = deleteCountByKey.get(key); + return count != null ? count.get() : 0; + } } diff --git a/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTypeProviderServiceMockHandler.java b/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTypeProviderServiceMockHandler.java index c6a9cda..811b6e1 100644 --- a/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTypeProviderServiceMockHandler.java +++ b/sample-app/srv/src/test/java/customer/sample_app/handlers/mock/NotificationTypeProviderServiceMockHandler.java @@ -6,6 +6,7 @@ import cds.gen.notificationtypeproviderservice.NotificationTypes; import cds.gen.notificationtypeproviderservice.NotificationTypes_; import com.sap.cds.services.cds.CdsCreateEventContext; +import com.sap.cds.services.cds.CdsDeleteEventContext; import com.sap.cds.services.cds.CdsReadEventContext; import com.sap.cds.services.cds.CdsUpdateEventContext; import com.sap.cds.services.cds.CqnService; @@ -43,6 +44,9 @@ public class NotificationTypeProviderServiceMockHandler implements EventHandler // Tracks how many times each NotificationTypeKey has been updated private static final Map updateCountByKey = new ConcurrentHashMap<>(); + // Tracks how many times each NotificationTypeKey has been deleted + private static final Map deleteCountByKey = new ConcurrentHashMap<>(); + @On(event = CqnService.EVENT_CREATE, entity = NotificationTypes_.CDS_NAME) public void interceptCreate(CdsCreateEventContext context) { logger.debug( @@ -93,6 +97,41 @@ public void interceptCreate(CdsCreateEventContext context) { context.setCompleted(); } + @On(event = CqnService.EVENT_DELETE, entity = NotificationTypes_.CDS_NAME) + public void interceptDelete(CdsDeleteEventContext context) { + logger.debug("MockHandler intercepting NotificationTypes DELETE"); + + context + .getCqn() + .where() + .ifPresent( + where -> { + // Extract the ID from the WHERE clause and delete from store + notificationTypeStore + .entrySet() + .removeIf( + entry -> { + NotificationTypes nt = entry.getValue(); + String id = nt.getNotificationTypeId(); + boolean matches = where.toString().contains(id != null ? id : ""); + if (matches) { + String key = nt.getNotificationTypeKey(); + String keyVersion = key + ":" + nt.getNotificationTypeVersion(); + notificationTypeByKeyVersion.remove(keyVersion); + deleteCountByKey + .computeIfAbsent(key, k -> new AtomicInteger(0)) + .incrementAndGet(); + logger.debug( + "MockHandler deleted notification type: Key={}, ID={}", key, id); + } + return matches; + }); + }); + + context.setResult(Collections.emptyList()); + context.setCompleted(); + } + @On(event = CqnService.EVENT_READ, entity = NotificationTypes_.CDS_NAME) public void interceptRead(CdsReadEventContext context) { logger.debug("MockHandler intercepting NotificationTypes READ"); @@ -180,6 +219,7 @@ public static void clearAllNotificationTypes() { notificationTypeStore.clear(); notificationTypeByKeyVersion.clear(); updateCountByKey.clear(); + deleteCountByKey.clear(); logger.debug("Mock NotificationTypeProviderService: Cleared all notification types"); } @@ -226,6 +266,11 @@ public static int getUpdateCount(String notificationTypeKey) { return count != null ? count.get() : 0; } + public static int getDeleteCount(String notificationTypeKey) { + AtomicInteger count = deleteCountByKey.get(notificationTypeKey); + return count != null ? count.get() : 0; + } + /** Gets the total number of updates across all notification types. */ public static int getTotalUpdateCount() { return updateCountByKey.values().stream().mapToInt(AtomicInteger::get).sum(); diff --git a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java index 26d2ced..900990d 100644 --- a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java +++ b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTemplateProvisioningTest.java @@ -115,27 +115,25 @@ void testCertificateExpirationTemplateStructure() { } // ────────────────────────────────────────────────────────────── - // Test 3: Visibility defaults to PRIVATE (not set) when no annotation + // Test 3: Visibility defaults to PRIVATE when no annotation // ────────────────────────────────────────────────────────────── @Test void testVisibilityDefaultsToPrivate() { LOG.debug("=========================================="); - LOG.debug( - "Test: Templates without @notification.customizable should have null visibility (PRIVATE)"); + LOG.debug("Test: Templates without @notification.customizable should have PRIVATE visibility"); LOG.debug("=========================================="); - // SystemMaintenance has no @notification.customizable → visibility should be null (ANS defaults - // PRIVATE) + // SystemMaintenance has no @notification.customizable → visibility should be PRIVATE NotificationTemplates template = NotificationTemplateProviderServiceMockHandler.getTemplateByKey("SystemMaintenance"); assertNotNull(template, "SystemMaintenance template should be provisioned"); - assertNull( + assertEquals( + "PRIVATE", template.getVisibility(), - "Template without @notification.customizable should have null visibility (ANS defaults to PRIVATE)"); + "Template without @notification.customizable should have PRIVATE visibility"); - LOG.debug( - "SystemMaintenance visibility: {} (null = ANS default PRIVATE)", template.getVisibility()); + LOG.debug("SystemMaintenance visibility: {}", template.getVisibility()); } // ────────────────────────────────────────────────────────────── @@ -341,12 +339,7 @@ void testTranslationMetadata() { // Event = event name assertEquals("CertificateExpiration", en.getEvent(), "Event should be the event name"); - // DisplayName = event name - assertEquals( - "CertificateExpiration", en.getDisplayName(), "DisplayName should be the event name"); - - LOG.debug( - "Source={}, Event={}, DisplayName={}", en.getSource(), en.getEvent(), en.getDisplayName()); + LOG.debug("Source={}, Event={}", en.getSource(), en.getEvent()); } // ────────────────────────────────────────────────────────────── @@ -555,29 +548,29 @@ private void assertNoUnresolvedI18n(String value, String fieldName, String lang) @Test void testReProvisioningUpdatesExistingTemplates() { LOG.debug("=========================================="); - LOG.debug("Test: Re-provisioning should UPDATE existing templates"); + LOG.debug("Test: Re-provisioning should DELETE and recreate existing templates"); LOG.debug("=========================================="); int countBefore = NotificationTemplateProviderServiceMockHandler.getTemplateCount(); assertTrue(countBefore > 0, "Templates should already be provisioned at startup"); - int updatesBefore = - NotificationTemplateProviderServiceMockHandler.getUpdateCount("CertificateExpiration"); + int deletesBefore = + NotificationTemplateProviderServiceMockHandler.getDeleteCount("CertificateExpiration"); createProvisioner().onApplicationPrepared(); - int updatesAfter = - NotificationTemplateProviderServiceMockHandler.getUpdateCount("CertificateExpiration"); + int deletesAfter = + NotificationTemplateProviderServiceMockHandler.getDeleteCount("CertificateExpiration"); assertEquals( - updatesBefore + 1, - updatesAfter, - "CertificateExpiration template should have been updated once during re-provisioning"); + deletesBefore + 1, + deletesAfter, + "CertificateExpiration template should have been deleted once during re-provisioning"); assertEquals( countBefore, NotificationTemplateProviderServiceMockHandler.getTemplateCount(), "Template count should remain the same after re-provisioning"); - LOG.debug("Re-provisioning triggered UPDATE for existing templates"); + LOG.debug("Re-provisioning triggered DELETE+CREATE for existing templates"); } } diff --git a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java index cf1e961..fd352ff 100644 --- a/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java +++ b/sample-app/srv/src/test/java/customer/sample_app/integration/NotificationTypeProvisioningTest.java @@ -86,13 +86,13 @@ void testEachNotificationTypeHasUniqueId() { } // ────────────────────────────────────────────────────────────── - // Test 2: Re-provisioning keeps IDs stable (UPDATE, not INSERT) + // Test 2: Re-provisioning uses PATCH (IDs remain stable) // ────────────────────────────────────────────────────────────── @Test void testReProvisioningUpdatesEachTypeCorrectly() { LOG.debug("=========================================="); - LOG.debug("Test: Re-provisioning should update each type without changing IDs"); + LOG.debug("Test: Re-provisioning should PATCH each existing type without changing IDs"); LOG.debug("=========================================="); Map idsBefore = new HashMap<>(); @@ -114,30 +114,30 @@ void testReProvisioningUpdatesEachTypeCorrectly() { assertEquals( idsBefore, idsAfter, - "NotificationTypeIds should remain the same after re-provisioning (UPDATE, not INSERT)"); + "NotificationTypeIds should remain the same after re-provisioning (PATCH, not INSERT)"); LOG.debug("Re-provisioning verified — all types retain their IDs"); } // ────────────────────────────────────────────────────────────── - // Test 3: Update count matches expected types + // Test 3: Re-provisioning updates all types via PATCH // ────────────────────────────────────────────────────────────── @Test void testReProvisioningUpdatesAllTypes() { LOG.debug("=========================================="); - LOG.debug("Test: Re-provisioning should trigger UPDATE for each existing type"); + LOG.debug("Test: Re-provisioning should trigger PATCH for each existing type"); LOG.debug("=========================================="); - Map countsBefore = new HashMap<>(); + Map updatesBefore = new HashMap<>(); for (String key : EXPECTED_KEYS) { - countsBefore.put(key, NotificationTypeProviderServiceMockHandler.getUpdateCount(key)); + updatesBefore.put(key, NotificationTypeProviderServiceMockHandler.getUpdateCount(key)); } createProvisioner().onApplicationPrepared(); for (String key : EXPECTED_KEYS) { - int before = countsBefore.get(key); + int before = updatesBefore.get(key); int after = NotificationTypeProviderServiceMockHandler.getUpdateCount(key); assertEquals( @@ -153,6 +153,6 @@ void testReProvisioningUpdatesAllTypes() { LOG.debug("Type '{}': update count {} → {}", key, before, after); } - LOG.debug("All {} types were updated during re-provisioning", EXPECTED_KEYS.size()); + LOG.debug("All {} types were updated via PATCH during re-provisioning", EXPECTED_KEYS.size()); } }