Enable notification template preview and test send (#24229)

* Add NotificationTemplate sending and rendering endpoints with mock data

* Setup entity fixtures for mock notifications

* Update generated TypeScript types

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Adrià Manero 2025-11-10 17:06:26 +01:00 committed by GitHub
parent 679ad3daf8
commit 9ad6783a99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 2933 additions and 186 deletions

View File

@ -13,28 +13,50 @@
package org.openmetadata.service.jdbi3;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.Invocation;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.openmetadata.schema.alert.type.EmailAlertConfig;
import org.openmetadata.schema.api.events.NotificationTemplateRenderRequest;
import org.openmetadata.schema.api.events.NotificationTemplateRenderResponse;
import org.openmetadata.schema.api.events.NotificationTemplateSendRequest;
import org.openmetadata.schema.api.events.NotificationTemplateValidationRequest;
import org.openmetadata.schema.api.events.NotificationTemplateValidationResponse;
import org.openmetadata.schema.api.events.TemplateRenderResult;
import org.openmetadata.schema.entity.events.EventSubscription;
import org.openmetadata.schema.entity.events.NotificationTemplate;
import org.openmetadata.schema.entity.events.SubscriptionDestination;
import org.openmetadata.schema.type.ChangeEvent;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.Include;
import org.openmetadata.schema.type.ProviderType;
import org.openmetadata.schema.type.Relationship;
import org.openmetadata.schema.type.Webhook;
import org.openmetadata.schema.type.change.ChangeSource;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.notifications.HandlebarsNotificationMessageEngine;
import org.openmetadata.service.notifications.channels.NotificationMessage;
import org.openmetadata.service.notifications.channels.email.EmailMessage;
import org.openmetadata.service.notifications.template.NotificationTemplateProcessor;
import org.openmetadata.service.notifications.template.handlebars.HandlebarsNotificationTemplateProcessor;
import org.openmetadata.service.notifications.template.testing.EntityFixtureLoader;
import org.openmetadata.service.notifications.template.testing.MockChangeEventFactory;
import org.openmetadata.service.notifications.template.testing.MockChangeEventRegistry;
import org.openmetadata.service.resources.events.NotificationTemplateResource;
import org.openmetadata.service.util.EntityUtil;
import org.openmetadata.service.util.EntityUtil.Fields;
import org.openmetadata.service.util.SubscriptionUtil;
import org.openmetadata.service.util.email.EmailUtil;
import org.openmetadata.service.util.resourcepath.ResourcePathResolver;
import org.openmetadata.service.util.resourcepath.providers.NotificationTemplateResourcePathProvider;
@ -45,6 +67,8 @@ public class NotificationTemplateRepository extends EntityRepository<Notificatio
static final String UPDATE_FIELDS = "templateBody,templateSubject";
private final NotificationTemplateProcessor templateProcessor;
private final MockChangeEventFactory mockChangeEventFactory;
private final HandlebarsNotificationMessageEngine messageEngine;
public NotificationTemplateRepository() {
super(
@ -57,6 +81,14 @@ public class NotificationTemplateRepository extends EntityRepository<Notificatio
// Initialize template processor
this.templateProcessor = new HandlebarsNotificationTemplateProcessor();
// Initialize mock factory for template testing
EntityFixtureLoader fixtureLoader = new EntityFixtureLoader();
MockChangeEventRegistry mockRegistry = new MockChangeEventRegistry(fixtureLoader);
this.mockChangeEventFactory = new MockChangeEventFactory(mockRegistry);
// Initialize message engine for template rendering
this.messageEngine = new HandlebarsNotificationMessageEngine(this);
}
@Override
@ -73,16 +105,14 @@ public class NotificationTemplateRepository extends EntityRepository<Notificatio
NotificationTemplateValidationResponse response = templateProcessor.validate(request);
// Check for validation errors
List<String> errors = new ArrayList<>();
if (response.getTemplateBody() != null && !response.getTemplateBody().getPassed()) {
errors.add("Template body: " + response.getTemplateBody().getError());
}
if (response.getTemplateSubject() != null && !response.getTemplateSubject().getPassed()) {
errors.add("Template subject: " + response.getTemplateSubject().getError());
}
if (!errors.isEmpty()) {
if (!response.getIsValid()) {
List<String> errors = new ArrayList<>();
if (response.getSubjectError() != null) {
errors.add("Template subject: " + response.getSubjectError());
}
if (response.getBodyError() != null) {
errors.add("Template body: " + response.getBodyError());
}
throw new IllegalArgumentException("Invalid template: " + String.join("; ", errors));
}
}
@ -258,6 +288,130 @@ public class NotificationTemplateRepository extends EntityRepository<Notificatio
return templateProcessor.validate(request);
}
/**
* Renders a template with mock data using HandlebarsNotificationMessageEngine.
* Called by the REST endpoint for rendering preview.
*
* @param request The render request with template and resource info
* @return The render response with validation and rendering results
*/
public NotificationTemplateRenderResponse render(NotificationTemplateRenderRequest request) {
NotificationTemplateValidationResponse validationResponse =
templateProcessor.validate(
new NotificationTemplateValidationRequest()
.withTemplateBody(request.getTemplateBody())
.withTemplateSubject(request.getTemplateSubject()));
if (!validationResponse.getIsValid()) {
return new NotificationTemplateRenderResponse()
.withValidation(validationResponse)
.withRender(null);
}
ChangeEvent mockEvent =
mockChangeEventFactory.create(request.getResource(), request.getEventType());
NotificationTemplate testTemplate =
new NotificationTemplate()
.withId(UUID.randomUUID())
.withName("test-template")
.withTemplateSubject(request.getTemplateSubject())
.withTemplateBody(request.getTemplateBody());
EventSubscription testSubscription =
new EventSubscription()
.withId(UUID.randomUUID())
.withName("test-subscription")
.withDisplayName("Test Notification");
SubscriptionDestination emailDestination =
new SubscriptionDestination().withType(SubscriptionDestination.SubscriptionType.EMAIL);
TemplateRenderResult renderResult =
renderWithMessageEngine(mockEvent, testSubscription, emailDestination, testTemplate);
return new NotificationTemplateRenderResponse()
.withValidation(validationResponse)
.withRender(renderResult);
}
private TemplateRenderResult renderWithMessageEngine(
ChangeEvent event,
EventSubscription subscription,
SubscriptionDestination destination,
NotificationTemplate template) {
try {
EmailMessage emailMessage =
(EmailMessage)
messageEngine.generateMessageWithTemplate(event, subscription, destination, template);
return new TemplateRenderResult()
.withSubject(emailMessage.getSubject())
.withBody(emailMessage.getHtmlContent());
} catch (Exception e) {
String errorMessage = "Failed to render template: " + e.getMessage();
LOG.error(errorMessage, e);
return new TemplateRenderResult().withSubject("").withBody("");
}
}
/**
* Validates and sends a template to specified destinations.
* Called by the REST endpoint for send testing.
*
* @param request The send request with template, resource, eventType, and destinations
* @return The validation response (delivery errors logged server-side only)
*/
public NotificationTemplateValidationResponse send(NotificationTemplateSendRequest request) {
NotificationTemplateRenderRequest renderRequest = request.getRenderRequest();
NotificationTemplateValidationRequest validationRequest =
new NotificationTemplateValidationRequest()
.withTemplateBody(renderRequest.getTemplateBody())
.withTemplateSubject(renderRequest.getTemplateSubject());
NotificationTemplateValidationResponse validation =
templateProcessor.validate(validationRequest);
if (!validation.getIsValid()) {
return validation;
}
validateExternalDestinations(request.getDestinations());
ChangeEvent mockEvent =
mockChangeEventFactory.create(renderRequest.getResource(), renderRequest.getEventType());
NotificationTemplate testTemplate =
new NotificationTemplate()
.withId(UUID.randomUUID())
.withName("test-template")
.withTemplateSubject(renderRequest.getTemplateSubject())
.withTemplateBody(renderRequest.getTemplateBody());
EventSubscription testSubscription =
new EventSubscription()
.withId(UUID.randomUUID())
.withName("test-notification")
.withDisplayName("Test Notification Template");
for (SubscriptionDestination dest : request.getDestinations()) {
try {
sendToDestination(mockEvent, testSubscription, dest, testTemplate);
LOG.info("Successfully sent test notification to {} destination", dest.getType());
} catch (Exception e) {
LOG.error(
"Failed to send test notification to {} destination: {}",
dest.getType(),
e.getMessage(),
e);
}
}
return validation;
}
private NotificationTemplate getDefaultTemplateFromSeed(String fqn) throws IOException {
String seedPath =
ResourcePathResolver.getResourcePath(NotificationTemplateResourcePathProvider.class);
@ -281,6 +435,62 @@ public class NotificationTemplateRepository extends EntityRepository<Notificatio
return EntityUtil.hash(content);
}
private void validateExternalDestinations(List<SubscriptionDestination> destinations) {
for (SubscriptionDestination dest : destinations) {
if (dest.getCategory() != SubscriptionDestination.SubscriptionCategory.EXTERNAL) {
throw new IllegalArgumentException(
"Only external destinations (Email, Slack, Teams, GChat, Webhook) are supported.");
}
}
}
private void sendToDestination(
ChangeEvent event,
EventSubscription subscription,
SubscriptionDestination destination,
NotificationTemplate template) {
NotificationMessage message =
messageEngine.generateMessageWithTemplate(event, subscription, destination, template);
switch (destination.getType()) {
case EMAIL -> sendEmailNotification((EmailMessage) message, destination);
case SLACK, MS_TEAMS, G_CHAT, WEBHOOK -> sendWebhookNotification(message, destination);
default -> throw new IllegalArgumentException(
"Unsupported destination type: " + destination.getType());
}
}
private void sendEmailNotification(
EmailMessage emailMessage, SubscriptionDestination destination) {
EmailAlertConfig emailConfig =
JsonUtils.convertValue(destination.getConfig(), EmailAlertConfig.class);
Set<String> receivers = emailConfig.getReceivers();
for (String receiver : receivers) {
EmailUtil.sendNotificationEmail(
receiver, emailMessage.getSubject(), emailMessage.getHtmlContent());
}
}
private void sendWebhookNotification(
NotificationMessage message, SubscriptionDestination destination) {
Webhook webhook = JsonUtils.convertValue(destination.getConfig(), Webhook.class);
String json = JsonUtils.pojoToJsonIgnoreNull(message);
try (Client client =
SubscriptionUtil.getClient(destination.getTimeout(), destination.getReadTimeout())) {
Invocation.Builder target = SubscriptionUtil.getTarget(client, webhook, json);
try (Response response =
target.post(jakarta.ws.rs.client.Entity.entity(json, MediaType.APPLICATION_JSON_TYPE))) {
if (response.getStatus() >= 300) {
throw new RuntimeException("Webhook failed with status: " + response.getStatus());
}
}
}
}
public class NotificationTemplateUpdater extends EntityUpdater {
public NotificationTemplateUpdater(
NotificationTemplate original, NotificationTemplate updated, Operation operation) {

View File

@ -74,6 +74,16 @@ public class HandlebarsNotificationMessageEngine implements NotificationMessageE
// Resolve the template for this event
NotificationTemplate template = resolveTemplate(event, subscription);
return generateMessageWithTemplate(event, subscription, destination, template);
}
@Override
public NotificationMessage generateMessageWithTemplate(
ChangeEvent event,
EventSubscription subscription,
SubscriptionDestination destination,
NotificationTemplate template) {
// Create deep copy of event to avoid modifying the original
ChangeEvent eventCopy = JsonUtils.deepCopy(event, ChangeEvent.class);

View File

@ -13,6 +13,7 @@
package org.openmetadata.service.notifications;
import org.openmetadata.schema.entity.events.EventSubscription;
import org.openmetadata.schema.entity.events.NotificationTemplate;
import org.openmetadata.schema.entity.events.SubscriptionDestination;
import org.openmetadata.schema.type.ChangeEvent;
import org.openmetadata.service.notifications.channels.NotificationMessage;
@ -32,4 +33,22 @@ public interface NotificationMessageEngine {
*/
NotificationMessage generateMessage(
ChangeEvent event, EventSubscription subscription, SubscriptionDestination destination);
/**
* Generate notification message using a specific template (for testing).
* Bypasses template resolution and uses the provided template directly.
* This method is primarily used for testing notification templates without storing them in the
* database.
*
* @param event The ChangeEvent to render
* @param subscription The EventSubscription context
* @param destination The target destination
* @param template The NotificationTemplate to use (not resolved from DB)
* @return Rendered NotificationMessage for the destination type
*/
NotificationMessage generateMessageWithTemplate(
ChangeEvent event,
EventSubscription subscription,
SubscriptionDestination destination,
NotificationTemplate template);
}

View File

@ -5,7 +5,6 @@ import com.github.jknack.handlebars.HandlebarsException;
import com.github.jknack.handlebars.Template;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.api.events.FieldValidation;
import org.openmetadata.schema.api.events.NotificationTemplateValidationRequest;
import org.openmetadata.schema.api.events.NotificationTemplateValidationResponse;
import org.openmetadata.service.notifications.template.NotificationTemplateProcessor;
@ -32,27 +31,25 @@ public class HandlebarsNotificationTemplateProcessor implements NotificationTemp
@Override
public NotificationTemplateValidationResponse validate(
NotificationTemplateValidationRequest request) {
NotificationTemplateValidationResponse response = new NotificationTemplateValidationResponse();
// Validate template body
if (request.getTemplateBody() != null && !request.getTemplateBody().isEmpty()) {
FieldValidation bodyValidation = new FieldValidation();
String error = validateTemplateString(request.getTemplateBody());
bodyValidation.setPassed(error == null);
bodyValidation.setError(error);
response.setTemplateBody(bodyValidation);
}
String subjectError = null;
String bodyError = null;
// Validate template subject
if (request.getTemplateSubject() != null && !request.getTemplateSubject().isEmpty()) {
FieldValidation subjectValidation = new FieldValidation();
String error = validateTemplateString(request.getTemplateSubject());
subjectValidation.setPassed(error == null);
subjectValidation.setError(error);
response.setTemplateSubject(subjectValidation);
subjectError = validateTemplateString(request.getTemplateSubject());
}
return response;
// Validate template body
if (request.getTemplateBody() != null && !request.getTemplateBody().isEmpty()) {
bodyError = validateTemplateString(request.getTemplateBody());
}
boolean isValid = (subjectError == null) && (bodyError == null);
return new NotificationTemplateValidationResponse()
.withIsValid(isValid)
.withSubjectError(subjectError)
.withBodyError(bodyError);
}
private String validateTemplateString(String templateString) {

View File

@ -0,0 +1,107 @@
package org.openmetadata.service.notifications.template.testing;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Loads entity fixture JSON files from classpath with caching.
* Encapsulates all knowledge about fixture file paths and structure.
* Provides fallback to generic fixtures when specific fixtures aren't found.
*/
public class EntityFixtureLoader {
private static final String FIXTURES_BASE_PATH = "json/data/notifications/fixtures/";
private static final String BASE_FIXTURE_PATH_FORMAT = FIXTURES_BASE_PATH + "%s/base.json";
private static final String SCENARIO_FIXTURE_PATH_FORMAT =
FIXTURES_BASE_PATH + "%s/scenarios/%s.json";
private final Map<String, Map<String, Object>> cache = new ConcurrentHashMap<>();
private final ObjectMapper json = new ObjectMapper();
/**
* Generic base fixture with EntityInterface fields.
* Used as fallback when resource-specific fixture doesn't exist.
*/
private static final Map<String, Object> GENERIC_BASE_FIXTURE =
Map.of(
"id", UUID.randomUUID().toString(),
"name", "generic_entity",
"displayName", "Generic Entity",
"fullyQualifiedName", "generic.entity",
"description", "Generic entity for notification template testing",
"version", 1.0,
"updatedAt", System.currentTimeMillis(),
"updatedBy", "admin",
"deleted", false);
/**
* Load base entity fixture for a resource.
* Convention: {FIXTURES_BASE_PATH}/{resource}/base.json
* Falls back to GENERIC_BASE_FIXTURE if resource-specific fixture not found.
*
* @param resource Resource type (e.g., "table", "dashboard")
* @return Entity fixture data as map (resource-specific or generic fallback)
*/
public Map<String, Object> loadBaseFixture(String resource) {
String path = String.format(BASE_FIXTURE_PATH_FORMAT, resource);
Map<String, Object> fixture = load(path);
if (fixture.isEmpty()) {
return GENERIC_BASE_FIXTURE;
}
return fixture;
}
/**
* Load scenario fixture for a resource and event type.
* Convention: {FIXTURES_BASE_PATH}/{resource}/scenarios/{eventType}.json
* Returns empty map if scenario fixture not found (caller will use base entity only).
*
* @param resource Resource type (e.g., "table", "dashboard")
* @param eventType Event type (e.g., "entityCreated", "entityUpdated")
* @return Scenario fixture data as map (empty if not found)
*/
public Map<String, Object> loadScenarioFixture(String resource, String eventType) {
String path = String.format(SCENARIO_FIXTURE_PATH_FORMAT, resource, eventType);
Map<String, Object> fixture = load(path);
if (fixture.isEmpty()) {
return Map.of();
}
return fixture;
}
/**
* Load a JSON file from classpath with caching.
* Generic method for loading any JSON file by full path.
*
* @param path Full classpath path to the JSON file
* @return Map representation of the JSON file, or empty map if not found
*/
private Map<String, Object> load(String path) {
return cache.computeIfAbsent(path, this::loadFromClasspath);
}
@SuppressWarnings("unchecked")
private Map<String, Object> loadFromClasspath(String path) {
try (InputStream is = getClass().getClassLoader().getResourceAsStream(path)) {
if (is == null) {
return Map.of(); // Return empty for missing files
}
return json.readValue(is, Map.class);
} catch (IOException e) {
throw new UncheckedIOException(
String.format(
"Failed to parse JSON file at classpath location: %s. "
+ "Verify the file contains valid JSON syntax.",
path),
e);
}
}
}

View File

@ -0,0 +1,76 @@
package org.openmetadata.service.notifications.template.testing;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import javax.annotation.Nullable;
import org.openmetadata.schema.type.ChangeEvent;
import org.openmetadata.schema.type.EventType;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.notifications.template.testing.MockChangeEventRegistry.EntityScenario;
import org.openmetadata.service.notifications.template.testing.MockChangeEventRegistry.Scenario;
/**
* Factory for creating mock ChangeEvents for notification template testing.
*/
public final class MockChangeEventFactory {
private static final String TEST_USER = "test-user";
private final MockChangeEventRegistry registry;
/**
* Create factory for generating mock ChangeEvents.
*
* @param registry Mock registry with builders and appliers
*/
public MockChangeEventFactory(MockChangeEventRegistry registry) {
this.registry = Objects.requireNonNull(registry, "registry must not be null");
}
/**
* Create a mock ChangeEvent for the given resource and eventType.
* Falls back to generic fixture for unknown resources.
*
* @param resource Resource type (e.g., "table", "dashboard")
* @param eventType Optional eventType (null for neutral mode)
* @return Fully populated ChangeEvent with mock data
*/
public ChangeEvent create(String resource, @Nullable EventType eventType) {
Objects.requireNonNull(resource, "resource must not be null");
String type = resource.trim();
Map<String, Object> entity = new HashMap<>();
registry.builder(type).build(entity);
UUID entityId = UUID.fromString(String.valueOf(entity.get("id")));
String fqn = "test.namespace.sample_" + type;
ChangeEvent event =
new ChangeEvent()
.withId(UUID.randomUUID())
.withEntityId(entityId)
.withEntityType(type)
.withEntity(JsonUtils.pojoToJson(entity))
.withUserName(TEST_USER)
.withTimestamp(Instant.now().toEpochMilli())
.withEntityFullyQualifiedName(fqn);
Optional.ofNullable(eventType)
.ifPresentOrElse(
typeEvent -> {
Scenario scenario = new EntityScenario(type, typeEvent);
registry.scenarioApplier(scenario).apply(event, entity);
},
() ->
event
.withEventType(null)
.withPreviousVersion(1.0)
.withCurrentVersion(1.1)
.withChangeDescription(null));
return event;
}
}

View File

@ -0,0 +1,180 @@
package org.openmetadata.service.notifications.template.testing;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import org.openmetadata.schema.type.ChangeDescription;
import org.openmetadata.schema.type.ChangeEvent;
import org.openmetadata.schema.type.EventType;
import org.openmetadata.schema.type.FieldChange;
/**
* Registry for entity fixture builders and event scenario appliers.
* Provides DSL-like API for registering mock generation strategies.
*/
public final class MockChangeEventRegistry {
/**
* Functional interface for building entity fixture data from loaded JSON.
*/
@FunctionalInterface
public interface EntityFixtureBuilder {
void build(Map<String, Object> entity);
}
/**
* Functional interface for applying event scenario data to ChangeEvents.
*/
@FunctionalInterface
public interface EntityFixtureEventApplier {
void apply(ChangeEvent event, Map<String, Object> entity);
}
/**
* Represents a test scenario for notification template testing.
* Used as a key to look up event scenario appliers in the registry.
*/
public sealed interface Scenario
permits MockChangeEventRegistry.EntityScenario, MockChangeEventRegistry.NeutralScenario {
String resource();
Optional<EventType> eventType();
}
/**
* Scenario for testing with a specific eventType (e.g., entityUpdated).
*/
public record EntityScenario(String resource, EventType type) implements Scenario {
@Override
public Optional<EventType> eventType() {
return Optional.of(type);
}
}
/**
* Scenario for testing without eventType (neutral mode).
* Tests only entity field access, not event-specific logic.
*/
public record NeutralScenario(String resource) implements Scenario {
@Override
public Optional<EventType> eventType() {
return Optional.empty();
}
}
private final Map<String, EntityFixtureBuilder> builders = new HashMap<>();
private final Map<Scenario, EntityFixtureEventApplier> appliers = new HashMap<>();
private final EntityFixtureLoader fixtures;
public MockChangeEventRegistry(EntityFixtureLoader fixtures) {
this.fixtures = fixtures;
}
/**
* Get builder for a resource using convention-based loading.
* Automatically loads from {resource}/base.json if not explicitly registered.
* Falls back to generic fixture if resource-specific fixture not found.
* Uses lazy loading with automatic caching.
*
* @param resource Resource type (e.g., "table", "dashboard")
* @return Builder for the resource
*/
public EntityFixtureBuilder builder(String resource) {
return builders.computeIfAbsent(
resource, r -> entity -> entity.putAll(fixtures.loadBaseFixture(r)));
}
/**
* Get applier for a scenario using convention-based loading.
* Automatically loads from {resource}/scenarios/{eventType}.json if not explicitly registered.
* Gracefully handles missing scenario fixtures (uses base entity only).
* Uses lazy loading with automatic caching.
*
* @param scenario Scenario containing resource and optional event type
* @return Applier for the scenario
*/
public EntityFixtureEventApplier scenarioApplier(Scenario scenario) {
return appliers.computeIfAbsent(
scenario,
s -> {
if (s.eventType().isEmpty()) {
// Neutral scenario - no fixture loading needed
return (event, entity) -> {};
}
String eventTypeValue = s.eventType().get().value();
return (event, entity) -> {
Map<String, Object> scenarioData =
fixtures.loadScenarioFixture(s.resource(), eventTypeValue);
applyScenario(event, scenarioData);
};
});
}
/**
* Apply scenario fixture to ChangeEvent.
* Gracefully handles empty scenarios (when fixture not found).
*/
private void applyScenario(ChangeEvent event, Map<String, Object> scenario) {
if (scenario.isEmpty()) {
// No scenario fixture found - skip scenario application
return;
}
String eventTypeStr = (String) scenario.get("eventType");
event
.withEventType(EventType.fromValue(eventTypeStr))
.withPreviousVersion((Double) scenario.get("previousVersion"))
.withCurrentVersion((Double) scenario.get("currentVersion"));
@SuppressWarnings("unchecked")
Map<String, Object> cdMap = (Map<String, Object>) scenario.get("changeDescription");
if (cdMap != null) {
ChangeDescription cd = buildChangeDescription(cdMap);
event.withChangeDescription(cd);
}
}
/**
* Build ChangeDescription from fixture map.
*/
@SuppressWarnings("unchecked")
private ChangeDescription buildChangeDescription(Map<String, Object> cdMap) {
ChangeDescription cd =
new ChangeDescription().withPreviousVersion((Double) cdMap.get("previousVersion"));
List<Map<String, String>> added = (List<Map<String, String>>) cdMap.get("fieldsAdded");
if (added != null) {
cd.withFieldsAdded(
added.stream()
.map(m -> new FieldChange().withName(m.get("name")).withNewValue(m.get("newValue")))
.collect(Collectors.toList()));
}
List<Map<String, String>> updated = (List<Map<String, String>>) cdMap.get("fieldsUpdated");
if (updated != null) {
cd.withFieldsUpdated(
updated.stream()
.map(
m ->
new FieldChange()
.withName(m.get("name"))
.withOldValue(m.get("oldValue"))
.withNewValue(m.get("newValue")))
.collect(Collectors.toList()));
}
List<Map<String, String>> deleted = (List<Map<String, String>>) cdMap.get("fieldsDeleted");
if (deleted != null) {
cd.withFieldsDeleted(
deleted.stream()
.map(m -> new FieldChange().withName(m.get("name")).withOldValue(m.get("oldValue")))
.collect(Collectors.toList()));
}
return cd;
}
}

View File

@ -47,6 +47,9 @@ import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.api.data.RestoreEntity;
import org.openmetadata.schema.api.events.CreateNotificationTemplate;
import org.openmetadata.schema.api.events.NotificationTemplateRenderRequest;
import org.openmetadata.schema.api.events.NotificationTemplateRenderResponse;
import org.openmetadata.schema.api.events.NotificationTemplateSendRequest;
import org.openmetadata.schema.api.events.NotificationTemplateValidationRequest;
import org.openmetadata.schema.api.events.NotificationTemplateValidationResponse;
import org.openmetadata.schema.entity.events.NotificationTemplate;
@ -614,9 +617,9 @@ public class NotificationTemplateResource
@Path("/validate")
@Operation(
operationId = "validateNotificationTemplate",
summary = "Validate a notification template",
summary = "Validate notification template syntax",
description =
"Validates the syntax and structure of a notification template without saving it. "
"Validates only the Handlebars syntax of template subject and body without generating mock data or sending. "
+ "Requires CREATE or EDIT_ALL permission for notification templates.",
responses = {
@ApiResponse(
@ -627,11 +630,7 @@ public class NotificationTemplateResource
mediaType = "application/json",
schema =
@Schema(implementation = NotificationTemplateValidationResponse.class))),
@ApiResponse(responseCode = "400", description = "Invalid request"),
@ApiResponse(responseCode = "401", description = "Unauthorized"),
@ApiResponse(
responseCode = "403",
description = "Forbidden - CREATE or EDIT_ALL permission required")
@ApiResponse(responseCode = "400", description = "Bad request")
})
public Response validateTemplate(
@Context UriInfo uriInfo,
@ -655,4 +654,82 @@ public class NotificationTemplateResource
return Response.ok(response).build();
}
@POST
@Path("/render")
@Operation(
operationId = "renderNotificationTemplate",
summary = "Render notification template with mock data",
description =
"Generates mock ChangeEvent data for the specified resource and eventType, then renders the template. "
+ "Returns the rendered subject and body for preview. Does not send to any destination. "
+ "Requires CREATE or EDIT_ALL permission for notification templates.",
responses = {
@ApiResponse(
responseCode = "200",
description = "Rendering result with validation and render outputs",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = NotificationTemplateRenderResponse.class))),
@ApiResponse(responseCode = "400", description = "Bad request")
})
public Response renderTemplate(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Valid NotificationTemplateRenderRequest request) {
List<AuthRequest> authRequests =
List.of(
new AuthRequest(
new OperationContext(entityType, MetadataOperation.CREATE), getResourceContext()),
new AuthRequest(
new OperationContext(entityType, MetadataOperation.EDIT_ALL),
getResourceContext()));
authorizer.authorizeRequests(securityContext, authRequests, AuthorizationLogic.ANY);
NotificationTemplateRenderResponse response = repository.render(request);
return Response.ok(response).build();
}
@POST
@Path("/send")
@Operation(
operationId = "sendNotificationTemplateTest",
summary = "Validate and send notification template to external destinations",
description =
"Validates template syntax, generates mock ChangeEvent data, and sends to specified external destinations. "
+ "Returns validation status only. Delivery errors are logged server-side. "
+ "Only external destinations (Email, Slack, Teams, GChat, Webhook) are supported. "
+ "Requires CREATE or EDIT_ALL permission for notification templates.",
responses = {
@ApiResponse(
responseCode = "200",
description = "Validation result",
content =
@Content(
mediaType = "application/json",
schema =
@Schema(implementation = NotificationTemplateValidationResponse.class))),
@ApiResponse(responseCode = "400", description = "Bad request or validation failure")
})
public Response sendTemplate(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Valid NotificationTemplateSendRequest request) {
List<AuthRequest> authRequests =
List.of(
new AuthRequest(
new OperationContext(entityType, MetadataOperation.CREATE), getResourceContext()),
new AuthRequest(
new OperationContext(entityType, MetadataOperation.EDIT_ALL),
getResourceContext()));
authorizer.authorizeRequests(securityContext, authRequests, AuthorizationLogic.ANY);
NotificationTemplateValidationResponse response = repository.send(request);
return Response.ok(response).build();
}
}

