Skip to content

Commit ca61c9c

Browse files
author
Kilo
committed
feat(notification): build subscription webhook system with event filtering
- Implemented WebhookEventFilterEngine with topic wildcards, attribute comparisons, and logical rule combinations - Added exclusion patterns and payload field projection support - Added developer portal documentation for event filtering in developer-portal/docs/webhook-guide.md - Added comprehensive unit test suite in backend/services/notification/__tests__/webhookFilter.test.ts Closes #955
1 parent 4a62952 commit ca61c9c

4 files changed

Lines changed: 540 additions & 0 deletions

File tree

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import {
2+
WebhookEventFilterEngine,
3+
webhookFilterEngine,
4+
matchEventPattern,
5+
evaluateAttributeRule,
6+
getNestedProperty,
7+
WebhookFilterConfig,
8+
} from '../webhookFilterEngine';
9+
10+
describe('WebhookEventFilterEngine', () => {
11+
let engine: WebhookEventFilterEngine;
12+
13+
beforeEach(() => {
14+
engine = new WebhookEventFilterEngine();
15+
});
16+
17+
describe('Nested Property Extraction', () => {
18+
it('extracts top-level and nested properties correctly', () => {
19+
const payload = {
20+
id: 'evt_123',
21+
type: 'subscription.created',
22+
data: {
23+
subscription: {
24+
id: 'sub_999',
25+
price: 49.99,
26+
currency: 'USD',
27+
},
28+
tags: ['enterprise', 'priority'],
29+
},
30+
};
31+
32+
expect(getNestedProperty(payload, 'id')).toBe('evt_123');
33+
expect(getNestedProperty(payload, 'type')).toBe('subscription.created');
34+
expect(getNestedProperty(payload, 'data.subscription.price')).toBe(49.99);
35+
expect(getNestedProperty(payload, 'data.subscription.currency')).toBe('USD');
36+
expect(getNestedProperty(payload, 'data.nonexistent.field')).toBeUndefined();
37+
expect(getNestedProperty(null, 'id')).toBeUndefined();
38+
});
39+
});
40+
41+
describe('Wildcard and Pattern Matching', () => {
42+
it('matches exact event types', () => {
43+
expect(matchEventPattern('subscription.created', 'subscription.created')).toBe(true);
44+
expect(matchEventPattern('subscription.created', 'payment.succeeded')).toBe(false);
45+
});
46+
47+
it('matches wildcard prefix patterns (* and .*)', () => {
48+
expect(matchEventPattern('*', 'subscription.created')).toBe(true);
49+
expect(matchEventPattern('subscription.*', 'subscription.created')).toBe(true);
50+
expect(matchEventPattern('subscription.*', 'subscription.renewed')).toBe(true);
51+
expect(matchEventPattern('subscription.*', 'payment.succeeded')).toBe(false);
52+
expect(matchEventPattern('payment.*', 'payment.failed')).toBe(true);
53+
});
54+
55+
it('matches suffix patterns (*.suffix)', () => {
56+
expect(matchEventPattern('*.created', 'subscription.created')).toBe(true);
57+
expect(matchEventPattern('*.created', 'invoice.created')).toBe(true);
58+
expect(matchEventPattern('*.created', 'subscription.cancelled')).toBe(false);
59+
});
60+
});
61+
62+
describe('Attribute Condition Evaluations', () => {
63+
const payload = {
64+
type: 'payment.succeeded',
65+
data: {
66+
amount: 150,
67+
currency: 'USDC',
68+
customer: {
69+
tier: 'gold',
70+
region: 'NA',
71+
riskScore: 12,
72+
},
73+
tags: ['web3', 'recurring'],
74+
},
75+
};
76+
77+
it('evaluates comparison operators (eq, neq, gt, gte, lt, lte)', () => {
78+
expect(evaluateAttributeRule(payload, { field: 'data.amount', operator: 'gt', value: 100 })).toBe(true);
79+
expect(evaluateAttributeRule(payload, { field: 'data.amount', operator: 'lt', value: 50 })).toBe(false);
80+
expect(evaluateAttributeRule(payload, { field: 'data.amount', operator: 'gte', value: 150 })).toBe(true);
81+
expect(evaluateAttributeRule(payload, { field: 'data.currency', operator: 'eq', value: 'USDC' })).toBe(true);
82+
expect(evaluateAttributeRule(payload, { field: 'data.currency', operator: 'neq', value: 'EUR' })).toBe(true);
83+
});
84+
85+
it('evaluates in and nin operators', () => {
86+
expect(evaluateAttributeRule(payload, { field: 'data.customer.tier', operator: 'in', value: ['gold', 'platinum'] })).toBe(true);
87+
expect(evaluateAttributeRule(payload, { field: 'data.customer.tier', operator: 'in', value: ['silver', 'bronze'] })).toBe(false);
88+
expect(evaluateAttributeRule(payload, { field: 'data.customer.region', operator: 'nin', value: ['EU', 'APAC'] })).toBe(true);
89+
});
90+
91+
it('evaluates contains, regex, and exists operators', () => {
92+
expect(evaluateAttributeRule(payload, { field: 'data.tags', operator: 'contains', value: 'web3' })).toBe(true);
93+
expect(evaluateAttributeRule(payload, { field: 'data.currency', operator: 'regex', value: '^USD?C$' })).toBe(true);
94+
expect(evaluateAttributeRule(payload, { field: 'data.customer.riskScore', operator: 'exists', value: true })).toBe(true);
95+
expect(evaluateAttributeRule(payload, { field: 'data.customer.missingField', operator: 'exists', value: false })).toBe(true);
96+
});
97+
});
98+
99+
describe('Complete Webhook Event Filter Evaluation', () => {
100+
const sampleEvent = {
101+
id: 'evt_abc123',
102+
type: 'subscription.created',
103+
data: {
104+
plan: {
105+
id: 'enterprise_tier',
106+
price: 250,
107+
currency: 'USD',
108+
},
109+
subscriber: {
110+
id: 'user_456',
111+
country: 'US',
112+
},
113+
},
114+
};
115+
116+
it('accepts event when no filter is provided or filter is disabled', () => {
117+
const result = engine.evaluate(sampleEvent, undefined);
118+
expect(result.isMatch).toBe(true);
119+
120+
const disabledResult = engine.evaluate(sampleEvent, { enabled: false });
121+
expect(disabledResult.isMatch).toBe(true);
122+
});
123+
124+
it('rejects events matching exclude patterns', () => {
125+
const filter: WebhookFilterConfig = {
126+
enabled: true,
127+
eventPatterns: ['subscription.*'],
128+
excludePatterns: ['subscription.created'],
129+
};
130+
131+
const result = engine.evaluate(sampleEvent, filter);
132+
expect(result.isMatch).toBe(false);
133+
expect(result.reason).toContain('exclusion pattern');
134+
});
135+
136+
it('evaluates AND combination rules correctly', () => {
137+
const filter: WebhookFilterConfig = {
138+
enabled: true,
139+
eventPatterns: ['subscription.*'],
140+
ruleCombination: 'AND',
141+
attributeRules: [
142+
{ field: 'data.plan.price', operator: 'gte', value: 200 },
143+
{ field: 'data.plan.currency', operator: 'eq', value: 'USD' },
144+
],
145+
};
146+
147+
const result = engine.evaluate(sampleEvent, filter);
148+
expect(result.isMatch).toBe(true);
149+
150+
// Failing rule
151+
const failingFilter: WebhookFilterConfig = {
152+
...filter,
153+
attributeRules: [
154+
...filter.attributeRules!,
155+
{ field: 'data.plan.price', operator: 'gt', value: 500 },
156+
],
157+
};
158+
159+
const failingResult = engine.evaluate(sampleEvent, failingFilter);
160+
expect(failingResult.isMatch).toBe(false);
161+
expect(failingResult.failedRule?.field).toBe('data.plan.price');
162+
});
163+
164+
it('evaluates OR combination rules correctly', () => {
165+
const filter: WebhookFilterConfig = {
166+
enabled: true,
167+
eventPatterns: ['subscription.*'],
168+
ruleCombination: 'OR',
169+
attributeRules: [
170+
{ field: 'data.plan.price', operator: 'gt', value: 1000 }, // Fails
171+
{ field: 'data.subscriber.country', operator: 'eq', value: 'US' }, // Passes
172+
],
173+
};
174+
175+
const result = engine.evaluate(sampleEvent, filter);
176+
expect(result.isMatch).toBe(true);
177+
});
178+
179+
it('projects specified fields when fieldProjections is configured', () => {
180+
const filter: WebhookFilterConfig = {
181+
enabled: true,
182+
eventPatterns: ['*'],
183+
fieldProjections: ['id', 'type', 'data.plan.price'],
184+
};
185+
186+
const result = engine.evaluate(sampleEvent, filter);
187+
expect(result.isMatch).toBe(true);
188+
expect(result.processedPayload).toEqual({
189+
id: 'evt_abc123',
190+
type: 'subscription.created',
191+
'data.plan.price': 250,
192+
});
193+
});
194+
195+
it('simulates filter runs for developer portal with execution telemetry', () => {
196+
const filter: WebhookFilterConfig = {
197+
enabled: true,
198+
eventPatterns: ['subscription.created'],
199+
attributeRules: [{ field: 'data.plan.price', operator: 'gt', value: 100 }],
200+
};
201+
202+
const simulation = engine.simulate(sampleEvent, filter);
203+
expect(simulation.passed).toBe(true);
204+
expect(simulation.executionTimeMs).toBeGreaterThanOrEqual(0);
205+
});
206+
});
207+
});

0 commit comments

Comments
 (0)