feat(alerting): NotificationContextBuilder for template context maps
Builds the Mustache context map from AlertRule + AlertInstance + Environment. Always emits env/rule/alert subtrees; conditionally emits kind-specific subtrees (agent, app, route, exchange, log, metric, deployment) based on rule.conditionKind(). Missing instance.context() keys resolve to empty string. alert.link prefixed with uiOrigin when non-null. 11 unit tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
package com.cameleer.server.app.alerting.notify;
|
||||
|
||||
import com.cameleer.server.core.alerting.*;
|
||||
import com.cameleer.server.core.runtime.Environment;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class NotificationContextBuilderTest {
|
||||
|
||||
private NotificationContextBuilder builder;
|
||||
|
||||
private static final UUID ENV_ID = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
private static final UUID RULE_ID = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||
private static final UUID INST_ID = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
builder = new NotificationContextBuilder();
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private Environment env() {
|
||||
return new Environment(ENV_ID, "prod", "Production", true, true, Map.of(), 5, Instant.EPOCH);
|
||||
}
|
||||
|
||||
private AlertRule rule(ConditionKind kind) {
|
||||
AlertCondition condition = switch (kind) {
|
||||
case ROUTE_METRIC -> new RouteMetricCondition(
|
||||
new AlertScope("my-app", "route-1", null),
|
||||
RouteMetric.ERROR_RATE, Comparator.GT, 0.1, 60);
|
||||
case EXCHANGE_MATCH -> new ExchangeMatchCondition(
|
||||
new AlertScope("my-app", "route-1", null),
|
||||
new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()),
|
||||
FireMode.PER_EXCHANGE, null, null, 30);
|
||||
case AGENT_STATE -> new AgentStateCondition(
|
||||
new AlertScope(null, null, null),
|
||||
"DEAD", 0);
|
||||
case DEPLOYMENT_STATE -> new DeploymentStateCondition(
|
||||
new AlertScope("my-app", null, null),
|
||||
List.of("FAILED"));
|
||||
case LOG_PATTERN -> new LogPatternCondition(
|
||||
new AlertScope("my-app", null, null),
|
||||
"ERROR", "OutOfMemory", 5, 60);
|
||||
case JVM_METRIC -> new JvmMetricCondition(
|
||||
new AlertScope(null, null, "agent-1"),
|
||||
"heap.used", AggregationOp.MAX, Comparator.GT, 90.0, 300);
|
||||
};
|
||||
return new AlertRule(
|
||||
RULE_ID, ENV_ID, "High error rate", "Alert description",
|
||||
AlertSeverity.CRITICAL, true,
|
||||
kind, condition,
|
||||
60, 120, 30,
|
||||
"{{rule.name}} fired", "Value: {{alert.currentValue}}",
|
||||
List.of(), List.of(),
|
||||
Instant.now(), null, Instant.now(),
|
||||
Map.of(),
|
||||
Instant.now(), "admin", Instant.now(), "admin"
|
||||
);
|
||||
}
|
||||
|
||||
private AlertInstance instance(Map<String, Object> ctx) {
|
||||
return new AlertInstance(
|
||||
INST_ID, RULE_ID, Map.of(), ENV_ID,
|
||||
AlertState.FIRING, AlertSeverity.CRITICAL,
|
||||
Instant.parse("2026-04-19T10:00:00Z"),
|
||||
null, null, null, null,
|
||||
false, 0.95, 0.1,
|
||||
ctx, "Alert fired", "Some message",
|
||||
List.of(), List.of(), List.of()
|
||||
);
|
||||
}
|
||||
|
||||
// ---- env / rule / alert subtrees always present ----
|
||||
|
||||
@Test
|
||||
void envSubtree_alwaysPresent() {
|
||||
var inst = instance(Map.of("route", Map.of("id", "route-1"), "app", Map.of("slug", "my-app")));
|
||||
var ctx = builder.build(rule(ConditionKind.ROUTE_METRIC), inst, env(), null);
|
||||
|
||||
assertThat(ctx).containsKey("env");
|
||||
@SuppressWarnings("unchecked") var env = (Map<String, Object>) ctx.get("env");
|
||||
assertThat(env).containsEntry("slug", "prod")
|
||||
.containsEntry("id", ENV_ID.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ruleSubtree_alwaysPresent() {
|
||||
var inst = instance(Map.of());
|
||||
var ctx = builder.build(rule(ConditionKind.AGENT_STATE), inst, env(), null);
|
||||
|
||||
@SuppressWarnings("unchecked") var ruleMap = (Map<String, Object>) ctx.get("rule");
|
||||
assertThat(ruleMap).containsEntry("id", RULE_ID.toString())
|
||||
.containsEntry("name", "High error rate")
|
||||
.containsEntry("severity", "CRITICAL")
|
||||
.containsEntry("description", "Alert description");
|
||||
}
|
||||
|
||||
@Test
|
||||
void alertSubtree_alwaysPresent() {
|
||||
var inst = instance(Map.of());
|
||||
var ctx = builder.build(rule(ConditionKind.AGENT_STATE), inst, env(), "https://ui.example.com");
|
||||
|
||||
@SuppressWarnings("unchecked") var alert = (Map<String, Object>) ctx.get("alert");
|
||||
assertThat(alert).containsEntry("id", INST_ID.toString())
|
||||
.containsEntry("state", "FIRING")
|
||||
.containsEntry("firedAt", "2026-04-19T10:00:00Z")
|
||||
.containsEntry("currentValue", "0.95")
|
||||
.containsEntry("threshold", "0.1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void alertLink_withUiOrigin() {
|
||||
var inst = instance(Map.of());
|
||||
var ctx = builder.build(rule(ConditionKind.AGENT_STATE), inst, env(), "https://ui.example.com");
|
||||
|
||||
@SuppressWarnings("unchecked") var alert = (Map<String, Object>) ctx.get("alert");
|
||||
assertThat(alert.get("link")).isEqualTo("https://ui.example.com/alerts/inbox/" + INST_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void alertLink_withoutUiOrigin_isRelative() {
|
||||
var inst = instance(Map.of());
|
||||
var ctx = builder.build(rule(ConditionKind.AGENT_STATE), inst, env(), null);
|
||||
|
||||
@SuppressWarnings("unchecked") var alert = (Map<String, Object>) ctx.get("alert");
|
||||
assertThat(alert.get("link")).isEqualTo("/alerts/inbox/" + INST_ID);
|
||||
}
|
||||
|
||||
// ---- conditional subtrees by kind ----
|
||||
|
||||
@Test
|
||||
void exchangeMatch_hasExchangeAppRoute_butNotLogOrMetric() {
|
||||
var ctx = Map.<String, Object>of(
|
||||
"exchange", Map.of("id", "ex-99", "status", "FAILED"),
|
||||
"app", Map.of("slug", "my-app", "id", "app-uuid"),
|
||||
"route", Map.of("id", "route-1", "uri", "direct:start"));
|
||||
var result = builder.build(rule(ConditionKind.EXCHANGE_MATCH), instance(ctx), env(), null);
|
||||
|
||||
assertThat(result).containsKeys("exchange", "app", "route")
|
||||
.doesNotContainKey("log")
|
||||
.doesNotContainKey("metric")
|
||||
.doesNotContainKey("agent");
|
||||
}
|
||||
|
||||
@Test
|
||||
void agentState_hasAgentAndApp_butNotRoute() {
|
||||
var ctx = Map.<String, Object>of(
|
||||
"agent", Map.of("id", "a-42", "name", "my-agent", "state", "DEAD"),
|
||||
"app", Map.of("slug", "my-app", "id", "app-uuid"));
|
||||
var result = builder.build(rule(ConditionKind.AGENT_STATE), instance(ctx), env(), null);
|
||||
|
||||
assertThat(result).containsKeys("agent", "app")
|
||||
.doesNotContainKey("route")
|
||||
.doesNotContainKey("exchange")
|
||||
.doesNotContainKey("log")
|
||||
.doesNotContainKey("metric");
|
||||
}
|
||||
|
||||
@Test
|
||||
void routeMetric_hasRouteAndApp_butNotAgentOrExchange() {
|
||||
var ctx = Map.<String, Object>of(
|
||||
"route", Map.of("id", "route-1", "uri", "timer://tick"),
|
||||
"app", Map.of("slug", "my-app", "id", "app-uuid"));
|
||||
var result = builder.build(rule(ConditionKind.ROUTE_METRIC), instance(ctx), env(), null);
|
||||
|
||||
assertThat(result).containsKeys("route", "app")
|
||||
.doesNotContainKey("agent")
|
||||
.doesNotContainKey("exchange")
|
||||
.doesNotContainKey("log");
|
||||
}
|
||||
|
||||
@Test
|
||||
void logPattern_hasLogAndApp_butNotRouteOrAgent() {
|
||||
var ctx = Map.<String, Object>of(
|
||||
"log", Map.of("pattern", "ERROR", "matchCount", "7"),
|
||||
"app", Map.of("slug", "my-app", "id", "app-uuid"));
|
||||
var result = builder.build(rule(ConditionKind.LOG_PATTERN), instance(ctx), env(), null);
|
||||
|
||||
assertThat(result).containsKeys("log", "app")
|
||||
.doesNotContainKey("route")
|
||||
.doesNotContainKey("agent")
|
||||
.doesNotContainKey("metric");
|
||||
}
|
||||
|
||||
@Test
|
||||
void jvmMetric_hasMetricAgentAndApp() {
|
||||
var ctx = Map.<String, Object>of(
|
||||
"metric", Map.of("name", "heap.used", "value", "88.5"),
|
||||
"agent", Map.of("id", "a-42", "name", "my-agent"),
|
||||
"app", Map.of("slug", "my-app", "id", "app-uuid"));
|
||||
var result = builder.build(rule(ConditionKind.JVM_METRIC), instance(ctx), env(), null);
|
||||
|
||||
assertThat(result).containsKeys("metric", "agent", "app")
|
||||
.doesNotContainKey("route")
|
||||
.doesNotContainKey("exchange")
|
||||
.doesNotContainKey("log");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingContextValues_emitEmptyString() {
|
||||
// Empty context — subtree values should all be empty string, not null.
|
||||
var inst = instance(Map.of());
|
||||
var result = builder.build(rule(ConditionKind.ROUTE_METRIC), inst, env(), null);
|
||||
|
||||
@SuppressWarnings("unchecked") var route = (Map<String, Object>) result.get("route");
|
||||
assertThat(route.get("id")).isEqualTo("");
|
||||
assertThat(route.get("uri")).isEqualTo("");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user