View File

@ -0,0 +1,49 @@
{
"id": "7f8c3e4d-5a6b-4c9d-8e7f-6a5b4c3d2e1f",
"name": "sample_chart",
"displayName": "Sample Chart",
"fullyQualifiedName": "sample-dashboard-service.sample_chart",
"description": "Mock chart for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"chartType": "Bar",
"sourceUrl": "/charts/sample_chart",
"href": "http://localhost:8585/api/v1/charts/7f8c3e4d-5a6b-4c9d-8e7f-6a5b4c3d2e1f",
"service": {
"id": "9e8d7c6b-5a4f-3e2d-1c0b-9a8f7e6d5c4b",
"type": "dashboardService",
"name": "sample-dashboard-service",
"fullyQualifiedName": "sample-dashboard-service",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/dashboardServices/9e8d7c6b-5a4f-3e2d-1c0b-9a8f7e6d5c4b"
},
"serviceType": "Superset",
"dashboards": [
{
"id": "a1b2c3d4-1111-2222-3333-444444444444",
"type": "dashboard",
"name": "sample_dashboard",
"fullyQualifiedName": "sample-dashboard-service.sample_dashboard",
"displayName": "Sample Dashboard"
}
],
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"domains": [
{
"id": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
"type": "domain",
"name": "Engineering",
"fullyQualifiedName": "Engineering"
}
],
"tags": []
}

