Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).

- Initial version of the project
- CAP Java plugin for integrating SAP Alert Notification Service (ANS) with CAP Java applications
- `@notification` annotation on CDS events to define notification types with title, subtitle, and grouped title templates
- `@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
- i18n support via `{i18n>KEY}` placeholders in all template fields with automatic language detection per recipient
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@

import cds.gen.notificationtypeproviderservice.DeliveryChannels;
import cds.gen.notificationtypeproviderservice.NotificationTypes;
import cds.gen.notificationtypeproviderservice.Templates;
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;
Expand All @@ -21,11 +19,9 @@ 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. */
Expand All @@ -48,22 +44,6 @@ private Optional<NotificationTypes> extractNotificationTypeFromEvent(CdsEvent ev
nt.setNotificationTypeKey(key);
nt.setNotificationTypeVersion("1");

Set<Locale> locales = i18nHelper.getAvailableLocales();
logger.debug("Creating templates for {} discovered i18n locales", locales.size());

List<Templates> templates = new ArrayList<>();

// Create template for each discovered locale using EdmxI18nProvider
for (Locale locale : locales) {
String lang = locale.toLanguageTag();
Map<String, String> i18nTexts = i18nHelper.getI18nTexts(locale);
Templates template = createTemplate(event, lang, i18nTexts);
templates.add(template);
logger.debug("Created template for language: {} with {} i18n texts", lang, i18nTexts.size());
}

nt.setTemplates(templates);

// Extract delivery channels
List<DeliveryChannels> deliveryChannels = extractDeliveryChannels(event);
if (!deliveryChannels.isEmpty()) {
Expand All @@ -74,83 +54,6 @@ private Optional<NotificationTypes> extractNotificationTypeFromEvent(CdsEvent ev
return Optional.of(nt);
}

private Templates createTemplate(CdsEvent event, String lang, Map<String, String> i18nTexts) {
Templates template = Struct.create(Templates.class);
template.setLanguage(lang);

// Validate and set required fields
String publicTitle =
i18nHelper.resolveAnnotationValue(event, "notification.template.publicTitle", i18nTexts);
if (publicTitle == null || publicTitle.trim().isEmpty()) {
throw new IllegalStateException(
String.format(
"Missing required annotation: @notification.template.publicTitle for event '%s'. ANS"
+ " requires publicTitle to be set. Please add 'publicTitle:"
+ " '{i18n>TEMPLATE_PUBLIC}'' to your @notification.template annotation and"
+ " define 'TEMPLATE_PUBLIC' in your i18n properties files.",
event.getName()));
}
template.setTemplatePublic(publicTitle);

String sensitiveTitle = i18nHelper.resolveAnnotationValue(event, "notification.template.title", i18nTexts);
if (sensitiveTitle == null || sensitiveTitle.trim().isEmpty()) {
throw new IllegalStateException(
String.format(
"Missing required annotation: @notification.template.title for event '%s'. ANS"
+ " requires title (sensitive template) to be set. Please add 'title:"
+ " '{i18n>TEMPLATE_SENSITIVE}'' to your @notification.template annotation and"
+ " define 'TEMPLATE_SENSITIVE' in your i18n properties files.",
event.getName()));
}
template.setTemplateSensitive(sensitiveTitle);

String groupedTitle =
i18nHelper.resolveAnnotationValue(event, "notification.template.groupedTitle", i18nTexts);
if (groupedTitle == null || groupedTitle.trim().isEmpty()) {
throw new IllegalStateException(
String.format(
"Missing required annotation: @notification.template.groupedTitle for event '%s'. ANS"
+ " requires groupedTitle to be set. Please add 'groupedTitle:"
+ " '{i18n>TEMPLATE_GROUPED}'' to your @notification annotation and define"
+ " 'TEMPLATE_GROUPED' in your i18n properties files.",
event.getName()));
}
template.setTemplateGrouped(groupedTitle);

template.setDescription(i18nHelper.resolveAnnotationValue(event, "description", i18nTexts));

String subtitle = i18nHelper.resolveAnnotationValue(event, "notification.template.subtitle", i18nTexts);
if (subtitle == null || subtitle.trim().isEmpty()) {
throw new IllegalStateException(
String.format(
"Missing required annotation: @notification.template.subtitle for event '%s'. ANS"
+ " requires subtitle to be set. Please add 'subtitle: '{i18n>SUBTITLE}'' to your"
+ " @notification.template annotation and define 'SUBTITLE' in your i18n"
+ " properties files.",
event.getName()));
}
template.setSubtitle(subtitle);

template.setEmailSubject(
i18nHelper.resolveAnnotationValue(event, "notification.template.email.subject", i18nTexts));

// Resolve email HTML - if it's a file path, load the file content
String emailHtmlValue =
i18nHelper.resolveAnnotationValue(event, "notification.template.email.html", i18nTexts);
if (emailHtmlValue != null && emailHtmlValue.endsWith(".html")) {
String htmlContent = i18nHelper.loadHtmlFromClasspath(emailHtmlValue, i18nTexts);
template.setEmailHtml(htmlContent);
logger.debug("Loaded HTML template from file: {}", emailHtmlValue);
} else {
template.setEmailHtml(emailHtmlValue);
}

template.setEmailText(i18nHelper.resolveAnnotationValue(event, "notificationType.emailText", i18nTexts));
template.setTemplateLanguage("MUSTACHE");

return template;
}

@SuppressWarnings("unchecked")
private List<DeliveryChannels> extractDeliveryChannels(CdsEvent event) {
List<DeliveryChannels> deliveryChannels = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,10 @@

import cds.gen.notificationtypeproviderservice.NotificationTypeProviderService;
import cds.gen.notificationtypeproviderservice.NotificationTypes;
import cds.gen.notificationtypeproviderservice.Templates;
import com.sap.cds.notifications.handlers.NotificationTypeAutoProvisionerHandler;
import com.sap.cds.services.runtime.CdsRuntime;
import customer.sample_app.handlers.mock.NotificationTypeProviderServiceMockHandler;
import java.util.*;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -24,8 +22,7 @@
* Tests for notification type auto-provisioning (SELECT → INSERT/UPDATE flow).
*
* <p>These tests specifically verify that the provisioner correctly handles the SELECT-all → UPDATE
* (existing) / INSERT (new) flow and that each notification type gets its own unique ID and retains
* its own data after updates.
* (existing) / INSERT (new) flow and that each notification type gets its own unique ID.
*/
@SpringBootTest
@ActiveProfiles("test")
Expand All @@ -46,10 +43,6 @@ public class NotificationTypeProvisioningTest {
"ServerIncident",
"DeploymentNotification");

/**
* Creates a fresh provisioner handler (same as production code does). The handler is not a Spring
* bean — it's created via new() in NotificationServiceConfiguration.
*/
private NotificationTypeAutoProvisionerHandler createProvisioner() {
return new NotificationTypeAutoProvisionerHandler(cdsRuntime, notificationTypeProviderService);
}
Expand All @@ -71,7 +64,6 @@ void testEachNotificationTypeHasUniqueId() {
allTypes.size(),
"All expected notification types should be provisioned");

// Collect all IDs
Map<String, String> keyToId = new HashMap<>();
for (NotificationTypes nt : allTypes) {
String key = nt.getNotificationTypeKey();
Expand All @@ -84,27 +76,25 @@ void testEachNotificationTypeHasUniqueId() {
LOG.debug("Type: {} → ID: {}", key, id);
}

// Verify all IDs are unique
Set<String> uniqueIds = new HashSet<>(keyToId.values());
assertEquals(
EXPECTED_KEYS.size(),
uniqueIds.size(),
"Each notification type must have a UNIQUE NotificationTypeId. " + "Found IDs: " + keyToId);
"Each notification type must have a UNIQUE NotificationTypeId. Found IDs: " + keyToId);

LOG.debug("All {} notification types have unique IDs", uniqueIds.size());
}

// ──────────────────────────────────────────────────────────────
// Test 2: Re-provisioning updates each type with correct data
// Test 2: Re-provisioning keeps IDs stable (UPDATE, not INSERT)
// ──────────────────────────────────────────────────────────────

@Test
void testReProvisioningUpdatesEachTypeCorrectly() {
LOG.debug("==========================================");
LOG.debug("Test: Re-provisioning should update each type with its own correct data");
LOG.debug("Test: Re-provisioning should update each type without changing IDs");
LOG.debug("==========================================");

// Record state BEFORE re-provisioning
Map<String, String> idsBefore = new HashMap<>();
for (NotificationTypes nt :
NotificationTypeProviderServiceMockHandler.getAllNotificationTypes()) {
Expand All @@ -113,11 +103,8 @@ void testReProvisioningUpdatesEachTypeCorrectly() {
assertEquals(
EXPECTED_KEYS.size(), idsBefore.size(), "All types should exist before re-provisioning");

// Trigger re-provisioning (simulates app restart)
LOG.debug("Triggering re-provisioning...");
createProvisioner().onApplicationPrepared();

// Verify IDs remain the same (UPDATE, not new INSERT)
Map<String, String> idsAfter = new HashMap<>();
for (NotificationTypes nt :
NotificationTypeProviderServiceMockHandler.getAllNotificationTypes()) {
Expand All @@ -129,15 +116,7 @@ void testReProvisioningUpdatesEachTypeCorrectly() {
idsAfter,
"NotificationTypeIds should remain the same after re-provisioning (UPDATE, not INSERT)");

// Verify each type's data is correct (not overwritten by another type)
assertTypeDataIsCorrect(
"CertificateExpiration", "Certificate Expiry", "Certificate: {{certificateName}}");
assertTypeDataIsCorrect(
"SystemMaintenance",
"Maintenance Notice",
"System maintenance scheduled for {{systemName}}");

LOG.debug("Re-provisioning verified — all types retain correct data");
LOG.debug("Re-provisioning verified — all types retain their IDs");
}

// ──────────────────────────────────────────────────────────────
Expand All @@ -150,16 +129,13 @@ void testReProvisioningUpdatesAllTypes() {
LOG.debug("Test: Re-provisioning should trigger UPDATE for each existing type");
LOG.debug("==========================================");

// Record update counts before
Map<String, Integer> countsBefore = new HashMap<>();
for (String key : EXPECTED_KEYS) {
countsBefore.put(key, NotificationTypeProviderServiceMockHandler.getUpdateCount(key));
}

// Trigger re-provisioning
createProvisioner().onApplicationPrepared();

// Each type should have been updated exactly once more
for (String key : EXPECTED_KEYS) {
int before = countsBefore.get(key);
int after = NotificationTypeProviderServiceMockHandler.getUpdateCount(key);
Expand All @@ -169,8 +145,7 @@ void testReProvisioningUpdatesAllTypes() {
after,
"NotificationType '"
+ key
+ "' should have been updated exactly once. "
+ "Before: "
+ "' should have been updated exactly once. Before: "
+ before
+ ", After: "
+ after);
Expand All @@ -180,110 +155,4 @@ void testReProvisioningUpdatesAllTypes() {

LOG.debug("All {} types were updated during re-provisioning", EXPECTED_KEYS.size());
}

// ──────────────────────────────────────────────────────────────
// Test 4: No data cross-contamination between types
// ──────────────────────────────────────────────────────────────

@Test
void testNoDataCrossContaminationBetweenTypes() {
LOG.debug("==========================================");
LOG.debug("Test: Each type's English template must contain its own specific content");
LOG.debug("==========================================");

Map<String, String> typeToExpectedSensitive =
Map.of(
"CertificateExpiration", "certificateName",
"SystemMaintenance", "systemName");

for (Map.Entry<String, String> entry : typeToExpectedSensitive.entrySet()) {
String typeKey = entry.getKey();
String expectedVariable = entry.getValue();

NotificationTypes nt =
NotificationTypeProviderServiceMockHandler.getNotificationTypeByKeyVersion(typeKey, "1");
assertNotNull(nt, "Type '" + typeKey + "' should exist");

// Find the English template
Templates enTemplate =
nt.getTemplates().stream()
.filter(t -> "en".equals(t.getLanguage()))
.findFirst()
.orElseThrow(() -> new AssertionError("No English template for " + typeKey));

String sensitive = enTemplate.getTemplateSensitive();
assertNotNull(sensitive, "TemplateSensitive should not be null for " + typeKey);

assertTrue(
sensitive.contains("{{" + expectedVariable + "}}"),
"Type '"
+ typeKey
+ "' TemplateSensitive should contain '{{"
+ expectedVariable
+ "}}' "
+ "but was: '"
+ sensitive
+ "'. This indicates data cross-contamination from another type.");

// Also verify publicTitle is unique per type
String publicTitle = enTemplate.getTemplatePublic();
LOG.debug("[{}] publicTitle='{}', sensitive='{}'", typeKey, publicTitle, sensitive);
}

// Verify all publicTitles are distinct
Set<String> publicTitles =
EXPECTED_KEYS.stream()
.map(
key ->
NotificationTypeProviderServiceMockHandler.getNotificationTypeByKeyVersion(
key, "1"))
.map(
nt ->
nt.getTemplates().stream()
.filter(t -> "en".equals(t.getLanguage()))
.findFirst()
.map(Templates::getTemplatePublic)
.orElse(null))
.collect(Collectors.toSet());

assertEquals(
EXPECTED_KEYS.size(),
publicTitles.size(),
"Each notification type must have a unique publicTitle. Found: " + publicTitles);

LOG.debug("No cross-contamination detected — all types have unique, correct data");
}

// ──────────────────────────────────────────────────────────────
// Helper
// ──────────────────────────────────────────────────────────────

private void assertTypeDataIsCorrect(
String typeKey, String expectedPublicTitle, String expectedSensitive) {
NotificationTypes nt =
NotificationTypeProviderServiceMockHandler.getNotificationTypeByKeyVersion(typeKey, "1");
assertNotNull(nt, "Type '" + typeKey + "' should exist after re-provisioning");

Templates enTemplate =
nt.getTemplates().stream()
.filter(t -> "en".equals(t.getLanguage()))
.findFirst()
.orElseThrow(() -> new AssertionError("No English template for " + typeKey));

assertEquals(
expectedPublicTitle,
enTemplate.getTemplatePublic(),
"PublicTitle mismatch for '"
+ typeKey
+ "' — data may have been overwritten by another type");
assertEquals(
expectedSensitive,
enTemplate.getTemplateSensitive(),
"SensitiveTitle mismatch for '"
+ typeKey
+ "' — data may have been overwritten by another type");

LOG.debug(
"[{}] ✓ publicTitle='{}', sensitive='{}'", typeKey, expectedPublicTitle, expectedSensitive);
}
}
Loading
Loading