import {describe, expect, test} from 'bun:test' import { type AutoScalingClient, CreateAutoScalingGroupCommand, DeleteAutoScalingGroupCommand, DescribeAutoScalingGroupsCommand, DescribeScalingActivitiesCommand, TerminateInstanceInAutoScalingGroupCommand, UpdateAutoScalingGroupCommand, } from '@aws-sdk/client-auto-scaling' import {DescribeSubnetsCommand, type EC2Client} from '@aws-sdk/client-ec2' import {AwsAutoScalingAdapter} from './AwsAutoScalingAdapter' import {NotFoundError, ValidationError} from '../cloud-spi/errors' type SendResult = Record function stub(handler: (command: object) => SendResult | Promise) { const sent: object[] = [] const client = { async send(command: object) { sent.push(command) return handler(command) }, } return {client, sent} } const group = { AutoScalingGroupName: 'web-asg', AutoScalingGroupARN: 'arn:aws:autoscaling:us-east-1:000000000000:autoScalingGroup:web-asg', LaunchTemplate: {LaunchTemplateName: 'web-lt', Version: '$Latest'}, MinSize: 1, MaxSize: 3, DesiredCapacity: 2, VPCZoneIdentifier: 'subnet-a,subnet-b', HealthCheckType: 'EC2', Instances: [ {InstanceId: 'i-b8736c8b4a8c129a3', AvailabilityZone: 'us-east-1a', LifecycleState: 'InService', HealthStatus: 'Healthy', InstanceType: 't3.micro'}, {InstanceId: 'i-a3155c876867976b1', AvailabilityZone: 'us-east-1b', LifecycleState: 'Pending', HealthStatus: 'Healthy', InstanceType: 't3.micro'}, ], CreatedTime: new Date('2026-10-25T16:49:54Z'), } const activity = {ActivityId: 'a1', Description: 'Launching a new EC2 instance: i-b8736c8b4a8c129a3', Cause: 'An instance was started in response to a desired capacity change.', StatusCode: 'Successful', StartTime: new Date('2026-10-25T16:50:09Z')} function adapterWith(handler: (command: object) => SendResult | undefined) { const autoscaling = stub((command) => handler(command) ?? ( command instanceof DescribeAutoScalingGroupsCommand ? {AutoScalingGroups: [group]} : command instanceof DescribeScalingActivitiesCommand ? {Activities: [activity]} : {})) const ec2 = stub((command) => command instanceof DescribeSubnetsCommand ? {Subnets: [{SubnetId: 'subnet-default-b'}, {SubnetId: 'subnet-default-a'}]} : {}) return { sent: autoscaling.sent, ec2Sent: ec2.sent, adapter: new AwsAutoScalingAdapter(autoscaling.client as unknown as AutoScalingClient, ec2.client as unknown as EC2Client), } } describe('AwsAutoScalingAdapter', () => { test('lists groups across pages with their capacity and template', async () => { const {adapter} = adapterWith((command) => { if (!(command instanceof DescribeAutoScalingGroupsCommand)) return undefined return command.input.NextToken ? {AutoScalingGroups: [{...group, AutoScalingGroupName: 'jobs', LaunchTemplate: {LaunchTemplateName: 'jobs-lt'}, Instances: []}]} : {AutoScalingGroups: [group], NextToken: 'n'} }) const groups = await adapter.list() expect(groups.map((g) => [g.name, g.metadata.capacity, g.metadata.desiredCapacity, g.metadata.launchTemplateName])).toEqual([ ['web-asg', '1 in service', 2, 'web-lt'], ['jobs', '0 in service', 2, 'jobs-lt'], ]) expect((await adapter.list({search: 'jobs-lt'})).map((g) => g.name)).toEqual(['jobs']) }) test('describes the instances and recent scaling activity', async () => { const {adapter} = adapterWith(() => undefined) const detail = await adapter.get('web-asg') expect(detail?.metadata.instances).toEqual([ {id: 'i-b8736c8b4a8c129a3', availabilityZone: 'us-east-1a', lifecycleState: 'InService', health: 'Healthy', instanceType: 't3.micro', protectedFromScaleIn: false}, {id: 'i-a3155c876867976b1', availabilityZone: 'us-east-1b', lifecycleState: 'Pending', health: 'Healthy', instanceType: 't3.micro', protectedFromScaleIn: false}, ]) expect(detail?.metadata.activities).toEqual([{id: 'a1', description: activity.Description, cause: activity.Cause, status: 'Successful', statusMessage: null, startTime: '2026-10-25T16:50:09.000Z'}]) }) test('keeps the group readable when its activities cannot be read', async () => { const {adapter} = adapterWith((command) => { if (command instanceof DescribeScalingActivitiesCommand) throw new Error('throttled') return undefined }) expect((await adapter.get('web-asg'))?.metadata.activitiesUnavailable).toBe(true) }) test('a missing group is null', async () => { const {adapter} = adapterWith((command) => command instanceof DescribeAutoScalingGroupsCommand ? {AutoScalingGroups: []} : undefined) expect(await adapter.get('nope')).toBeNull() }) test('creates a group in the default subnets when none are given', async () => { const {adapter, sent} = adapterWith(() => undefined) await adapter.create({values: {name: 'web-asg', launchTemplate: 'web-lt', minSize: '1', desiredCapacity: '2', maxSize: '3'}}) expect((sent[0] as CreateAutoScalingGroupCommand).input).toEqual({ AutoScalingGroupName: 'web-asg', LaunchTemplate: {LaunchTemplateName: 'web-lt', Version: '$Latest'}, MinSize: 1, MaxSize: 3, DesiredCapacity: 2, VPCZoneIdentifier: 'subnet-default-a,subnet-default-b', HealthCheckType: 'EC2', }) }) test('creates from a template ID with given subnets, target groups and ELB health checks', async () => { const {adapter, sent, ec2Sent} = adapterWith(() => undefined) const targetGroup = 'arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/web/abc' await adapter.create({values: {name: 'web-asg', launchTemplate: 'lt-ba034c5aa8346f602', launchTemplateVersion: '2', minSize: '0', maxSize: '2', subnets: 'subnet-a, subnet-b', targetGroupArns: targetGroup, healthCheckType: 'ELB'}}) expect((sent[0] as CreateAutoScalingGroupCommand).input).toMatchObject({ LaunchTemplate: {LaunchTemplateId: 'lt-ba034c5aa8346f602', Version: '2'}, DesiredCapacity: 0, VPCZoneIdentifier: 'subnet-a,subnet-b', TargetGroupARNs: [targetGroup], HealthCheckType: 'ELB', HealthCheckGracePeriod: 300, }) expect(ec2Sent).toHaveLength(0) }) test('refuses capacities AWS would reject, before calling it', async () => { const {adapter, sent} = adapterWith(() => undefined) const base = {name: 'g', launchTemplate: 'web-lt'} await expect(adapter.create({values: {...base, minSize: '3', maxSize: '1'}})).rejects.toThrow('minSize cannot be larger than maxSize') await expect(adapter.create({values: {...base, minSize: '1', desiredCapacity: '5', maxSize: '3'}})).rejects.toThrow('desiredCapacity must be between') await expect(adapter.create({values: {...base, minSize: '0', maxSize: '500'}})).rejects.toBeInstanceOf(ValidationError) await expect(adapter.create({values: {...base, minSize: '', maxSize: '1'}})).rejects.toBeInstanceOf(ValidationError) await expect(adapter.create({values: {...base, minSize: '1', maxSize: '1', launchTemplateVersion: 'latest'}})).rejects.toBeInstanceOf(ValidationError) await expect(adapter.create({values: {...base, name: 'a:b', minSize: '1', maxSize: '1'}})).rejects.toBeInstanceOf(ValidationError) expect(sent).toHaveLength(0) }) test('changes capacity, keeping the values not given', async () => { const {adapter, sent} = adapterWith(() => undefined) await adapter.setAutoScalingCapacity('web-asg', {desiredCapacity: 3}) expect((sent.find((command) => command instanceof UpdateAutoScalingGroupCommand) as UpdateAutoScalingGroupCommand).input).toEqual({AutoScalingGroupName: 'web-asg', MinSize: 1, DesiredCapacity: 3, MaxSize: 3}) await expect(adapter.setAutoScalingCapacity('web-asg', {desiredCapacity: 4})).rejects.toThrow('desiredCapacity must be between') }) test('terminates an instance of the group, replacing it or shrinking the group', async () => { const {adapter, sent} = adapterWith(() => undefined) await adapter.terminateAutoScalingInstance('web-asg', 'i-b8736c8b4a8c129a3', true) await adapter.terminateAutoScalingInstance('web-asg', 'i-b8736c8b4a8c129a3', false) const terminations = sent.filter((command): command is TerminateInstanceInAutoScalingGroupCommand => command instanceof TerminateInstanceInAutoScalingGroupCommand) expect(terminations.map((command) => command.input.ShouldDecrementDesiredCapacity)).toEqual([false, true]) await expect(adapter.terminateAutoScalingInstance('web-asg', 'i-0000000000000000', true)).rejects.toBeInstanceOf(NotFoundError) }) test('deletes a group together with its instances', async () => { const {adapter, sent} = adapterWith(() => undefined) await adapter.delete('web-asg') expect((sent[0] as DeleteAutoScalingGroupCommand).input).toEqual({AutoScalingGroupName: 'web-asg', ForceDelete: true}) }) })