View File

@ -0,0 +1,45 @@
{
"id": "cf7d6b5a-4f3e-2d1c-0b9a-8f7e6d5c4b3a",
"name": "sample_container",
"displayName": "Sample Container",
"fullyQualifiedName": "sample-storage-service.sample_container",
"description": "Mock container for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"dataModel": {
"isPartitioned": false
},
"prefix": "/data/",
"numberOfObjects": 150,
"size": 1073741824,
"fileFormats": ["parquet", "json"],
"href": "http://localhost:8585/api/v1/containers/cf7d6b5a-4f3e-2d1c-0b9a-8f7e6d5c4b3a",
"service": {
"id": "5a4f3e2d-1c0b-9a8f-7e6d-5c4b3a2f1e0d",
"type": "storageService",
"name": "sample-storage-service",
"fullyQualifiedName": "sample-storage-service",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/storageServices/5a4f3e2d-1c0b-9a8f-7e6d-5c4b3a2f1e0d"
},
"serviceType": "S3",
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"domains": [
{
"id": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
"type": "domain",
"name": "Engineering",
"fullyQualifiedName": "Engineering"
}
],
"tags": []
}

View File

@ -0,0 +1,63 @@
{
"id": "a1b2c3d4-1111-2222-3333-444444444444",
"name": "sample_dashboard",
"displayName": "Sample Dashboard",
"fullyQualifiedName": "sample-dashboard-service.sample_dashboard",
"description": "Mock dashboard for notification template testing",
"version": 1.0,
"updatedAt": 1234567890000,
"updatedBy": "admin",
"href": "http://localhost:8585/api/v1/dashboards/a1b2c3d4-1111-2222-3333-444444444444",
"dashboardType": "Dashboard",
"charts": [
{
"id": "b2c3d4e5-2222-3333-4444-555555555555",
"type": "chart",
"name": "revenue_chart",
"fullyQualifiedName": "sample-dashboard-service.revenue_chart",
"displayName": "Revenue Overview"
},
{
"id": "c3d4e5f6-3333-4444-5555-666666666666",
"type": "chart",
"name": "users_chart",
"fullyQualifiedName": "sample-dashboard-service.users_chart",
"displayName": "Active Users"
}
],
"service": {
"id": "d4e5f6a7-4444-5555-6666-777777777777",
"type": "dashboardService",
"name": "sample-dashboard-service",
"fullyQualifiedName": "sample-dashboard-service",
"displayName": "Sample Dashboard Service"
},
"serviceType": "Superset",
"dataModels": [
{
"id": "e5f6a7b8-5555-6666-7777-888888888888",
"type": "dashboardDataModel",
"name": "sample_data_model",
"fullyQualifiedName": "sample-dashboard-service.sample_data_model",
"displayName": "Sample Data Model"
}
],
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"domains": [
{
"id": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
"type": "domain",
"name": "Engineering",
"fullyQualifiedName": "Engineering"
}
],
"tags": [],
"deleted": false
}

View File

@ -0,0 +1,52 @@
{
"id": "8e7d6c5b-4a3f-2e1d-0c9b-8a7f6e5d4c3b",
"name": "sample_data_model",
"displayName": "Sample Data Model",
"fullyQualifiedName": "sample-dashboard-service.sample_dashboard.datamodel.sample_data_model",
"description": "Mock dashboard data model for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"dataModelType": "SupersetDataModel",
"sourceUrl": "/datamodels/sample_data_model",
"href": "http://localhost:8585/api/v1/dashboardDataModels/8e7d6c5b-4a3f-2e1d-0c9b-8a7f6e5d4c3b",
"service": {
"id": "9e8d7c6b-5a4f-3e2d-1c0b-9a8f7e6d5c4b",
"type": "dashboardService",
"name": "sample-dashboard-service",
"fullyQualifiedName": "sample-dashboard-service",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/dashboardServices/9e8d7c6b-5a4f-3e2d-1c0b-9a8f7e6d5c4b"
},
"serviceType": "Superset",
"columns": [
{
"name": "user_id",
"dataType": "NUMERIC",
"description": "User identifier"
},
{
"name": "revenue",
"dataType": "NUMERIC",
"description": "Total revenue"
}
],
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"domains": [
{
"id": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
"type": "domain",
"name": "Engineering",
"fullyQualifiedName": "Engineering"
}
],
"tags": []
}

View File

@ -0,0 +1,28 @@
{
"id": "9e8d7c6b-5a4f-3e2d-1c0b-9a8f7e6d5c4b",
"name": "sample-dashboard-service",
"displayName": "Sample Dashboard Service",
"fullyQualifiedName": "sample-dashboard-service",
"description": "Mock dashboard service for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/dashboardServices/9e8d7c6b-5a4f-3e2d-1c0b-9a8f7e6d5c4b",
"serviceType": "Superset",
"connection": {
"config": {
"type": "Superset",
"hostPort": "http://localhost:8088"
}
},
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"tags": []
}

View File

@ -0,0 +1,90 @@
{
"id": "df6c5b4a-3f2e-1d0c-9b8a-7f6e5d4c3b2a",
"name": "sample_data_contract",
"displayName": "Sample Data Contract",
"fullyQualifiedName": "sample_data_contract",
"description": "Mock data contract for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/dataContracts/df6c5b4a-3f2e-1d0c-9b8a-7f6e5d4c3b2a",
"contractType": "SLA",
"entity": {
"id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"type": "table",
"name": "sample_table",
"fullyQualifiedName": "sample-service.sample-db.public.sample_table",
"deleted": false
},
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"reviewers": [
{
"id": "b2c3d4e5-f678-90ab-cdef-123456789012",
"type": "user",
"name": "jane.smith",
"fullyQualifiedName": "jane.smith"
}
],
"entityStatus": "Approved",
"schema": {
"columns": [
{
"name": "id",
"dataType": "BIGINT",
"constraint": "NOT NULL"
},
{
"name": "email",
"dataType": "VARCHAR",
"constraint": "NOT NULL"
}
]
},
"qualityExpectations": {
"completeness": {
"threshold": 95.0
},
"accuracy": {
"threshold": 99.0
}
},
"contractUpdates": [
{
"timestamp": 1699564800000,
"updatedBy": "admin",
"changes": "Initial contract creation"
}
],
"semantics": {
"purpose": "Customer data management",
"classifications": ["PII", "GDPR"]
},
"termsOfUse": "Data must be used in accordance with privacy policies",
"security": {
"encryption": "AES-256",
"accessControl": "RBAC"
},
"sla": {
"freshness": {
"maxAge": "PT24H"
},
"availability": {
"uptime": 99.9
}
},
"latestResult": {
"timestamp": 1699564800000,
"status": "Passed",
"violations": 0
},
"dataQualityTests": [],
"tags": []
}

View File

@ -0,0 +1,39 @@
{
"id": "ef5b4a3f-2e1d-0c9b-8a7f-6e5d4c3b2a1f",
"name": "sample_database",
"displayName": "Sample Database",
"fullyQualifiedName": "sample-database-service.sample_database",
"description": "Mock database for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/databases/ef5b4a3f-2e1d-0c9b-8a7f-6e5d4c3b2a1f",
"service": {
"id": "4a3f2e1d-0c9b-8a7f-6e5d-4c3b2a1f0e9d",
"type": "databaseService",
"name": "sample-database-service",
"fullyQualifiedName": "sample-database-service",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/databaseServices/4a3f2e1d-0c9b-8a7f-6e5d-4c3b2a1f0e9d"
},
"serviceType": "Postgres",
"default": false,
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"domains": [
{
"id": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
"type": "domain",
"name": "Engineering",
"fullyQualifiedName": "Engineering"
}
],
"tags": []
}

View File

@ -0,0 +1,47 @@
{
"id": "ff4a3f2e-1d0c-9b8a-7f6e-5d4c3b2a1f0e",
"name": "public",
"displayName": "Public Schema",
"fullyQualifiedName": "sample-database-service.sample_database.public",
"description": "Mock database schema for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/databaseSchemas/ff4a3f2e-1d0c-9b8a-7f6e-5d4c3b2a1f0e",
"service": {
"id": "4a3f2e1d-0c9b-8a7f-6e5d-4c3b2a1f0e9d",
"type": "databaseService",
"name": "sample-database-service",
"fullyQualifiedName": "sample-database-service",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/databaseServices/4a3f2e1d-0c9b-8a7f-6e5d-4c3b2a1f0e9d"
},
"serviceType": "Postgres",
"database": {
"id": "ef5b4a3f-2e1d-0c9b-8a7f-6e5d4c3b2a1f",
"type": "database",
"name": "sample_database",
"fullyQualifiedName": "sample-database-service.sample_database",
"deleted": false,
"href": "http://localhost:8585/api/v1/databases/ef5b4a3f-2e1d-0c9b-8a7f-6e5d4c3b2a1f"
},
"retentionPeriod": "P30D",
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"domains": [
{
"id": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
"type": "domain",
"name": "Engineering",
"fullyQualifiedName": "Engineering"
}
],
"tags": []
}

