import {describe, expect, test} from 'bun:test' import { DeleteAlarmsCommand, DescribeAlarmHistoryCommand, DescribeAlarmsCommand, DisableAlarmActionsCommand, EnableAlarmActionsCommand, GetMetricDataCommand, ListMetricsCommand, PutCompositeAlarmCommand, PutMetricAlarmCommand, SetAlarmStateCommand, type CloudWatchClient, } from '@aws-sdk/client-cloudwatch' import {AwsMonitoringAdapter} from './AwsMonitoringAdapter' import {ValidationError} from '../cloud-spi/errors' type SendResult = Record function stubCloudWatch(handler: (command: object) => SendResult | Promise) { const sent: object[] = [] const client = { async send(command: object) { sent.push(command) return handler(command) }, } as unknown as CloudWatchClient return {adapter: new AwsMonitoringAdapter(client), sent} } const UPDATED = new Date('2026-10-25T10:00:00Z') const metricAlarm = { AlarmName: 'high-errors', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:high-errors', StateValue: 'ALARM', StateReason: 'Threshold crossed', StateUpdatedTimestamp: UPDATED, AlarmConfigurationUpdatedTimestamp: UPDATED, ActionsEnabled: true, AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: [], Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}], Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold', } const compositeAlarm = { AlarmName: 'app-unhealthy', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:app-unhealthy', StateValue: 'OK', AlarmRule: 'ALARM("high-errors")', ActionsEnabled: false, AlarmConfigurationUpdatedTimestamp: UPDATED, } describe('AwsMonitoringAdapter alarms', () => { test('lists metric and composite alarms with a readable condition', async () => { const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {MetricAlarms: [metricAlarm], CompositeAlarms: [compositeAlarm]} : {}) const alarms = await adapter.list() expect((sent[0] as DescribeAlarmsCommand).input.AlarmTypes).toEqual(['MetricAlarm', 'CompositeAlarm']) expect(alarms.map((alarm) => [alarm.name, alarm.status, alarm.metadata.alarmType])).toEqual([['app-unhealthy', 'OK', 'composite'], ['high-errors', 'ALARM', 'metric']]) expect(alarms[1].metadata).toMatchObject({condition: 'Errors Sum > 5 for 2 × 5 min', dimensions: [{name: 'FunctionName', value: 'checkout'}], threshold: 5, periodSeconds: 300}) expect(alarms[0].metadata).toMatchObject({condition: 'ALARM("high-errors")', actionsEnabled: false}) expect(await adapter.list({search: 'errors'})).toHaveLength(1) }) test('creates a metric alarm that notifies an SNS topic both ways', async () => { const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {MetricAlarms: [metricAlarm]} : {}) await adapter.create({values: { alarmType: 'metric', name: 'high-errors', namespace: 'App', metricName: 'Errors', dimensions: 'FunctionName=checkout\n', statistic: 'Sum', period: '300', comparison: 'GreaterThanThreshold', threshold: '5', evaluationPeriods: '2', topicArn: 'arn:aws:sns:us-east-1:000000000000:alerts', }}) expect((sent[0] as PutMetricAlarmCommand).input).toMatchObject({ AlarmName: 'high-errors', Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}], Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold', AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], }) }) test('creates a composite alarm from its rule', async () => { const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {CompositeAlarms: [compositeAlarm]} : {}) const created = await adapter.create({values: {alarmType: 'composite', name: 'app-unhealthy', alarmRule: 'ALARM("high-errors")'}}) expect((sent[0] as PutCompositeAlarmCommand).input).toMatchObject({AlarmName: 'app-unhealthy', AlarmRule: 'ALARM("high-errors")', AlarmActions: []}) expect(created.metadata.alarmType).toBe('composite') }) test('checks the alarm before creating it', async () => { const {adapter, sent} = stubCloudWatch(() => ({})) const valid = {alarmType: 'metric', name: 'a', namespace: 'App', metricName: 'Errors', threshold: '5'} await expect(adapter.create({values: {...valid, threshold: 'lots'}})).rejects.toThrow(/threshold/) await expect(adapter.create({values: {...valid, metricName: ''}})).rejects.toThrow(/metricName/) await expect(adapter.create({values: {...valid, dimensions: 'FunctionName'}})).rejects.toThrow(/Name=Value/) await expect(adapter.create({values: {...valid, topicArn: 'my-topic'}})).rejects.toThrow(/SNS topic ARN/) await expect(adapter.create({values: {...valid, period: '7'}})).rejects.toThrow(ValidationError) await expect(adapter.create({values: {alarmType: 'composite', name: 'b', alarmRule: ' '}})).rejects.toThrow(/rule/) expect(sent.some((command) => command instanceof PutMetricAlarmCommand || command instanceof PutCompositeAlarmCommand)).toBe(false) }) test('deletes, sets the state and toggles the actions of an alarm', async () => { const {adapter, sent} = stubCloudWatch(() => ({})) await adapter.delete('high-errors') await adapter.setAlarmState('high-errors', 'ALARM', '') await adapter.setAlarmActionsEnabled('high-errors', false) await adapter.setAlarmActionsEnabled('high-errors', true) expect((sent[0] as DeleteAlarmsCommand).input).toEqual({AlarmNames: ['high-errors']}) expect((sent[1] as SetAlarmStateCommand).input).toMatchObject({AlarmName: 'high-errors', StateValue: 'ALARM'}) expect((sent[1] as SetAlarmStateCommand).input.StateReason).toContain('TheCloud') expect(sent[2]).toBeInstanceOf(DisableAlarmActionsCommand) expect(sent[3]).toBeInstanceOf(EnableAlarmActionsCommand) await expect(adapter.setAlarmState('high-errors', 'BROKEN' as never, '')).rejects.toThrow(/state/) }) test('reads the alarm history newest first', async () => { const {adapter, sent} = stubCloudWatch(() => ({AlarmHistoryItems: [{Timestamp: UPDATED, HistoryItemType: 'StateUpdate', HistorySummary: 'Alarm updated from OK to ALARM'}]})) expect(await adapter.describeAlarmHistory('high-errors')).toEqual([{timestamp: '2026-10-25T10:00:00.000Z', type: 'StateUpdate', summary: 'Alarm updated from OK to ALARM'}]) expect((sent[0] as DescribeAlarmHistoryCommand).input).toMatchObject({AlarmName: 'high-errors', ScanBy: 'TimestampDescending'}) }) }) describe('AwsMonitoringAdapter metrics', () => { test('lists metrics sorted by namespace and filters them', async () => { const {adapter} = stubCloudWatch((command) => command instanceof ListMetricsCommand ? {Metrics: [ {Namespace: 'AWS/SQS', MetricName: 'NumberOfMessagesSent', Dimensions: [{Name: 'QueueName', Value: 'orders'}]}, {Namespace: 'App', MetricName: 'Errors', Dimensions: []}, ]} : {}) expect((await adapter.listMetrics()).map((metric) => `${metric.namespace}/${metric.metricName}`)).toEqual(['App/Errors', 'AWS/SQS/NumberOfMessagesSent']) expect(await adapter.listMetrics({search: 'orders'})).toHaveLength(1) }) test('charts a metric with a period that suits the range', async () => { const {adapter, sent} = stubCloudWatch((command) => command instanceof GetMetricDataCommand ? {MetricDataResults: [{Id: 'm1', Label: 'Errors', Timestamps: [new Date('2026-10-25T10:01:00Z'), new Date('2026-10-25T10:00:00Z')], Values: [7, 3]}]} : {}) const series = await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', statistic: 'Sum', rangeMinutes: 60, dimensions: [{name: 'FunctionName', value: 'checkout'}]}) expect(series).toEqual({label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00.000Z', value: 3}, {timestamp: '2026-10-25T10:01:00.000Z', value: 7}]}) const query = (sent[0] as GetMetricDataCommand).input.MetricDataQueries![0] expect(query.MetricStat).toEqual({Metric: {Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}]}, Period: 60, Stat: 'Sum'}) await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 24 * 60}) expect((sent[1] as GetMetricDataCommand).input.MetricDataQueries![0].MetricStat!.Period).toBe(300) await expect(adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 1})).rejects.toThrow(/rangeMinutes/) await expect(adapter.getMetricSeries({namespace: '', metricName: 'Errors'})).rejects.toThrow(/namespace/) }) })