1- import { MonitoringService } from '../monitoring' ;
1+ import { MonitoringService , calculateSlaCreditAmount } from '../monitoring' ;
22import type { TransactionEvent } from '../types' ;
33
44const makeEvent = (
@@ -15,6 +15,21 @@ const makeEvent = (
1515 gasUsed,
1616} ) ;
1717
18+ const makeSlaEvent = (
19+ subscriptionId : string ,
20+ status : TransactionEvent [ 'status' ] ,
21+ timestamp : number = Date . now ( ) ,
22+ id = Math . random ( ) . toString ( 36 )
23+ ) : TransactionEvent => ( {
24+ id,
25+ subscriptionId,
26+ amount : 10 ,
27+ currency : 'USD' ,
28+ status,
29+ timestamp,
30+ gasUsed : 100_000 ,
31+ } ) ;
32+
1833describe ( 'MonitoringService' , ( ) => {
1934 let svc : MonitoringService ;
2035 beforeEach ( ( ) => {
@@ -112,4 +127,287 @@ describe('MonitoringService', () => {
112127 expect ( dash . successRate ) . toBe ( 1 ) ;
113128 expect ( dash . activeAlerts ) . toHaveLength ( 0 ) ;
114129 } ) ;
130+
131+ // ── SLA target configuration ──────────────────────────────────────────────
132+
133+ describe ( 'SLA monitoring — target configuration' , ( ) => {
134+ it ( 'registers an SLA target and reports a compliant initial status' , ( ) => {
135+ svc . setSlaTarget ( 'sub-sla' , { uptimeTarget : 99 , measurementInterval : 86_400 } ) ;
136+ const status = svc . getSlaStatus ( 'sub-sla' ) ;
137+ expect ( status ) . not . toBeNull ( ) ;
138+ expect ( status ! . uptimeTarget ) . toBe ( 99 ) ;
139+ expect ( status ! . uptimePercentage ) . toBe ( 100 ) ;
140+ expect ( status ! . compliant ) . toBe ( true ) ;
141+ expect ( status ! . observedTransactions ) . toBe ( 0 ) ;
142+ expect ( svc . getSlaBreaches ( 'sub-sla' ) ) . toHaveLength ( 0 ) ;
143+ } ) ;
144+
145+ it ( 'normalizes invalid target values' , ( ) => {
146+ svc . setSlaTarget ( 'sub-bad' , {
147+ uptimeTarget : Number . NaN ,
148+ measurementInterval : - 50 ,
149+ creditCap : - 3 ,
150+ } ) ;
151+ const target = svc . getSlaTarget ( 'sub-bad' ) ;
152+ expect ( target ) . toEqual ( {
153+ uptimeTarget : 99 ,
154+ measurementInterval : 1 ,
155+ creditCap : 0 ,
156+ } ) ;
157+ } ) ;
158+
159+ it ( 'clamps uptime target into 0–100' , ( ) => {
160+ svc . setSlaTarget ( 'sub-clamp' , { uptimeTarget : 150 , measurementInterval : 60 } ) ;
161+ expect ( svc . getSlaTarget ( 'sub-clamp' ) ! . uptimeTarget ) . toBe ( 100 ) ;
162+ } ) ;
163+
164+ it ( 'updates an existing target in place' , ( ) => {
165+ svc . setSlaTarget ( 'sub-upd' , { uptimeTarget : 99 , measurementInterval : 60 } ) ;
166+ svc . setSlaTarget ( 'sub-upd' , { uptimeTarget : 99.9 , measurementInterval : 120 } ) ;
167+ expect ( svc . getSlaTarget ( 'sub-upd' ) ) . toEqual ( {
168+ uptimeTarget : 99.9 ,
169+ measurementInterval : 120 ,
170+ creditCap : 0 ,
171+ } ) ;
172+ } ) ;
173+
174+ it ( 'returns null status and no breaches after removeSlaTarget' , ( ) => {
175+ svc . setSlaTarget ( 'sub-rm' , { uptimeTarget : 99 , measurementInterval : 86_400 } ) ;
176+ svc . recordTransaction ( makeSlaEvent ( 'sub-rm' , 'failed' ) ) ;
177+ expect ( svc . getSlaBreaches ( 'sub-rm' ) ) . toHaveLength ( 1 ) ;
178+
179+ svc . removeSlaTarget ( 'sub-rm' ) ;
180+ expect ( svc . getSlaStatus ( 'sub-rm' ) ) . toBeNull ( ) ;
181+ expect ( svc . getSlaTarget ( 'sub-rm' ) ) . toBeUndefined ( ) ;
182+ // Breach history is retained; no open alert remains for the subscription.
183+ expect ( svc . getSlaBreaches ( 'sub-rm' ) ) . toHaveLength ( 1 ) ;
184+ expect ( svc . getActiveAlerts ( ) . some ( ( a ) => a . ruleId === 'sla-breach:sub-rm' ) ) . toBe ( false ) ;
185+ } ) ;
186+ } ) ;
187+
188+ // ── SLA breach detection ──────────────────────────────────────────────────
189+
190+ describe ( 'SLA monitoring — breach detection' , ( ) => {
191+ it ( 'detects a breach when uptime drops below target and issues credit' , ( ) => {
192+ svc . setSlaTarget ( 'sub-breach' , { uptimeTarget : 99 , measurementInterval : 86_400 } ) ;
193+ svc . recordTransaction ( makeSlaEvent ( 'sub-breach' , 'success' ) ) ;
194+ svc . recordTransaction ( makeSlaEvent ( 'sub-breach' , 'failed' ) ) ;
195+
196+ const breaches = svc . getSlaBreaches ( 'sub-breach' ) ;
197+ expect ( breaches ) . toHaveLength ( 1 ) ;
198+ expect ( breaches [ 0 ] . resolvedAt ) . toBeNull ( ) ;
199+ expect ( breaches [ 0 ] . uptimePercentage ) . toBe ( 50 ) ;
200+ expect ( breaches [ 0 ] . observedTransactions ) . toBe ( 2 ) ;
201+ expect ( breaches [ 0 ] . failedTransactions ) . toBe ( 1 ) ;
202+ expect ( breaches [ 0 ] . creditAmount ) . toBeGreaterThan ( 0 ) ;
203+
204+ const status = svc . getSlaStatus ( 'sub-breach' ) ;
205+ expect ( status ! . compliant ) . toBe ( false ) ;
206+ expect ( status ! . activeBreachId ) . toBe ( breaches [ 0 ] . id ) ;
207+ expect ( status ! . creditBalance ) . toBe ( breaches [ 0 ] . creditAmount ) ;
208+ } ) ;
209+
210+ it ( 'raises an SLA alert alongside the breach' , ( ) => {
211+ svc . setSlaTarget ( 'sub-alert' , { uptimeTarget : 99 , measurementInterval : 86_400 } ) ;
212+ svc . recordTransaction ( makeSlaEvent ( 'sub-alert' , 'failed' ) ) ;
213+
214+ const alert = svc . getActiveAlerts ( ) . find ( ( a ) => a . ruleId === 'sla-breach:sub-alert' ) ;
215+ expect ( alert ) . toBeDefined ( ) ;
216+ expect ( alert ! . severity ) . toBe ( 'critical' ) ; // deviation 99% ≥ 5
217+ expect ( alert ! . correlationId ) . toBe ( svc . getSlaBreaches ( 'sub-alert' ) [ 0 ] . id ) ;
218+ } ) ;
219+
220+ it ( 'does not open a breach while uptime stays at or above the target' , ( ) => {
221+ svc . setSlaTarget ( 'sub-ok' , { uptimeTarget : 99 , measurementInterval : 86_400 } ) ;
222+ svc . recordTransaction ( makeSlaEvent ( 'sub-ok' , 'success' ) ) ;
223+ svc . recordTransaction ( makeSlaEvent ( 'sub-ok' , 'success' ) ) ;
224+ svc . recordTransaction ( makeSlaEvent ( 'sub-ok' , 'failed' ) ) ; // 66.67% → breach
225+
226+ expect ( svc . getSlaBreaches ( 'sub-ok' ) ) . toHaveLength ( 1 ) ;
227+
228+ // A second failure keeps the same single open breach (no duplicates).
229+ svc . recordTransaction ( makeSlaEvent ( 'sub-ok' , 'failed' ) ) ;
230+ expect ( svc . getSlaBreaches ( 'sub-ok' ) ) . toHaveLength ( 1 ) ;
231+ expect ( svc . getSlaBreaches ( 'sub-ok' ) [ 0 ] . resolvedAt ) . toBeNull ( ) ;
232+ } ) ;
233+
234+ it ( 'ignores pending transactions when computing uptime' , ( ) => {
235+ svc . setSlaTarget ( 'sub-pending' , { uptimeTarget : 99 , measurementInterval : 86_400 } ) ;
236+ svc . recordTransaction ( makeSlaEvent ( 'sub-pending' , 'pending' ) ) ;
237+ svc . recordTransaction ( makeSlaEvent ( 'sub-pending' , 'failed' ) ) ;
238+ svc . recordTransaction ( makeSlaEvent ( 'sub-pending' , 'success' ) ) ;
239+
240+ const status = svc . getSlaStatus ( 'sub-pending' ) ;
241+ expect ( status ! . observedTransactions ) . toBe ( 2 ) ;
242+ expect ( status ! . uptimePercentage ) . toBe ( 50 ) ;
243+ expect ( svc . getSlaBreaches ( 'sub-pending' ) ) . toHaveLength ( 1 ) ;
244+ } ) ;
245+
246+ it ( 'ignores transactions outside the measurement window' , ( ) => {
247+ const interval = 3_600 ;
248+ svc . setSlaTarget ( 'sub-window' , { uptimeTarget : 99 , measurementInterval : interval } ) ;
249+ const stale = Date . now ( ) - ( interval * 1000 + 5_000 ) ;
250+ svc . recordTransaction ( makeSlaEvent ( 'sub-window' , 'failed' , stale ) ) ;
251+
252+ const status = svc . getSlaStatus ( 'sub-window' ) ;
253+ expect ( status ! . observedTransactions ) . toBe ( 0 ) ;
254+ expect ( status ! . compliant ) . toBe ( true ) ;
255+ expect ( svc . getSlaBreaches ( 'sub-window' ) ) . toHaveLength ( 0 ) ;
256+ } ) ;
257+
258+ it ( 'auto-resolves the breach and its alert when uptime recovers' , ( ) => {
259+ svc . setSlaTarget ( 'sub-recover' , { uptimeTarget : 99 , measurementInterval : 86_400 } ) ;
260+ svc . recordTransaction ( makeSlaEvent ( 'sub-recover' , 'failed' ) ) ;
261+ svc . recordTransaction ( makeSlaEvent ( 'sub-recover' , 'success' ) ) ; // 50% → breach
262+
263+ expect ( svc . getSlaBreaches ( 'sub-recover' ) ) . toHaveLength ( 1 ) ;
264+ expect ( svc . getActiveAlerts ( ) . some ( ( a ) => a . ruleId === 'sla-breach:sub-recover' ) ) . toBe ( true ) ;
265+
266+ // 99 successes + 1 failure = 99% uptime → back at target.
267+ for ( let i = 0 ; i < 98 ; i ++ ) {
268+ svc . recordTransaction ( makeSlaEvent ( 'sub-recover' , 'success' ) ) ;
269+ }
270+
271+ const status = svc . getSlaStatus ( 'sub-recover' ) ;
272+ expect ( status ! . compliant ) . toBe ( true ) ;
273+ expect ( svc . getSlaBreaches ( 'sub-recover' ) [ 0 ] . resolvedAt ) . not . toBeNull ( ) ;
274+ expect ( status ! . activeBreachId ) . toBeNull ( ) ;
275+ expect ( svc . getActiveAlerts ( ) . some ( ( a ) => a . ruleId === 'sla-breach:sub-recover' ) ) . toBe ( false ) ;
276+ } ) ;
277+
278+ it ( 'tracks a full breach lifecycle: detect → resolve → detect again' , ( ) => {
279+ svc . setSlaTarget ( 'sub-lifecycle' , { uptimeTarget : 99 , measurementInterval : 86_400 } ) ;
280+ svc . recordTransaction ( makeSlaEvent ( 'sub-lifecycle' , 'failed' ) ) ;
281+ svc . recordTransaction ( makeSlaEvent ( 'sub-lifecycle' , 'success' ) ) ; // breach #1
282+
283+ for ( let i = 0 ; i < 98 ; i ++ ) {
284+ svc . recordTransaction ( makeSlaEvent ( 'sub-lifecycle' , 'success' ) ) ;
285+ } // 99% → resolved
286+
287+ svc . recordTransaction ( makeSlaEvent ( 'sub-lifecycle' , 'failed' ) ) ; // 98.02% → breach #2
288+ svc . recordTransaction ( makeSlaEvent ( 'sub-lifecycle' , 'failed' ) ) ;
289+
290+ const breaches = svc . getSlaBreaches ( 'sub-lifecycle' ) ;
291+ expect ( breaches ) . toHaveLength ( 2 ) ;
292+ // Newest breach first, and it is the open one.
293+ expect ( breaches [ 0 ] . resolvedAt ) . toBeNull ( ) ;
294+ expect ( breaches [ 1 ] . resolvedAt ) . not . toBeNull ( ) ;
295+ expect ( svc . getSlaStatus ( 'sub-lifecycle' ) ! . breachCount ) . toBe ( 2 ) ;
296+ } ) ;
297+
298+ it ( 'monitors multiple subscriptions independently' , ( ) => {
299+ svc . setSlaTarget ( 'sub-a' , { uptimeTarget : 99 , measurementInterval : 86_400 } ) ;
300+ svc . setSlaTarget ( 'sub-b' , { uptimeTarget : 99 , measurementInterval : 86_400 } ) ;
301+
302+ svc . recordTransaction ( makeSlaEvent ( 'sub-a' , 'failed' ) ) ;
303+ svc . recordTransaction ( makeSlaEvent ( 'sub-a' , 'success' ) ) ;
304+ svc . recordTransaction ( makeSlaEvent ( 'sub-b' , 'success' ) ) ;
305+
306+ expect ( svc . getSlaBreaches ( 'sub-a' ) ) . toHaveLength ( 1 ) ;
307+ expect ( svc . getSlaBreaches ( 'sub-b' ) ) . toHaveLength ( 0 ) ;
308+ expect ( svc . getSlaStatus ( 'sub-b' ) ! . compliant ) . toBe ( true ) ;
309+ } ) ;
310+ } ) ;
311+
312+ // ── SLA breach management ─────────────────────────────────────────────────
313+
314+ describe ( 'SLA monitoring — breach management' , ( ) => {
315+ it ( 'manually resolves a breach and its alert' , ( ) => {
316+ svc . setSlaTarget ( 'sub-manual' , { uptimeTarget : 99 , measurementInterval : 86_400 } ) ;
317+ svc . recordTransaction ( makeSlaEvent ( 'sub-manual' , 'failed' ) ) ;
318+ const breach = svc . getSlaBreaches ( 'sub-manual' ) [ 0 ] ;
319+
320+ svc . resolveSlaBreach ( breach . id ) ;
321+ expect ( svc . getSlaBreaches ( 'sub-manual' ) [ 0 ] . resolvedAt ) . not . toBeNull ( ) ;
322+ expect ( svc . getActiveAlerts ( ) . some ( ( a ) => a . ruleId === 'sla-breach:sub-manual' ) ) . toBe ( false ) ;
323+ expect ( svc . getSlaStatus ( 'sub-manual' ) ! . activeBreachId ) . toBeNull ( ) ;
324+ } ) ;
325+
326+ it ( 'acknowledges a breach' , ( ) => {
327+ svc . setSlaTarget ( 'sub-ack' , { uptimeTarget : 99 , measurementInterval : 86_400 } ) ;
328+ svc . recordTransaction ( makeSlaEvent ( 'sub-ack' , 'failed' ) ) ;
329+ const breach = svc . getSlaBreaches ( 'sub-ack' ) [ 0 ] ;
330+
331+ svc . acknowledgeSlaBreach ( breach . id ) ;
332+ expect ( svc . getSlaBreaches ( 'sub-ack' ) [ 0 ] . acknowledged ) . toBe ( true ) ;
333+ } ) ;
334+
335+ it ( 'is a no-op for unknown breach ids' , ( ) => {
336+ expect ( ( ) => svc . resolveSlaBreach ( 'nope' ) ) . not . toThrow ( ) ;
337+ expect ( ( ) => svc . acknowledgeSlaBreach ( 'nope' ) ) . not . toThrow ( ) ;
338+ } ) ;
339+ } ) ;
340+
341+ // ── SLA credit calculation ────────────────────────────────────────────────
342+
343+ describe ( 'calculateSlaCreditAmount' , ( ) => {
344+ const target = { uptimeTarget : 99 , measurementInterval : 86_400 , creditCap : 0 } ;
345+
346+ it ( 'returns 0 when uptime is at or above target' , ( ) => {
347+ expect ( calculateSlaCreditAmount ( target , 99 ) ) . toBe ( 0 ) ;
348+ expect ( calculateSlaCreditAmount ( target , 99.5 ) ) . toBe ( 0 ) ;
349+ } ) ;
350+
351+ it ( 'returns at least 1 for any breach' , ( ) => {
352+ expect ( calculateSlaCreditAmount ( target , 98.9999 ) ) . toBeGreaterThanOrEqual ( 1 ) ;
353+ } ) ;
354+
355+ it ( 'scales credit with the size of the deficit' , ( ) => {
356+ const small = calculateSlaCreditAmount ( target , 98 ) ;
357+ const large = calculateSlaCreditAmount ( target , 50 ) ;
358+ expect ( large ) . toBeGreaterThan ( small ) ;
359+ } ) ;
360+
361+ it ( 'respects the credit cap when set' , ( ) => {
362+ const capped = calculateSlaCreditAmount ( { ...target , creditCap : 250 } , 50 ) ;
363+ expect ( capped ) . toBe ( 250 ) ;
364+ } ) ;
365+
366+ it ( 'is unaffected by an explicit zero cap' , ( ) => {
367+ const uncapped = calculateSlaCreditAmount ( { ...target , creditCap : 0 } , 50 ) ;
368+ expect ( uncapped ) . toBe ( calculateSlaCreditAmount ( target , 50 ) ) ;
369+ } ) ;
370+ } ) ;
371+
372+ // ── SLA summary & dashboard integration ───────────────────────────────────
373+
374+ describe ( 'SLA monitoring — summary and dashboard' , ( ) => {
375+ it ( 'exposes SLA data through getDashboard' , ( ) => {
376+ svc . setSlaTarget ( 'sub-dash' , { uptimeTarget : 99 , measurementInterval : 86_400 } ) ;
377+ svc . recordTransaction ( makeSlaEvent ( 'sub-dash' , 'failed' ) ) ;
378+ svc . recordTransaction ( makeSlaEvent ( 'sub-dash' , 'success' ) ) ;
379+
380+ const dash = svc . getDashboard ( ) ;
381+ expect ( dash . slaStatuses ) . toHaveLength ( 1 ) ;
382+ expect ( dash . slaStatuses [ 0 ] . subscriptionId ) . toBe ( 'sub-dash' ) ;
383+ expect ( dash . slaBreaches ) . toHaveLength ( 1 ) ;
384+ expect ( dash . slaSummary . totalMonitored ) . toBe ( 1 ) ;
385+ expect ( dash . slaSummary . breached ) . toBe ( 1 ) ;
386+ expect ( dash . slaSummary . openBreaches ) . toBe ( 1 ) ;
387+ expect ( dash . slaSummary . totalCreditsIssued ) . toBeGreaterThan ( 0 ) ;
388+ } ) ;
389+
390+ it ( 'aggregates summary across healthy and breached subscriptions' , ( ) => {
391+ svc . setSlaTarget ( 'sub-healthy' , { uptimeTarget : 99 , measurementInterval : 86_400 } ) ;
392+ svc . setSlaTarget ( 'sub-broken' , { uptimeTarget : 99 , measurementInterval : 86_400 } ) ;
393+ svc . recordTransaction ( makeSlaEvent ( 'sub-healthy' , 'success' ) ) ;
394+ svc . recordTransaction ( makeSlaEvent ( 'sub-broken' , 'failed' ) ) ;
395+
396+ const summary = svc . getSlaSummary ( ) ;
397+ expect ( summary . totalMonitored ) . toBe ( 2 ) ;
398+ expect ( summary . compliant ) . toBe ( 1 ) ;
399+ expect ( summary . breached ) . toBe ( 1 ) ;
400+ expect ( summary . openBreaches ) . toBe ( 1 ) ;
401+ } ) ;
402+
403+ it ( 'returns an empty summary when nothing is monitored' , ( ) => {
404+ expect ( svc . getSlaSummary ( ) ) . toEqual ( {
405+ totalMonitored : 0 ,
406+ compliant : 0 ,
407+ breached : 0 ,
408+ openBreaches : 0 ,
409+ totalCreditsIssued : 0 ,
410+ } ) ;
411+ } ) ;
412+ } ) ;
115413} ) ;
0 commit comments