View File

@ -0,0 +1,31 @@
{
"id": "4a3f2e1d-0c9b-8a7f-6e5d-4c3b2a1f0e9d",
"name": "sample-database-service",
"displayName": "Sample Database Service",
"fullyQualifiedName": "sample-database-service",
"description": "Mock database service for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/databaseServices/4a3f2e1d-0c9b-8a7f-6e5d-4c3b2a1f0e9d",
"serviceType": "Postgres",
"connection": {
"config": {
"type": "Postgres",
"scheme": "postgresql+psycopg2",
"username": "openmetadata_user",
"hostPort": "localhost:5432",
"database": "sample_database"
}
},
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"tags": []
}

View File

@ -0,0 +1,14 @@
{
"id": "1e2d1c0b-9a8f-7e6d-5c4b-3a2f1e0d9c8b",
"name": "sample_glossary",
"displayName": "Sample Glossary",
"fullyQualifiedName": "sample_glossary",
"description": "Mock glossary for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/glossaries/1e2d1c0b-9a8f-7e6d-5c4b-3a2f1e0d9c8b",
"mutuallyExclusive": false,
"tags": []
}

View File

@ -0,0 +1,53 @@
{
"id": "2d1c0b9a-8f7e-6d5c-4b3a-2f1e0d9c8b7a",
"name": "sample_term",
"displayName": "Sample Term",
"fullyQualifiedName": "sample_glossary.sample_term",
"description": "Mock glossary term for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/glossaryTerms/2d1c0b9a-8f7e-6d5c-4b3a-2f1e0d9c8b7a",
"glossary": {
"id": "1e2d1c0b-9a8f-7e6d-5c4b-3a2f1e0d9c8b",
"type": "glossary",
"name": "sample_glossary",
"fullyQualifiedName": "sample_glossary",
"deleted": false,
"href": "http://localhost:8585/api/v1/glossaries/1e2d1c0b-9a8f-7e6d-5c4b-3a2f1e0d9c8b"
},
"parent": null,
"synonyms": [],
"relatedTerms": [],
"references": [],
"style": {
"color": "#7147E8",
"iconURL": "https://example.com/icons/glossary.svg"
},
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"domains": [
{
"id": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
"type": "domain",
"name": "Engineering",
"fullyQualifiedName": "Engineering"
}
],
"reviewers": [
{
"id": "b2c3d4e5-f678-90ab-cdef-123456789012",
"type": "user",
"name": "jane.smith",
"fullyQualifiedName": "jane.smith"
}
],
"tags": []
}

View File

@ -0,0 +1,40 @@
{
"id": "2d1c0b9a-8f7e-6d5c-4b3a-2f1e0d9c8b7a",
"name": "sample_ingestion_pipeline",
"displayName": "Sample Ingestion Pipeline",
"fullyQualifiedName": "sample-database-service.sample_ingestion_pipeline",
"description": "Mock ingestion pipeline for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/ingestionPipelines/2d1c0b9a-8f7e-6d5c-4b3a-2f1e0d9c8b7a",
"pipelineType": "metadata",
"service": {
"id": "4a3f2e1d-0c9b-8a7f-6e5d-4c3b2a1f0e9d",
"type": "databaseService",
"name": "sample-database-service",
"fullyQualifiedName": "sample-database-service",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/databaseServices/4a3f2e1d-0c9b-8a7f-6e5d-4c3b2a1f0e9d"
},
"sourceConfig": {
"config": {
"type": "DatabaseMetadata"
}
},
"airflowConfig": {
"scheduleInterval": "0 0 * * *"
},
"enabled": true,
"deployed": true,
"loggerLevel": "INFO",
"processingEngine": {
"type": "Default",
"configuration": {}
},
"pipelineStatuses": {
"timestamp": 1699564800000,
"pipelineState": "success"
}
}

View File

@ -0,0 +1,24 @@
{
"id": "0f3e2d1c-0b9a-8f7e-6d5c-4b3a2f1e0d9c",
"name": "sample_location",
"displayName": "Sample Location",
"fullyQualifiedName": "sample-storage-service.sample_location",
"description": "Mock location for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"locationType": "Bucket",
"path": "s3://sample-bucket/data",
"href": "http://localhost:8585/api/v1/locations/0f3e2d1c-0b9a-8f7e-6d5c-4b3a2f1e0d9c",
"service": {
"id": "5a4f3e2d-1c0b-9a8f-7e6d-5c4b3a2f1e0d",
"type": "storageService",
"name": "sample-storage-service",
"fullyQualifiedName": "sample-storage-service",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/storageServices/5a4f3e2d-1c0b-9a8f-7e6d-5c4b3a2f1e0d"
},
"serviceType": "S3",
"tags": []
}

View File

@ -0,0 +1,28 @@
{
"id": "8d7c6b5a-4f3e-2d1c-0b9a-8f7e6d5c4b3a",
"name": "sample-messaging-service",
"displayName": "Sample Messaging Service",
"fullyQualifiedName": "sample-messaging-service",
"description": "Mock messaging service for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/messagingServices/8d7c6b5a-4f3e-2d1c-0b9a-8f7e6d5c4b3a",
"serviceType": "Kafka",
"connection": {
"config": {
"type": "Kafka",
"bootstrapServers": "localhost:9092"
}
},
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"tags": []
}

View File

@ -0,0 +1,28 @@
{
"id": "3e2d1c0b-9a8f-7e6d-5c4b-3a2f1e0d9c8b",
"name": "sample-metadata-service",
"displayName": "Sample Metadata Service",
"fullyQualifiedName": "sample-metadata-service",
"description": "Mock metadata service for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/metadataServices/3e2d1c0b-9a8f-7e6d-5c4b-3a2f1e0d9c8b",
"serviceType": "Amundsen",
"connection": {
"config": {
"type": "Amundsen",
"hostPort": "http://localhost:5000"
}
},
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"tags": []
}

View File

@ -0,0 +1,69 @@
{
"id": "bf8e7d6c-5a4f-3e2d-1c0b-9a8f7e6d5c4b",
"name": "sample_ml_model",
"displayName": "Sample ML Model",
"fullyQualifiedName": "sample-mlmodel-service.sample_ml_model",
"description": "Mock ML model for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"algorithm": "RandomForest",
"href": "http://localhost:8585/api/v1/mlmodels/bf8e7d6c-5a4f-3e2d-1c0b-9a8f7e6d5c4b",
"service": {
"id": "6b5a4f3e-2d1c-0b9a-8f7e-6d5c4b3a2f1e",
"type": "mlmodelService",
"name": "sample-mlmodel-service",
"fullyQualifiedName": "sample-mlmodel-service",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/mlmodelServices/6b5a4f3e-2d1c-0b9a-8f7e-6d5c4b3a2f1e"
},
"serviceType": "Mlflow",
"mlFeatures": [
{
"name": "age",
"dataType": "numerical",
"description": "Customer age"
},
{
"name": "category",
"dataType": "categorical",
"description": "Product category"
}
],
"mlHyperParameters": [
{
"name": "n_estimators",
"value": "100"
},
{
"name": "max_depth",
"value": "10"
}
],
"target": "purchase_probability",
"dashboard": {
"id": "a1b2c3d4-1111-2222-3333-444444444444",
"type": "dashboard",
"name": "sample_dashboard",
"fullyQualifiedName": "sample-dashboard-service.sample_dashboard",
"displayName": "Sample Dashboard"
},
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"domains": [
{
"id": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
"type": "domain",
"name": "Engineering",
"fullyQualifiedName": "Engineering"
}
],
"tags": []
}

View File

@ -0,0 +1,28 @@
{
"id": "6b5a4f3e-2d1c-0b9a-8f7e-6d5c4b3a2f1e",
"name": "sample-mlmodel-service",
"displayName": "Sample ML Model Service",
"fullyQualifiedName": "sample-mlmodel-service",
"description": "Mock ML model service for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/mlmodelServices/6b5a4f3e-2d1c-0b9a-8f7e-6d5c4b3a2f1e",
"serviceType": "Mlflow",
"connection": {
"config": {
"type": "Mlflow",
"trackingUri": "http://localhost:5000"
}
},
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"tags": []
}

View File

@ -0,0 +1,60 @@
{
"id": "af9e8d7c-6b5a-4f3e-2d1c-0b9a8f7e6d5c",
"name": "sample_pipeline",
"displayName": "Sample Pipeline",
"fullyQualifiedName": "sample-pipeline-service.sample_pipeline",
"description": "Mock pipeline for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"sourceUrl": "http://localhost:8080/pipelines/sample_pipeline",
"href": "http://localhost:8585/api/v1/pipelines/af9e8d7c-6b5a-4f3e-2d1c-0b9a8f7e6d5c",
"service": {
"id": "7c6b5a4f-3e2d-1c0b-9a8f-7e6d5c4b3a2f",
"type": "pipelineService",
"name": "sample-pipeline-service",
"fullyQualifiedName": "sample-pipeline-service",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/pipelineServices/7c6b5a4f-3e2d-1c0b-9a8f-7e6d5c4b3a2f"
},
"serviceType": "Airflow",
"pipelineStatus": {
"timestamp": 1699564800000,
"executionStatus": "Successful"
},
"tasks": [
{
"name": "extract_task",
"displayName": "Extract Data",
"description": "Extract data from source"
},
{
"name": "transform_task",
"displayName": "Transform Data",
"description": "Transform extracted data"
},
{
"name": "load_task",
"displayName": "Load Data",
"description": "Load transformed data"
}
],
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"domains": [
{
"id": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
"type": "domain",
"name": "Engineering",
"fullyQualifiedName": "Engineering"
}
],
"tags": []
}

View File

@ -0,0 +1,28 @@
{
"id": "7c6b5a4f-3e2d-1c0b-9a8f-7e6d5c4b3a2f",
"name": "sample-pipeline-service",
"displayName": "Sample Pipeline Service",
"fullyQualifiedName": "sample-pipeline-service",
"description": "Mock pipeline service for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/pipelineServices/7c6b5a4f-3e2d-1c0b-9a8f-7e6d5c4b3a2f",
"serviceType": "Airflow",
"connection": {
"config": {
"type": "Airflow",
"hostPort": "http://localhost:8080"
}
},
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"tags": []
}

View File

@ -0,0 +1,28 @@
{
"id": "5a4f3e2d-1c0b-9a8f-7e6d-5c4b3a2f1e0d",
"name": "sample-storage-service",
"displayName": "Sample Storage Service",
"fullyQualifiedName": "sample-storage-service",
"description": "Mock storage service for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/storageServices/5a4f3e2d-1c0b-9a8f-7e6d-5c4b3a2f1e0d",
"serviceType": "S3",
"connection": {
"config": {
"type": "S3",
"awsRegion": "us-east-1"
}
},
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"tags": []
}

View File

@ -0,0 +1,97 @@
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "sample_table",
"displayName": "Sample Table",
"fullyQualifiedName": "sample-service.sample-db.public.sample_table",
"description": "Mock table for notification template testing",
"version": 1.0,
"updatedAt": 1234567890000,
"updatedBy": "admin",
"href": "http://localhost:8585/api/v1/tables/3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tableType": "Regular",
"columns": [
{
"name": "id",
"dataType": "BIGINT",
"dataTypeDisplay": "bigint",
"description": "Unique identifier",
"fullyQualifiedName": "sample-service.sample-db.public.sample_table.id",
"ordinalPosition": 1
},
{
"name": "name",
"dataType": "VARCHAR",
"dataTypeDisplay": "varchar(255)",
"description": "User name",
"fullyQualifiedName": "sample-service.sample-db.public.sample_table.name",
"ordinalPosition": 2
},
{
"name": "email",
"dataType": "VARCHAR",
"dataTypeDisplay": "varchar(255)",
"description": "Contact email address",
"fullyQualifiedName": "sample-service.sample-db.public.sample_table.email",
"ordinalPosition": 3
}
],
"databaseSchema": {
"id": "7c7f9a8b-1234-5678-9abc-def012345678",
"type": "databaseSchema",
"name": "public",
"fullyQualifiedName": "sample-service.sample-db.public",
"description": "Public database schema",
"displayName": "Public"
},
"database": {
"id": "8d8e0b9c-2345-6789-0bcd-ef1234567890",
"type": "database",
"name": "sample-db",
"fullyQualifiedName": "sample-service.sample-db",
"description": "Sample database",
"displayName": "Sample DB"
},
"service": {
"id": "9e9f1cad-3456-789a-1cde-f23456789012",
"type": "databaseService",
"name": "sample-service",
"fullyQualifiedName": "sample-service",
"displayName": "Sample Service"
},
"serviceType": "Postgres",
"tableConstraints": [
{
"constraintType": "PRIMARY_KEY",
"columns": ["id"]
}
],
"tablePartition": {
"columns": ["created_date"],
"intervalType": "TIME-UNIT"
},
"dataModel": {
"id": "4f5a6b7c-8d9e-0f1a-2b3c-4d5e6f7a8b9c",
"type": "dashboardDataModel",
"name": "sample_data_model",
"fullyQualifiedName": "sample-dashboard-service.sample_data_model"
},
"sourceUrl": "https://github.com/organization/repo/blob/main/schema/sample_table.sql",
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"domains": [
{
"id": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
"type": "domain",
"name": "Engineering",
"fullyQualifiedName": "Engineering"
}
],
"tags": [],
"deleted": false
}

View File

@ -0,0 +1,24 @@
{
"id": "3c0b9a8f-7e6d-5c4b-3a2f-1e0d9c8b7a6f",
"name": "sample_tag",
"displayName": "Sample Tag",
"fullyQualifiedName": "sample_classification.sample_tag",
"description": "Mock tag for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"deprecated": false,
"href": "http://localhost:8585/api/v1/tags/3c0b9a8f-7e6d-5c4b-3a2f-1e0d9c8b7a6f",
"classification": {
"id": "4b9a8f7e-6d5c-4b3a-2f1e-0d9c8b7a6f5e",
"type": "classification",
"name": "sample_classification",
"fullyQualifiedName": "sample_classification",
"deleted": false
},
"style": {
"color": "#2196F3",
"iconURL": ""
}
}

View File

@ -0,0 +1,15 @@
{
"id": "4b9a8f7e-6d5c-4b3a-2f1e-0d9c8b7a6f5e",
"name": "sample_classification",
"displayName": "Sample Classification",
"fullyQualifiedName": "sample_classification",
"description": "Mock classification (tag category) for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/classifications/4b9a8f7e-6d5c-4b3a-2f1e-0d9c8b7a6f5e",
"mutuallyExclusive": false,
"disabled": false,
"provider": "User"
}

View File

@ -0,0 +1,63 @@
{
"id": "1c0b9a8f-7e6d-5c4b-3a2f-1e0d9c8b7a6f",
"name": "sample_test_case",
"displayName": "Sample Test Case",
"fullyQualifiedName": "sample_test_suite.sample_test_case",
"description": "Mock test case for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/dataQuality/testCases/1c0b9a8f-7e6d-5c4b-3a2f-1e0d9c8b7a6f",
"testSuite": {
"id": "0b9a8f7e-6d5c-4b3a-2f1e-0d9c8b7a6f5e",
"type": "testSuite",
"name": "sample_test_suite",
"fullyQualifiedName": "sample_test_suite",
"deleted": false,
"href": "http://localhost:8585/api/v1/dataQuality/testSuites/0b9a8f7e-6d5c-4b3a-2f1e-0d9c8b7a6f5e"
},
"entityLink": "<#E::table::sample-service.sample-db.public.sample_table>",
"testDefinition": {
"id": "9a8f7e6d-5c4b-3a2f-1e0d-9c8b7a6f5e4d",
"type": "testDefinition",
"name": "columnValuesToBeNotNull",
"fullyQualifiedName": "columnValuesToBeNotNull",
"deleted": false
},
"parameterValues": [
{
"name": "columnName",
"value": "id"
}
],
"testCaseResult": {
"timestamp": 1699564800000,
"testCaseStatus": "Success",
"result": "Test passed successfully"
},
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"testSuites": [
{
"id": "0b9a8f7e-6d5c-4b3a-2f1e-0d9c8b7a6f5e",
"type": "testSuite",
"name": "sample_test_suite",
"fullyQualifiedName": "sample_test_suite"
}
],
"domains": [
{
"id": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
"type": "domain",
"name": "Engineering",
"fullyQualifiedName": "Engineering"
}
]
}

View File

@ -0,0 +1,33 @@
{
"id": "0b9a8f7e-6d5c-4b3a-2f1e-0d9c8b7a6f5e",
"name": "sample_test_suite",
"displayName": "Sample Test Suite",
"fullyQualifiedName": "sample_test_suite",
"description": "Mock test suite for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/dataQuality/testSuites/0b9a8f7e-6d5c-4b3a-2f1e-0d9c8b7a6f5e",
"executableEntityReference": {
"id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"type": "table",
"name": "sample_table",
"fullyQualifiedName": "sample-service.sample-db.public.sample_table",
"deleted": false
},
"summary": {
"success": 8,
"failed": 2,
"aborted": 0,
"total": 10
},
"tests": [
{
"id": "1c0b9a8f-7e6d-5c4b-3a2f-1e0d9c8b7a6f",
"type": "testCase",
"name": "sample_test_case",
"fullyQualifiedName": "sample_test_suite.sample_test_case"
}
]
}

View File

@ -0,0 +1,46 @@
{
"id": "9f8e7d6c-5b4a-3f2e-1d0c-9b8a7f6e5d4c",
"name": "sample_topic",
"displayName": "Sample Topic",
"fullyQualifiedName": "sample-messaging-service.sample_topic",
"description": "Mock topic for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"partitions": 3,
"replicationFactor": 1,
"maximumMessageSize": 1048576,
"cleanupPolicies": ["delete"],
"href": "http://localhost:8585/api/v1/topics/9f8e7d6c-5b4a-3f2e-1d0c-9b8a7f6e5d4c",
"service": {
"id": "8d7c6b5a-4f3e-2d1c-0b9a-8f7e6d5c4b3a",
"type": "messagingService",
"name": "sample-messaging-service",
"fullyQualifiedName": "sample-messaging-service",
"deleted": false,
"href": "http://localhost:8585/api/v1/services/messagingServices/8d7c6b5a-4f3e-2d1c-0b9a-8f7e6d5c4b3a"
},
"serviceType": "Kafka",
"messageSchema": {
"schemaType": "Avro",
"schemaText": "{\"type\":\"record\",\"name\":\"SampleEvent\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"},{\"name\":\"timestamp\",\"type\":\"long\"}]}"
},
"owners": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "user",
"name": "john.doe",
"fullyQualifiedName": "john.doe"
}
],
"domains": [
{
"id": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
"type": "domain",
"name": "Engineering",
"fullyQualifiedName": "Engineering"
}
],
"tags": []
}

View File

@ -0,0 +1,72 @@
{
"id": "9a8f7e6d-5c4b-3a2f-1e0d-9c8b7a6f5e4d",
"name": "sample.user",
"displayName": "Sample User",
"fullyQualifiedName": "sample.user",
"description": "Mock user for notification template testing",
"version": 1.0,
"updatedAt": 1699564800000,
"updatedBy": "admin",
"deleted": false,
"href": "http://localhost:8585/api/v1/users/9a8f7e6d-5c4b-3a2f-1e0d-9c8b7a6f5e4d",
"email": "sample.user@example.com",
"timezone": "America/Los_Angeles",
"isBot": false,
"isAdmin": false,
"authenticationMechanism": {
"authType": "JWT",
"config": {}
},
"roles": [
{
"id": "8f7e6d5c-4b3a-2f1e-0d9c-8b7a6f5e4d3c",
"type": "role",
"name": "DataConsumer",
"fullyQualifiedName": "DataConsumer",
"deleted": false
}
],
"teams": [
{
"id": "7e6d5c4b-3a2f-1e0d-9c8b-7a6f5e4d3c2b",
"type": "team",
"name": "Data",
"fullyQualifiedName": "Data",
"deleted": false
}
],
"isEmailVerified": true,
"profile": {
"images": {
"image": "https://example.com/images/users/sample-user.jpg",
"image192": "https://example.com/images/users/sample-user-192.jpg",
"image512": "https://example.com/images/users/sample-user-512.jpg"
}
},
"personas": [
{
"id": "d4e5f6a7-8901-2345-6789-0abcdef12345",
"type": "persona",
"name": "DataAnalyst",
"fullyQualifiedName": "DataAnalyst"
}
],
"defaultPersona": {
"id": "d4e5f6a7-8901-2345-6789-0abcdef12345",
"type": "persona",
"name": "DataAnalyst",
"fullyQualifiedName": "DataAnalyst"
},
"domains": [
{
"id": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
"type": "domain",
"name": "Engineering",
"fullyQualifiedName": "Engineering"
}
],
"personaPreferences": {
"landingPage": "myData",
"assistantEnabled": true
}
}

View File

@ -2539,32 +2539,6 @@ public class EventSubscriptionResourceTest
templateTest.deleteEntity(template.getId(), ADMIN_AUTH_HEADERS);
}
@Test
void test_deleteTemplatePreservesSubscription(TestInfo test) throws IOException {
NotificationTemplateResourceTest templateTest = new NotificationTemplateResourceTest();
CreateNotificationTemplate createTemplate =
templateTest
.createRequest("template-to-delete-" + test.getDisplayName())
.withTemplateBody("<div>Will be deleted</div>");
NotificationTemplate template = templateTest.createEntity(createTemplate, ADMIN_AUTH_HEADERS);
EntityReference templateRef =
new EntityReference().withId(template.getId()).withType(Entity.NOTIFICATION_TEMPLATE);
CreateEventSubscription createSub =
createRequest("sub-survives-" + test.getDisplayName())
.withNotificationTemplate(templateRef);
EventSubscription subscription = createEntity(createSub, ADMIN_AUTH_HEADERS);
templateTest.deleteEntity(template.getId(), ADMIN_AUTH_HEADERS);
EventSubscription afterDelete = getEntity(subscription.getId(), ADMIN_AUTH_HEADERS);
assertNotNull(afterDelete, "Subscription should exist after template deletion");
assertNull(afterDelete.getNotificationTemplate(), "Template reference should be null");
deleteEntity(subscription.getId(), ADMIN_AUTH_HEADERS);
}
@Test
void test_querySubscriptionsByTemplate(TestInfo test) throws IOException {
NotificationTemplateResourceTest templateTest = new NotificationTemplateResourceTest();
@ -2624,67 +2598,4 @@ public class EventSubscriptionResourceTest
templateTest.deleteEntity(template1.getId(), ADMIN_AUTH_HEADERS);
templateTest.deleteEntity(template2.getId(), ADMIN_AUTH_HEADERS);
}
@Test
void test_compositeFlow_TemplateLifecycle(TestInfo test) throws IOException {
NotificationTemplateResourceTest templateTest = new NotificationTemplateResourceTest();
CreateNotificationTemplate createTemplate1 =
templateTest
.createRequest("composite-template1-" + test.getDisplayName())
.withTemplateBody("<div>Composite Template 1</div>");
NotificationTemplate template1 = templateTest.createEntity(createTemplate1, ADMIN_AUTH_HEADERS);
EntityReference template1Ref =
new EntityReference().withId(template1.getId()).withType(Entity.NOTIFICATION_TEMPLATE);
CreateEventSubscription createSub1 =
createRequest("composite-sub1-" + test.getDisplayName())
.withNotificationTemplate(template1Ref);
EventSubscription sub1 = createEntity(createSub1, ADMIN_AUTH_HEADERS);
CreateEventSubscription createSub2 =
createRequest("composite-sub2-" + test.getDisplayName())
.withNotificationTemplate(template1Ref);
EventSubscription sub2 = createEntity(createSub2, ADMIN_AUTH_HEADERS);
Map<String, String> params = new HashMap<>();
params.put("notificationTemplate", template1.getId().toString());
ResultList<EventSubscription> results = listEntities(params, ADMIN_AUTH_HEADERS);
assertEquals(2, results.getData().size(), "Should have 2 subscriptions with template1");
CreateNotificationTemplate createTemplate2 =
templateTest
.createRequest("composite-template2-" + test.getDisplayName())
.withTemplateBody("<div>Composite Template 2</div>");
NotificationTemplate template2 = templateTest.createEntity(createTemplate2, ADMIN_AUTH_HEADERS);
EntityReference template2Ref =
new EntityReference().withId(template2.getId()).withType(Entity.NOTIFICATION_TEMPLATE);
updateEntity(createSub1.withNotificationTemplate(template2Ref), OK, ADMIN_AUTH_HEADERS);
params.put("notificationTemplate", template1.getId().toString());
results = listEntities(params, ADMIN_AUTH_HEADERS);
assertEquals(1, results.getData().size(), "Should have 1 subscription with template1");
params.put("notificationTemplate", template2.getId().toString());
results = listEntities(params, ADMIN_AUTH_HEADERS);
assertEquals(1, results.getData().size(), "Should have 1 subscription with template2");
deleteEntity(sub2.getId(), ADMIN_AUTH_HEADERS);
params.put("notificationTemplate", template1.getId().toString());
results = listEntities(params, ADMIN_AUTH_HEADERS);
assertEquals(0, results.getData().size(), "Should have 0 subscriptions with template1");
templateTest.deleteEntity(template1.getId(), ADMIN_AUTH_HEADERS);
EventSubscription sub1After = getEntity(sub1.getId(), ADMIN_AUTH_HEADERS);
assertNotNull(sub1After, "Subscription should still exist");
assertEquals(template2.getId(), sub1After.getNotificationTemplate().getId());
deleteEntity(sub1.getId(), ADMIN_AUTH_HEADERS);
templateTest.deleteEntity(template2.getId(), ADMIN_AUTH_HEADERS);
}
}

View File

@ -22,6 +22,7 @@ import static jakarta.ws.rs.core.Response.Status.OK;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.openmetadata.service.util.EntityUtil.fieldUpdated;
import static org.openmetadata.service.util.TestUtils.ADMIN_AUTH_HEADERS;
@ -34,8 +35,10 @@ import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@ -45,12 +48,21 @@ import org.apache.http.client.HttpResponseException;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.openmetadata.schema.alert.type.EmailAlertConfig;
import org.openmetadata.schema.api.events.CreateEventSubscription;
import org.openmetadata.schema.api.events.CreateNotificationTemplate;
import org.openmetadata.schema.api.events.NotificationTemplateRenderRequest;
import org.openmetadata.schema.api.events.NotificationTemplateRenderResponse;
import org.openmetadata.schema.api.events.NotificationTemplateSendRequest;
import org.openmetadata.schema.api.events.NotificationTemplateValidationRequest;
import org.openmetadata.schema.api.events.NotificationTemplateValidationResponse;
import org.openmetadata.schema.entity.events.EventSubscription;
import org.openmetadata.schema.entity.events.NotificationTemplate;
import org.openmetadata.schema.entity.events.SubscriptionDestination;
import org.openmetadata.schema.type.ChangeDescription;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.ProviderType;
import org.openmetadata.schema.type.Webhook;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.schema.utils.ResultList;
import org.openmetadata.service.Entity;
@ -239,6 +251,175 @@ public class NotificationTemplateResourceTest
authHeaders);
}
public final NotificationTemplateRenderResponse renderTemplate(
NotificationTemplateRenderRequest request, Map<String, String> authHeaders)
throws HttpResponseException {
WebTarget target = getCollection().path("/render");
return TestUtils.post(
target, request, NotificationTemplateRenderResponse.class, OK.getStatusCode(), authHeaders);
}
public final NotificationTemplateValidationResponse sendTemplate(
NotificationTemplateSendRequest request, Map<String, String> authHeaders)
throws HttpResponseException {
WebTarget target = getCollection().path("/send");
return TestUtils.post(
target,
request,
NotificationTemplateValidationResponse.class,
OK.getStatusCode(),
authHeaders);
}
public final Response sendTemplateRaw(
NotificationTemplateSendRequest request, Map<String, String> authHeaders) {
WebTarget target = getCollection().path("/send");
return SecurityUtil.addHeaders(target, authHeaders)
.post(entity(request, MediaType.APPLICATION_JSON));
}
private SubscriptionDestination createEmailDestination(String... receivers) {
EmailAlertConfig config = new EmailAlertConfig().withReceivers(Set.of(receivers));
return new SubscriptionDestination()
.withType(SubscriptionDestination.SubscriptionType.EMAIL)
.withCategory(SubscriptionDestination.SubscriptionCategory.EXTERNAL)
.withConfig(JsonUtils.valueToTree(config));
}
private SubscriptionDestination createWebhookDestination(
SubscriptionDestination.SubscriptionType type, String endpoint) {
Webhook config = new Webhook().withEndpoint(URI.create(endpoint));
return new SubscriptionDestination()
.withType(type)
.withCategory(SubscriptionDestination.SubscriptionCategory.EXTERNAL)
.withConfig(JsonUtils.valueToTree(config))
.withTimeout(5000)
.withReadTimeout(5000);
}
private SubscriptionDestination createInternalDestination(
SubscriptionDestination.SubscriptionType type) {
return new SubscriptionDestination()
.withType(type)
.withCategory(SubscriptionDestination.SubscriptionCategory.TEAMS);
}
private NotificationTemplateSendRequest createSendRequest(
String templateSubject,
String templateBody,
String resource,
SubscriptionDestination... destinations) {
NotificationTemplateRenderRequest renderRequest =
new NotificationTemplateRenderRequest()
.withTemplateSubject(templateSubject)
.withTemplateBody(templateBody)
.withResource(resource);
return new NotificationTemplateSendRequest()
.withRenderRequest(renderRequest)
.withDestinations(List.of(destinations));
}
/**
* Helper method to build the expected email with the full envelope structure.
* This wraps the provided content in the complete OpenMetadata email envelope.
*
* @param content The notification content (without envelope)
* @return Complete HTML email with envelope
*/
private String createExpectedEmailWithEnvelope(String content) {
// Build the complete HTML envelope that matches the system template
String envelopeHtml =
"<!DOCTYPE html PUBLIC \"-//W3C//DTD HTML 4.01 Transitional//EN\" \"http://www.w3.org/TR/html4/loose.dtd\">"
+ "<html lang=\"en\" xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\">"
+ "<head>"
+ "<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\">"
+ "<meta name=\"x-apple-disable-message-reformatting\">"
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
+ "<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">"
+ "<title>OpenMetadata · Change Event</title>"
+ "<!--[if mso]><xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->"
+ "<style type=\"text/css\">"
+ "html, body { margin:0!important; padding:0!important; height:100%!important; width:100%!important; } "
+ "* { -ms-text-size-adjust:100%; -webkit-text-size-adjust:100%; } "
+ "table, td { mso-table-lspace:0pt; mso-table-rspace:0pt; } "
+ "table { border-collapse:collapse!important; } "
+ "img { -ms-interpolation-mode:bicubic; border:0; outline:none; text-decoration:none; display:block; } "
+ "a { text-decoration:none; } "
+ ".ExternalClass { width:100%; } "
+ ".ExternalClass, .ExternalClass * { line-height:100%; } "
+ "a[x-apple-data-detectors] { color:inherit!important; text-decoration:none!important; } "
+ "u + #body .full-wrap { width:100%!important; width:100vw!important; } "
+ ".preheader { display:none!important; visibility:hidden; opacity:0; color:transparent; height:0; width:0; overflow:hidden; mso-hide:all; } "
+ ".container { max-width:600px!important; table-layout:fixed!important; } "
+ ".content-card { word-break:break-word; overflow-wrap:anywhere; -ms-word-break:break-all; white-space:normal; max-width:100%!important; } "
+ ".content-scroll { display:block; max-width:100%!important; overflow-x:auto; -webkit-overflow-scrolling:touch; } "
+ "pre, code { white-space:pre-wrap!important; word-break:break-word!important; } "
+ "blockquote { margin:10px 0!important; padding:12px 16px!important; background-color:#f8f9fa!important; border-left:3px solid #6b7280!important; border-radius:3px!important; font-style:italic!important; color:#1f2937!important; } "
+ "@media (max-width:600px){ .container { width:100%!important; } .p-sm { padding-left:16px!important; padding-right:16px!important; } }"
+ "</style>"
+ "</head>"
+ "<body id=\"body\" style=\"margin:0; padding:0; background-color:#f6f7fb;\">"
+ "<div class=\"preheader\">Fresh activity spotted in your OpenMetadata environment.</div>"
+ "<center role=\"presentation\" style=\"width:100%; background-color:#f6f7fb;\">"
+ "<!--[if (gte mso 9)|(IE)]><table role=\"presentation\" width=\"100%\" bgcolor=\"#f6f7fb\"><tr><td align=\"center\"><![endif]-->"
+ "<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" class=\"full-wrap\" style=\"width:100%; background-color:#f6f7fb;\">"
+ "<tr><td align=\"center\" style=\"padding:24px 12px;\">"
+ "<!--[if (gte mso 9)|(IE)]><table role=\"presentation\" width=\"600\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\"><tr><td><![endif]-->"
+ "<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" class=\"container\" bgcolor=\"#ffffff\" style=\"max-width:600px; border-radius:14px; background-color:#ffffff;\">"
+ "<tr><td style=\"padding:0; border-top-left-radius:14px; border-top-right-radius:14px;\">"
+ "<!--[if mso]><v:rect xmlns:v=\"urn:schemas-microsoft-com:vml\" fill=\"true\" stroke=\"false\" style=\"width:600px;height:96px;\"><v:fill type=\"frame\" src=\"https://i.imgur.com/7fn1VBe.png\" /><v:textbox inset=\"0,0,0,0\"></v:textbox></v:rect><![endif]-->"
+ "<!--[if !mso]><!-- -->"
+ "<img src=\"https://i.imgur.com/7fn1VBe.png\" width=\"600\" alt=\"OpenMetadata · Change Event\" style=\"width:100%; height:auto; border-top-left-radius:14px; border-top-right-radius:14px;\">"
+ "<!--<![endif]-->"
+ "</td></tr>"
+ "<tr><td class=\"p-sm\" style=\"padding:20px 24px 8px 24px;\">"
+ "<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\">"
+ "<tr><td align=\"left\" style=\"font-family:-apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; font-size:14px; line-height:22px; color:#1f2937;\">"
+ "<strong>Heads up!</strong> Some fresh updates just landed in your OpenMetadata environment. Take a quick look at what's new below:"
+ "</td></tr></table></td></tr>"
+ "<tr><td class=\"p-sm\" style=\"padding:8px 24px 24px 24px;\">"
+ "<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"border-radius:12px;\">"
+ "<tr><td width=\"6\" style=\"background-color:#7247e8; border-top-left-radius:12px; border-bottom-left-radius:12px;\">&nbsp;</td>"
+ "<td bgcolor=\"#ffffff\" style=\"background-color:#ffffff; padding:16px; border-top-right-radius:12px; border-bottom-right-radius:12px;\">"
+ "<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\">"
+ "<tr><td align=\"left\" style=\"font-family:-apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; font-size:14px; line-height:22px; color:#1f2937;\">"
+ "<div class=\"content-card\"><div class=\"content-scroll\">"
+ content
+ "</div></div>"
+ "</td></tr></table></td></tr></table></td></tr>"
+ "<tr><td class=\"p-sm\" style=\"padding:0 24px 24px 24px;\">"
+ "<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\">"
+ "<tr><td align=\"left\" style=\"font-family:-apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; font-size:14px; line-height:22px; color:#1f2937;\">"
+ "<span style=\"font-weight:550;\">Happy exploring!</span><br>Thanks,<br><span style=\"color:#9ca3af; font-style:italic;\">OpenMetadata Team</span>"
+ "</td></tr></table></td></tr></table>"
+ "<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" class=\"container\" style=\"max-width:600px;\">"
+ "<tr><td class=\"p-sm\" align=\"left\" style=\"padding:16px 24px 6px 24px; font-family:-apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; font-size:12px; line-height:18px; color:#6b7280;\">"
+ "You're receiving this message as part of your OpenMetadata change notification subscription."
+ "</td></tr>"
+ "<tr><td class=\"p-sm\" align=\"left\" style=\"padding:0 24px 32px 24px; font-family:-apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; font-size:12px; line-height:18px; color:#6b7280;\">"
+ "Need a hand? The <a href=\"https://docs.open-metadata.org\" target=\"_blank\" style=\"color:#6b4cf6; text-decoration:underline;\">OpenMetadata docs</a> and <a href=\"https://open-metadata.org/community\" target=\"_blank\" style=\"color:#6b4cf6; text-decoration:underline;\">community</a> are ready to help."
+ "</td></tr></table>"
+ "<!--[if (gte mso 9)|(IE)]></td></tr></table><![endif]-->"
+ "</td></tr></table>"
+ "<!--[if (gte mso 9)|(IE)]></td></tr></table><![endif]-->"
+ "</center></body></html>";
return envelopeHtml;
}
private void assertRenderEquals(
String expectedSubject,
String expectedHtmlBody,
NotificationTemplateRenderResponse response) {
assertNotNull(response);
assertNotNull(response.getValidation());
assertTrue(response.getValidation().getIsValid(), "Validation should pass");
assertNotNull(response.getRender());
assertEquals(expectedSubject, response.getRender().getSubject(), "Subject should match");
assertEquals(expectedHtmlBody, response.getRender().getBody(), "HTML body should match");
}
@Test
void post_validNotificationTemplate_200(TestInfo test) throws IOException {
CreateNotificationTemplate create =
@ -768,10 +949,8 @@ public class NotificationTemplateResourceTest
NotificationTemplateValidationResponse validationResponse =
validateTemplate(request, ADMIN_AUTH_HEADERS);
assertNotNull(validationResponse.getTemplateBody());
assertTrue(validationResponse.getTemplateBody().getPassed());
assertNotNull(validationResponse.getTemplateSubject());
assertTrue(validationResponse.getTemplateSubject().getPassed());
assertTrue(validationResponse.getIsValid(), "Validation should pass");
assertNotNull(validationResponse);
}
@Test
@ -785,19 +964,15 @@ public class NotificationTemplateResourceTest
NotificationTemplateValidationResponse validationResponse =
validateTemplate(request, ADMIN_AUTH_HEADERS);
// Template body should fail validation
assertNotNull(validationResponse.getTemplateBody());
assertFalse(validationResponse.getTemplateBody().getPassed());
assertNotNull(validationResponse.getTemplateBody().getError());
assertFalse(validationResponse.getIsValid(), "Validation should fail");
assertNotNull(validationResponse.getBodyError());
assertTrue(
validationResponse.getTemplateBody().getError().contains("Template validation failed"));
// Template subject should also fail validation
assertNotNull(validationResponse.getTemplateSubject());
assertFalse(validationResponse.getTemplateSubject().getPassed());
assertNotNull(validationResponse.getTemplateSubject().getError());
validationResponse.getBodyError().contains("Template validation failed"),
"Body error should contain validation message");
assertNotNull(validationResponse.getSubjectError());
assertTrue(
validationResponse.getTemplateSubject().getError().contains("Template validation failed"));
validationResponse.getSubjectError().contains("Template validation failed"),
"Subject error should contain validation message");
}
@Test
@ -827,4 +1002,297 @@ public class NotificationTemplateResourceTest
.post(entity(request, MediaType.APPLICATION_JSON));
assertEquals(FORBIDDEN.getStatusCode(), response.getStatus());
}
@Test
void test_renderTemplate_table_200() throws HttpResponseException {
NotificationTemplateRenderRequest request =
new NotificationTemplateRenderRequest()
.withTemplateSubject("Table Update: {{entity.name}}")
.withTemplateBody(
"<h3>Table {{entity.name}} has been updated</h3>"
+ "<p>FQN: {{entity.fullyQualifiedName}}</p>"
+ "<p>Description: {{entity.description}}</p>"
+ "<ul>"
+ "{{#each entity.columns as |column|}}"
+ "<li>{{column.name}} - {{column.dataType}}</li>"
+ "{{/each}}"
+ "</ul>")
.withResource("table");
NotificationTemplateRenderResponse response = renderTemplate(request, ADMIN_AUTH_HEADERS);
String expectedSubject = "Table Update: sample_table";
String expectedContent =
"<h3>Table sample_table has been updated</h3>"
+ "<p>FQN: sample-service.sample-db.public.sample_table</p>"
+ "<p>Description: Mock table for notification template testing</p>"
+ "<ul>"
+ "<li>id - BIGINT</li>"
+ "<li>name - VARCHAR</li>"
+ "<li>email - VARCHAR</li>"
+ "</ul>";
String expectedBody = createExpectedEmailWithEnvelope(expectedContent);
assertRenderEquals(expectedSubject, expectedBody, response);
}
@Test
void test_renderTemplate_dashboard_200() throws HttpResponseException {
NotificationTemplateRenderRequest request =
new NotificationTemplateRenderRequest()
.withTemplateSubject("Dashboard Update: {{entity.displayName}}")
.withTemplateBody(
"<h3>Dashboard {{entity.displayName}}</h3>"
+ "<p>Name: {{entity.name}}</p>"
+ "<p>FQN: {{entity.fullyQualifiedName}}</p>"
+ "{{#if entity.description}}"
+ "<p>Description: {{entity.description}}</p>"
+ "{{/if}}")
.withResource("dashboard");
NotificationTemplateRenderResponse response = renderTemplate(request, ADMIN_AUTH_HEADERS);
String expectedSubject = "Dashboard Update: Sample Dashboard";
String expectedContent =
"<h3>Dashboard Sample Dashboard</h3>"
+ "<p>Name: sample_dashboard</p>"
+ "<p>FQN: sample-dashboard-service.sample_dashboard</p>"
+ "<p>Description: Mock dashboard for notification template testing</p>";
String expectedBody = createExpectedEmailWithEnvelope(expectedContent);
assertRenderEquals(expectedSubject, expectedBody, response);
}
@Test
void test_renderTemplate_unknownResource_usesGenericFixture() throws HttpResponseException {
// Unknown resources now use generic EntityInterface fixture instead of throwing errors
NotificationTemplateRenderRequest request =
new NotificationTemplateRenderRequest()
.withTemplateSubject("Unknown Entity: {{entity.name}}")
.withTemplateBody(
"<h3>Entity {{entity.name}}</h3>"
+ "<p>FQN: {{entity.fullyQualifiedName}}</p>"
+ "<p>Description: {{entity.description}}</p>")
.withResource("unknown-resource-type");
NotificationTemplateRenderResponse response = renderTemplate(request, ADMIN_AUTH_HEADERS);
// Should succeed with generic fixture values
assertNotNull(response);
assertNotNull(response.getValidation());
assertTrue(response.getValidation().getIsValid(), "Validation should pass");
assertNotNull(response.getRender());
String expectedSubject = "Unknown Entity: generic_entity";
assertEquals(expectedSubject, response.getRender().getSubject());
// Body should contain the generic fixture values
assertTrue(response.getRender().getBody().contains("generic_entity"));
assertTrue(response.getRender().getBody().contains("generic.entity"));
assertTrue(
response
.getRender()
.getBody()
.contains("Generic entity for notification template testing"));
}
@Test
void test_renderTemplate_allResource_usesGenericFixture() throws HttpResponseException {
// "all" resource now uses generic EntityInterface fixture instead of throwing errors
NotificationTemplateRenderRequest request =
new NotificationTemplateRenderRequest()
.withTemplateSubject("All Entities: {{entity.name}}")
.withTemplateBody(
"<h3>Entity {{entity.name}}</h3>" + "<p>Display Name: {{entity.displayName}}</p>")
.withResource("all");
NotificationTemplateRenderResponse response = renderTemplate(request, ADMIN_AUTH_HEADERS);
// Should succeed with generic fixture values
assertNotNull(response);
assertNotNull(response.getValidation());
assertTrue(response.getValidation().getIsValid(), "Validation should pass");
assertNotNull(response.getRender());
String expectedSubject = "All Entities: generic_entity";
assertEquals(expectedSubject, response.getRender().getSubject());
// Body should contain the generic fixture values
assertTrue(response.getRender().getBody().contains("Generic Entity"));
}
@Test
void test_renderTemplate_invalidSyntax_validationFailure() throws HttpResponseException {
NotificationTemplateRenderRequest request =
new NotificationTemplateRenderRequest()
.withTemplateSubject("Valid Subject")
.withTemplateBody("{{#if entity.name}} Missing end if tag")
.withResource("table");
NotificationTemplateRenderResponse response = renderTemplate(request, ADMIN_AUTH_HEADERS);
assertNotNull(response);
assertNotNull(response.getValidation());
assertFalse(response.getValidation().getIsValid(), "Validation should fail");
assertNotNull(response.getValidation().getBodyError());
assertTrue(
response.getValidation().getBodyError().contains("Template validation failed"),
"Body error should contain validation message");
}
@Test
void test_renderTemplate_403_forbidden() {
NotificationTemplateRenderRequest request =
new NotificationTemplateRenderRequest()
.withTemplateSubject("Table Update: {{entity.name}}")
.withTemplateBody("<p>{{entity.name}}</p>")
.withResource("table");
WebTarget target = getCollection().path("/render");
Response response =
SecurityUtil.addHeaders(target, TEST_AUTH_HEADERS)
.post(entity(request, MediaType.APPLICATION_JSON));
assertEquals(FORBIDDEN.getStatusCode(), response.getStatus());
}
@Test
void test_sendTemplate_emailDestination_200() throws HttpResponseException {
SubscriptionDestination emailDest = createEmailDestination("test@example.com");
NotificationTemplateSendRequest request =
createSendRequest(
"Test Email: {{entity.name}}",
"<p>Entity {{entity.name}} was updated</p>",
"table",
emailDest);
NotificationTemplateValidationResponse response = sendTemplate(request, ADMIN_AUTH_HEADERS);
assertNotNull(response);
assertTrue(response.getIsValid(), "Validation should pass");
}
@Test
void test_sendTemplate_webhookDestination_200() throws HttpResponseException {
SubscriptionDestination webhookDest =
createWebhookDestination(
SubscriptionDestination.SubscriptionType.SLACK, "http://localhost:9999/webhook");
NotificationTemplateSendRequest request =
createSendRequest(
"Test Webhook: {{entity.name}}",
"<p>Entity {{entity.name}} was updated</p>",
"table",
webhookDest);
NotificationTemplateValidationResponse response = sendTemplate(request, ADMIN_AUTH_HEADERS);
assertNotNull(response);
assertTrue(response.getIsValid(), "Validation should pass");
}
@Test
void test_sendTemplate_multipleDestinations_200() throws HttpResponseException {
SubscriptionDestination emailDest = createEmailDestination("test@example.com");
SubscriptionDestination webhookDest =
createWebhookDestination(
SubscriptionDestination.SubscriptionType.SLACK, "http://localhost:9999/webhook");
NotificationTemplateSendRequest request =
createSendRequest(
"Test Multiple: {{entity.name}}",
"<p>Entity {{entity.name}} was updated</p>",
"table",
emailDest,
webhookDest);
NotificationTemplateValidationResponse response = sendTemplate(request, ADMIN_AUTH_HEADERS);
assertNotNull(response);
assertTrue(response.getIsValid(), "Validation should pass");
}
@Test
void test_sendTemplate_internalDestination_400() {
SubscriptionDestination internalDest =
createInternalDestination(SubscriptionDestination.SubscriptionType.EMAIL);
NotificationTemplateSendRequest request =
createSendRequest(
"Test Internal: {{entity.name}}",
"<p>Entity {{entity.name}} was updated</p>",
"table",
internalDest);
Response response = sendTemplateRaw(request, ADMIN_AUTH_HEADERS);
assertEquals(BAD_REQUEST.getStatusCode(), response.getStatus());
String responseBody = response.readEntity(String.class);
assertTrue(
responseBody.contains("Only external destinations"),
"Error message should indicate only external destinations are supported");
}
@Test
void test_sendTemplate_invalidTemplate_validationFailure() throws HttpResponseException {
SubscriptionDestination emailDest = createEmailDestination("test@example.com");
NotificationTemplateSendRequest request =
createSendRequest(
"Valid Subject", "{{#if entity.name}} Missing end if tag", "table", emailDest);
NotificationTemplateValidationResponse response = sendTemplate(request, ADMIN_AUTH_HEADERS);
assertNotNull(response);
assertFalse(response.getIsValid(), "Validation should fail");
assertNotNull(response.getBodyError());
assertTrue(
response.getBodyError().contains("Template validation failed"),
"Body error should contain validation message");
}
@Test
void test_sendTemplate_403_forbidden() {
SubscriptionDestination emailDest = createEmailDestination("test@example.com");
NotificationTemplateSendRequest request =
createSendRequest(
"Test Email: {{entity.name}}",
"<p>Entity {{entity.name}} was updated</p>",
"table",
emailDest);
Response response = sendTemplateRaw(request, TEST_AUTH_HEADERS);
assertEquals(FORBIDDEN.getStatusCode(), response.getStatus());
}
@Test
void test_deleteTemplatePreservesSubscription(TestInfo test) throws IOException {
EventSubscriptionResourceTest subscriptionTest = new EventSubscriptionResourceTest();
CreateNotificationTemplate createTemplate =
createRequest("template-to-delete-" + test.getDisplayName())
.withTemplateBody("<div>Will be deleted</div>");
NotificationTemplate template = createEntity(createTemplate, ADMIN_AUTH_HEADERS);
EntityReference templateRef =
new EntityReference().withId(template.getId()).withType(Entity.NOTIFICATION_TEMPLATE);
CreateEventSubscription createSub =
subscriptionTest
.createRequest("sub-survives-" + test.getDisplayName())
.withNotificationTemplate(templateRef);
EventSubscription subscription = subscriptionTest.createEntity(createSub, ADMIN_AUTH_HEADERS);
deleteEntity(template.getId(), ADMIN_AUTH_HEADERS);
EventSubscription afterDelete =
subscriptionTest.getEntity(subscription.getId(), ADMIN_AUTH_HEADERS);
assertNotNull(afterDelete, "Subscription should exist after template deletion");
assertNull(afterDelete.getNotificationTemplate(), "Template reference should be null");
subscriptionTest.deleteEntity(subscription.getId(), ADMIN_AUTH_HEADERS);
}
}

View File

@ -0,0 +1,34 @@
{
"$id": "https://open-metadata.org/schema/api/events/notificationTemplateRenderRequest.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "NotificationTemplateRenderRequest",
"description": "Request to render a notification template with mock data for preview.",
"type": "object",
"javaType": "org.openmetadata.schema.api.events.NotificationTemplateRenderRequest",
"properties": {
"templateSubject": {
"description": "Handlebars template for notification subject line",
"type": "string",
"minLength": 1,
"maxLength": 255
},
"templateBody": {
"description": "Handlebars template content for rendering notifications",
"type": "string",
"minLength": 1,
"maxLength": 10240
},
"resource": {
"type": "string",
"description": "Entity type to mock (e.g., 'table', 'dashboard', 'task'). Falls back to generic fixture if not found.",
"minLength": 1,
"maxLength": 128
},
"eventType": {
"$ref": "../../type/changeEventType.json",
"description": "Optional. Specific event type to test (e.g., 'entityUpdated'). When omitted, generates a neutral ChangeEvent with no event-specific fields."
}
},
"required": ["templateSubject", "templateBody", "resource"],
"additionalProperties": false
}

View File

@ -0,0 +1,39 @@
{
"$id": "https://open-metadata.org/schema/api/events/notificationTemplateRenderResponse.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "NotificationTemplateRenderResponse",
"description": "Response from rendering a notification template with mock data, including validation and rendered output.",
"type": "object",
"javaType": "org.openmetadata.schema.api.events.NotificationTemplateRenderResponse",
"definitions": {
"templateRenderResult": {
"type": "object",
"description": "Rendered template output with subject and body strings.",
"javaType": "org.openmetadata.schema.api.events.TemplateRenderResult",
"properties": {
"subject": {
"type": "string",
"description": "Rendered template subject."
},
"body": {
"type": "string",
"description": "Rendered template body."
}
},
"required": ["subject", "body"],
"additionalProperties": false
}
},
"properties": {
"validation": {
"$ref": "./notificationTemplateValidationResponse.json",
"description": "Syntax validation results for the template."
},
"render": {
"$ref": "#/definitions/templateRenderResult",
"description": "Actual rendering results with mock data. Null if validation failed."
}
},
"required": ["validation"],
"additionalProperties": false
}

View File

@ -0,0 +1,24 @@
{
"$id": "https://open-metadata.org/schema/api/events/notificationTemplateSendRequest.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "NotificationTemplateSendRequest",
"description": "Request to render and send a notification template with mock data to external destinations.",
"type": "object",
"javaType": "org.openmetadata.schema.api.events.NotificationTemplateSendRequest",
"properties": {
"renderRequest": {
"$ref": "./notificationTemplateRenderRequest.json",
"description": "Template rendering parameters (template content, resource, eventType)"
},
"destinations": {
"type": "array",
"description": "External-only destinations for sending test notifications (Slack, Teams, GChat, Email, Webhook).",
"items": {
"$ref": "../../events/eventSubscription.json#/definitions/destination"
},
"minItems": 1
}
},
"required": ["renderRequest", "destinations"],
"additionalProperties": false
}

View File

@ -5,35 +5,20 @@
"description": "Response from notification template validation",
"type": "object",
"javaType": "org.openmetadata.schema.api.events.NotificationTemplateValidationResponse",
"definitions": {
"fieldValidation": {
"description": "Validation result for a template field",
"type": "object",
"javaType": "org.openmetadata.schema.api.events.FieldValidation",
"properties": {
"passed": {
"description": "Whether the field validation passed",
"type": "boolean"
},
"error": {
"description": "Error message if validation failed",
"type": "string",
"default": null
}
},
"required": ["passed"],
"additionalProperties": false
}
},
"properties": {
"templateBody": {
"description": "Validation result for template body",
"$ref": "#/definitions/fieldValidation"
"isValid": {
"description": "Whether both subject and body passed validation",
"type": "boolean"
},
"templateSubject": {
"description": "Validation result for template subject",
"$ref": "#/definitions/fieldValidation"
"subjectError": {
"description": "Error message if subject validation failed, null otherwise",
"type": ["string", "null"]
},
"bodyError": {
"description": "Error message if body validation failed, null otherwise",
"type": ["string", "null"]
}
},
"required": ["isValid"],
"additionalProperties": false
}

View File

@ -0,0 +1,63 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Request to render a notification template with mock data for preview.
*/
export interface NotificationTemplateRenderRequest {
/**
* Optional. Specific event type to test (e.g., 'entityUpdated'). When omitted, generates a
* neutral ChangeEvent with no event-specific fields.
*/
eventType?: EventType;
/**
* Entity type to mock (e.g., 'table', 'dashboard', 'task'). Falls back to generic fixture
* if not found.
*/
resource: string;
/**
* Handlebars template content for rendering notifications
*/
templateBody: string;
/**
* Handlebars template for notification subject line
*/
templateSubject: string;
}
/**
* Optional. Specific event type to test (e.g., 'entityUpdated'). When omitted, generates a
* neutral ChangeEvent with no event-specific fields.
*
* Type of event.
*/
export enum EventType {
EntityCreated = "entityCreated",
EntityDeleted = "entityDeleted",
EntityFieldsChanged = "entityFieldsChanged",
EntityNoChange = "entityNoChange",
EntityRestored = "entityRestored",
EntitySoftDeleted = "entitySoftDeleted",
EntityUpdated = "entityUpdated",
LogicalTestCaseAdded = "logicalTestCaseAdded",
PostCreated = "postCreated",
PostUpdated = "postUpdated",
SuggestionAccepted = "suggestionAccepted",
SuggestionCreated = "suggestionCreated",
SuggestionDeleted = "suggestionDeleted",
SuggestionRejected = "suggestionRejected",
SuggestionUpdated = "suggestionUpdated",
TaskClosed = "taskClosed",
TaskResolved = "taskResolved",
ThreadCreated = "threadCreated",
ThreadUpdated = "threadUpdated",
}

View File

@ -0,0 +1,62 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Response from rendering a notification template with mock data, including validation and
* rendered output.
*/
export interface NotificationTemplateRenderResponse {
/**
* Actual rendering results with mock data. Null if validation failed.
*/
render?: TemplateRenderResult;
/**
* Syntax validation results for the template.
*/
validation: NotificationTemplateValidationResponse;
}
/**
* Actual rendering results with mock data. Null if validation failed.
*
* Rendered template output with subject and body strings.
*/
export interface TemplateRenderResult {
/**
* Rendered template body.
*/
body: string;
/**
* Rendered template subject.
*/
subject: string;
}
/**
* Syntax validation results for the template.
*
* Response from notification template validation
*/
export interface NotificationTemplateValidationResponse {
/**
* Error message if body validation failed, null otherwise
*/
bodyError?: null | string;
/**
* Whether both subject and body passed validation
*/
isValid: boolean;
/**
* Error message if subject validation failed, null otherwise
*/
subjectError?: null | string;
}

View File

@ -0,0 +1,305 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Request to render and send a notification template with mock data to external
* destinations.
*/
export interface NotificationTemplateSendRequest {
/**
* External-only destinations for sending test notifications (Slack, Teams, GChat, Email,
* Webhook).
*/
destinations: Destination[];
/**
* Template rendering parameters (template content, resource, eventType)
*/
renderRequest: NotificationTemplateRenderRequest;
}
/**
* Subscription which has a type and the config.
*/
export interface Destination {
category: SubscriptionCategory;
config?: Webhook;
/**
* Maximum depth for downstream stakeholder notification traversal. If null, traverses
* without depth limit (with cycle protection).
*/
downstreamDepth?: number | null;
/**
* Is the subscription enabled.
*/
enabled?: boolean;
/**
* Unique identifier that identifies this Event Subscription.
*/
id?: string;
/**
* Enable notification of downstream entity stakeholders. When true, notifications will
* traverse lineage to include stakeholders of entities that consume data from the affected
* entity.
*/
notifyDownstream?: boolean;
/**
* Read timeout in seconds. (Default 12s).
*/
readTimeout?: number;
statusDetails?: TionStatus;
/**
* Connection timeout in seconds. (Default 10s).
*/
timeout?: number;
type: SubscriptionType;
}
/**
* Subscription Endpoint Type.
*/
export enum SubscriptionCategory {
Admins = "Admins",
Assignees = "Assignees",
External = "External",
Followers = "Followers",
Mentions = "Mentions",
Owners = "Owners",
Teams = "Teams",
Users = "Users",
}
/**
* This schema defines webhook for receiving events from OpenMetadata.
*
* This schema defines email config for receiving events from OpenMetadata.
*
* A generic map that can be deserialized later.
*/
export interface Webhook {
/**
* Endpoint to receive the webhook events over POST requests.
*/
endpoint?: string;
/**
* Custom headers to be sent with the webhook request.
*/
headers?: { [key: string]: any };
/**
* HTTP operation to send the webhook request. Supports POST or PUT.
*/
httpMethod?: HTTPMethod;
/**
* Query parameters to be added to the webhook request URL.
*/
queryParams?: { [key: string]: any };
/**
* List of receivers to send mail to
*/
receivers?: string[];
/**
* Secret set by the webhook client used for computing HMAC SHA256 signature of webhook
* payload and sent in `X-OM-Signature` header in POST requests to publish the events.
*/
secretKey?: string;
/**
* Send the Event to Admins
*
* Send the Mails to Admins
*/
sendToAdmins?: boolean;
/**
* Send the Event to Followers
*
* Send the Mails to Followers
*/
sendToFollowers?: boolean;
/**
* Send the Event to Owners
*
* Send the Mails to Owners
*/
sendToOwners?: boolean;
[property: string]: any;
}
/**
* HTTP operation to send the webhook request. Supports POST or PUT.
*/
export enum HTTPMethod {
Post = "POST",
Put = "PUT",
}
/**
* Current status of the subscription, including details on the last successful and failed
* attempts, and retry information.
*
* Detailed status of the destination during a test operation, including HTTP response
* information.
*/
export interface TionStatus {
/**
* Timestamp of the last failed callback in UNIX UTC epoch time in milliseconds.
*/
lastFailedAt?: number;
/**
* Detailed reason for the last failure received during callback.
*/
lastFailedReason?: string;
/**
* HTTP status code received during the last failed callback attempt.
*/
lastFailedStatusCode?: number;
/**
* Timestamp of the last successful callback in UNIX UTC epoch time in milliseconds.
*/
lastSuccessfulAt?: number;
/**
* Timestamp for the next retry attempt in UNIX epoch time in milliseconds. Only valid if
* `status` is `awaitingRetry`.
*/
nextAttempt?: number;
/**
* Status is `disabled` when the event subscription was created with `enabled` set to false
* and it never started publishing events. Status is `active` when the event subscription is
* functioning normally and a 200 OK response was received for the callback notification.
* Status is `failed` when a bad callback URL, connection failures, or `1xx` or `3xx`
* response was received for the callback notification. Status is `awaitingRetry` when the
* previous attempt at callback timed out or received a `4xx` or `5xx` response. Status is
* `retryLimitReached` after all retries fail.
*
* Overall test status, indicating if the test operation succeeded or failed.
*/
status?: Status;
/**
* Current timestamp of this status in UNIX epoch time in milliseconds.
*
* Timestamp when the response was received, in UNIX epoch time milliseconds.
*/
timestamp?: number;
/**
* Body of the HTTP response, if any, returned by the server.
*/
entity?: string;
/**
* HTTP headers returned in the response as a map of header names to values.
*/
headers?: any;
/**
* URL location if the response indicates a redirect or newly created resource.
*/
location?: string;
/**
* Media type of the response entity, if specified (e.g., application/json).
*/
mediaType?: string;
/**
* Detailed reason for failure if the test did not succeed.
*/
reason?: string;
/**
* HTTP status code of the response (e.g., 200 for OK, 404 for Not Found).
*/
statusCode?: number;
/**
* HTTP status reason phrase associated with the status code (e.g., 'Not Found').
*/
statusInfo?: string;
}
/**
* Status is `disabled` when the event subscription was created with `enabled` set to false
* and it never started publishing events. Status is `active` when the event subscription is
* functioning normally and a 200 OK response was received for the callback notification.
* Status is `failed` when a bad callback URL, connection failures, or `1xx` or `3xx`
* response was received for the callback notification. Status is `awaitingRetry` when the
* previous attempt at callback timed out or received a `4xx` or `5xx` response. Status is
* `retryLimitReached` after all retries fail.
*
* Overall test status, indicating if the test operation succeeded or failed.
*/
export enum Status {
Active = "active",
AwaitingRetry = "awaitingRetry",
Disabled = "disabled",
Failed = "failed",
RetryLimitReached = "retryLimitReached",
StatusFailed = "Failed",
Success = "Success",
}
/**
* Subscription Endpoint Type.
*/
export enum SubscriptionType {
ActivityFeed = "ActivityFeed",
Email = "Email",
GChat = "GChat",
GovernanceWorkflowChangeEvent = "GovernanceWorkflowChangeEvent",
MSTeams = "MsTeams",
Slack = "Slack",
Webhook = "Webhook",
}
/**
* Template rendering parameters (template content, resource, eventType)
*
* Request to render a notification template with mock data for preview.
*/
export interface NotificationTemplateRenderRequest {
/**
* Optional. Specific event type to test (e.g., 'entityUpdated'). When omitted, generates a
* neutral ChangeEvent with no event-specific fields.
*/
eventType?: EventType;
/**
* Entity type to mock (e.g., 'table', 'dashboard', 'task'). Falls back to generic fixture
* if not found.
*/
resource: string;
/**
* Handlebars template content for rendering notifications
*/
templateBody: string;
/**
* Handlebars template for notification subject line
*/
templateSubject: string;
}
/**
* Optional. Specific event type to test (e.g., 'entityUpdated'). When omitted, generates a
* neutral ChangeEvent with no event-specific fields.
*
* Type of event.
*/
export enum EventType {
EntityCreated = "entityCreated",
EntityDeleted = "entityDeleted",
EntityFieldsChanged = "entityFieldsChanged",
EntityNoChange = "entityNoChange",
EntityRestored = "entityRestored",
EntitySoftDeleted = "entitySoftDeleted",
EntityUpdated = "entityUpdated",
LogicalTestCaseAdded = "logicalTestCaseAdded",
PostCreated = "postCreated",
PostUpdated = "postUpdated",
SuggestionAccepted = "suggestionAccepted",
SuggestionCreated = "suggestionCreated",
SuggestionDeleted = "suggestionDeleted",
SuggestionRejected = "suggestionRejected",
SuggestionUpdated = "suggestionUpdated",
TaskClosed = "taskClosed",
TaskResolved = "taskResolved",
ThreadCreated = "threadCreated",
ThreadUpdated = "threadUpdated",
}

View File

@ -15,29 +15,15 @@
*/
export interface NotificationTemplateValidationResponse {
/**
* Validation result for template body
* Error message if body validation failed, null otherwise
*/
templateBody?: FieldValidation;
bodyError?: null | string;
/**
* Validation result for template subject
* Whether both subject and body passed validation
*/
templateSubject?: FieldValidation;
}
/**
* Validation result for template body
*
* Validation result for a template field
*
* Validation result for template subject
*/
export interface FieldValidation {
/**
* Error message if validation failed
*/
error?: string;
/**
* Whether the field validation passed
*/
passed: boolean;
isValid: boolean;
/**
* Error message if subject validation failed, null otherwise
*/
subjectError?: null | string;
}