/*
Copyright 2017 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package aws

import (
	"bytes"
	"context"
	"fmt"
	"maps"
	"math"
	"net"
	"sort"
	"strings"
	"testing"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/service/route53"
	route53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
	log "github.com/sirupsen/logrus"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"

	"sigs.k8s.io/external-dns/provider/blueprint"

	"sigs.k8s.io/external-dns/endpoint"
	"sigs.k8s.io/external-dns/internal/testutils"
	"sigs.k8s.io/external-dns/plan"
	"sigs.k8s.io/external-dns/provider"
)

const (
	defaultBatchChangeSize       = 4000
	defaultBatchChangeSizeBytes  = 32000
	defaultBatchChangeSizeValues = 1000
	defaultBatchChangeInterval   = time.Second
	defaultEvaluateTargetHealth  = true
)

// Compile time check for interface conformance
var _ Route53API = &Route53APIStub{}

// Route53APIStub is a minimal implementation of Route53API, used primarily for unit testing.
// See http://http://docs.aws.amazon.com/sdk-for-go/api/service/route53.html for descriptions
// of all of its methods.
// mostly taken from: https://github.com/kubernetes/kubernetes/blob/853167624edb6bc0cfdcdfb88e746e178f5db36c/federation/pkg/dnsprovider/providers/aws/route53/stubs/route53api.go
type Route53APIStub struct {
	zones      map[string]*route53types.HostedZone
	recordSets map[string]map[string][]route53types.ResourceRecordSet
	zoneTags   map[string][]route53types.Tag
	m          dynamicMock
	t          *testing.T
}

// MockMethod starts a description of an expectation of the specified method
// being called.
//
//	Route53APIStub.MockMethod("MyMethod", arg1, arg2)
func (r *Route53APIStub) MockMethod(method string, args ...any) *mock.Call {
	return r.m.On(method, args...)
}

// NewRoute53APIStub returns an initialized Route53APIStub
func NewRoute53APIStub(t *testing.T) *Route53APIStub {
	return &Route53APIStub{
		zones:      make(map[string]*route53types.HostedZone),
		recordSets: make(map[string]map[string][]route53types.ResourceRecordSet),
		zoneTags:   make(map[string][]route53types.Tag),
		t:          t,
	}
}

func (r *Route53APIStub) ListResourceRecordSets(ctx context.Context, input *route53.ListResourceRecordSetsInput, optFns ...func(options *route53.Options)) (*route53.ListResourceRecordSetsOutput, error) {
	if r.m.isMocked("ListResourceRecordSets", input) {
		return r.m.ListResourceRecordSets(ctx, input, optFns...)
	}

	output := &route53.ListResourceRecordSetsOutput{} // TODO: Support optional input args.
	require.NotNil(r.t, input.MaxItems)
	assert.Equal(r.t, route53PageSize, *input.MaxItems)
	if len(r.recordSets) == 0 {
		output.ResourceRecordSets = []route53types.ResourceRecordSet{}
	} else if _, ok := r.recordSets[*input.HostedZoneId]; !ok {
		output.ResourceRecordSets = []route53types.ResourceRecordSet{}
	} else {
		for _, rrsets := range r.recordSets[*input.HostedZoneId] {
			output.ResourceRecordSets = append(output.ResourceRecordSets, rrsets...)
		}
	}
	return output, nil
}

type Route53APICounter struct {
	wrapped Route53API
	calls   map[string]int
}

func NewRoute53APICounter(w Route53API) *Route53APICounter {
	return &Route53APICounter{
		wrapped: w,
		calls:   map[string]int{},
	}
}

func (c *Route53APICounter) ListResourceRecordSets(ctx context.Context, input *route53.ListResourceRecordSetsInput, optFns ...func(options *route53.Options)) (*route53.ListResourceRecordSetsOutput, error) {
	c.calls["ListResourceRecordSetsPages"]++
	return c.wrapped.ListResourceRecordSets(ctx, input, optFns...)
}

func (c *Route53APICounter) ChangeResourceRecordSets(ctx context.Context, input *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) {
	c.calls["ChangeResourceRecordSets"]++
	return c.wrapped.ChangeResourceRecordSets(ctx, input, optFns...)
}

func (c *Route53APICounter) CreateHostedZone(ctx context.Context, input *route53.CreateHostedZoneInput, optFns ...func(*route53.Options)) (*route53.CreateHostedZoneOutput, error) {
	c.calls["CreateHostedZone"]++
	return c.wrapped.CreateHostedZone(ctx, input, optFns...)
}

func (c *Route53APICounter) ListHostedZones(ctx context.Context, input *route53.ListHostedZonesInput, optFns ...func(options *route53.Options)) (*route53.ListHostedZonesOutput, error) {
	c.calls["ListHostedZonesPages"]++
	return c.wrapped.ListHostedZones(ctx, input, optFns...)
}

func (c *Route53APICounter) ListTagsForResources(ctx context.Context, input *route53.ListTagsForResourcesInput, optFns ...func(options *route53.Options)) (*route53.ListTagsForResourcesOutput, error) {
	c.calls["ListTagsForResource"]++
	return c.wrapped.ListTagsForResources(ctx, input, optFns...)
}

// Route53 stores wildcards escaped: http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html?shortFooter=true#domain-name-format-asterisk
func wildcardEscape(s string) string {
	if strings.Contains(s, "*") {
		s = strings.Replace(s, "*", "\\052", 1)
	}
	return s
}

// Route53 octal escapes https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html
func specialCharactersEscape(s string) string {
	var result strings.Builder
	for _, char := range s {
		if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '-' || char == '.' {
			result.WriteRune(char)
		} else {
			octalCode := fmt.Sprintf("\\%03o", char)
			result.WriteString(octalCode)
		}
	}
	return result.String()
}

func (r *Route53APIStub) ListTagsForResources(_ context.Context, input *route53.ListTagsForResourcesInput, _ ...func(options *route53.Options)) (*route53.ListTagsForResourcesOutput, error) {
	if input.ResourceType == route53types.TagResourceTypeHostedzone {
		var sets []route53types.ResourceTagSet
		for _, el := range input.ResourceIds {
			zoneId := fmt.Sprintf("/%s/%s", input.ResourceType, el)
			if strings.Contains(zoneId, "ext-dns-test-error-on-list-tags") {
				return nil, fmt.Errorf("operation error Route53APIStub: ListTagsForResource")
			}

			if r.zoneTags[zoneId] != nil {
				sets = append(sets, route53types.ResourceTagSet{
					ResourceId:   &el,
					ResourceType: route53types.TagResourceTypeHostedzone,
					Tags:         r.zoneTags[zoneId],
				})
			}
		}
		return &route53.ListTagsForResourcesOutput{ResourceTagSets: sets}, nil
	}
	return &route53.ListTagsForResourcesOutput{}, nil
}

func (r *Route53APIStub) ChangeResourceRecordSets(_ context.Context, input *route53.ChangeResourceRecordSetsInput, _ ...func(options *route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) {
	if r.m.isMocked("ChangeResourceRecordSets", input) {
		return r.m.ChangeResourceRecordSets(input)
	}

	_, ok := r.zones[*input.HostedZoneId]
	if !ok {
		return nil, fmt.Errorf("hosted zone doesn't exist: %s", *input.HostedZoneId)
	}

	if len(input.ChangeBatch.Changes) == 0 {
		return nil, fmt.Errorf("ChangeBatch doesn't contain any changes")
	}

	output := &route53.ChangeResourceRecordSetsOutput{}
	recordSets, ok := r.recordSets[*input.HostedZoneId]
	if !ok {
		recordSets = make(map[string][]route53types.ResourceRecordSet)
	}

	for _, change := range input.ChangeBatch.Changes {
		if change.ResourceRecordSet.Type == route53types.RRTypeA {
			for _, rrs := range change.ResourceRecordSet.ResourceRecords {
				if net.ParseIP(*rrs.Value) == nil {
					return nil, fmt.Errorf("A records must point to IPs")
				}
			}
		}

		change.ResourceRecordSet.Name = aws.String(wildcardEscape(provider.EnsureTrailingDot(*change.ResourceRecordSet.Name)))

		if change.ResourceRecordSet.AliasTarget != nil {
			change.ResourceRecordSet.AliasTarget.DNSName = aws.String(wildcardEscape(provider.EnsureTrailingDot(*change.ResourceRecordSet.AliasTarget.DNSName)))
		}

		setID := ""
		if change.ResourceRecordSet.SetIdentifier != nil {
			setID = *change.ResourceRecordSet.SetIdentifier
		}
		key := *change.ResourceRecordSet.Name + "::" + string(change.ResourceRecordSet.Type) + "::" + setID
		switch change.Action {
		case route53types.ChangeActionCreate:
			if _, found := recordSets[key]; found {
				return nil, fmt.Errorf("attempt to create duplicate rrset %s", key) // TODO: Return AWS errors with codes etc
			}
			recordSets[key] = append(recordSets[key], *change.ResourceRecordSet)
		case route53types.ChangeActionDelete:
			if _, found := recordSets[key]; !found {
				return nil, fmt.Errorf("attempt to delete non-existent rrset %s", key) // TODO: Check other fields too
			}
			delete(recordSets, key)
		case route53types.ChangeActionUpsert:
			recordSets[key] = []route53types.ResourceRecordSet{*change.ResourceRecordSet}
		}
	}
	r.recordSets[*input.HostedZoneId] = recordSets
	return output, nil // TODO: We should ideally return status etc, but we don't' use that yet.
}

func (r *Route53APIStub) ListHostedZones(_ context.Context, _ *route53.ListHostedZonesInput, _ ...func(options *route53.Options)) (*route53.ListHostedZonesOutput, error) {
	output := &route53.ListHostedZonesOutput{}
	for _, zone := range r.zones {
		output.HostedZones = append(output.HostedZones, *zone)
	}
	return output, nil
}

func (r *Route53APIStub) CreateHostedZone(_ context.Context, input *route53.CreateHostedZoneInput, _ ...func(options *route53.Options)) (*route53.CreateHostedZoneOutput, error) {
	name := *input.Name
	id := "/hostedzone/" + name
	if _, ok := r.zones[id]; ok {
		return nil, fmt.Errorf("Error creating hosted DNS zone: %s already exists", id)
	}
	r.zones[id] = &route53types.HostedZone{
		Id:     aws.String(id),
		Name:   aws.String(name),
		Config: input.HostedZoneConfig,
	}
	return &route53.CreateHostedZoneOutput{HostedZone: r.zones[id]}, nil
}

type dynamicMock struct {
	mock.Mock
}

func (m *dynamicMock) ListResourceRecordSets(_ context.Context, input *route53.ListResourceRecordSetsInput, _ ...func(options *route53.Options)) (*route53.ListResourceRecordSetsOutput, error) {
	args := m.Called(input)
	if args.Get(0) != nil {
		return args.Get(0).(*route53.ListResourceRecordSetsOutput), args.Error(1)
	}
	return nil, args.Error(1)
}

func (m *dynamicMock) ChangeResourceRecordSets(input *route53.ChangeResourceRecordSetsInput) (*route53.ChangeResourceRecordSetsOutput, error) {
	args := m.Called(input)
	if args.Get(0) != nil {
		return args.Get(0).(*route53.ChangeResourceRecordSetsOutput), args.Error(1)
	}
	return nil, args.Error(1)
}

func (m *dynamicMock) isMocked(method string, arguments ...any) bool {
	for _, call := range m.ExpectedCalls {
		if call.Method == method && call.Repeatability > -1 {
			_, diffCount := call.Arguments.Diff(arguments)
			if diffCount == 0 {
				return true
			}
		}
	}
	return false
}

func TestAWSZones(t *testing.T) {
	publicZones := map[string]*route53types.HostedZone{
		"/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.": {
			Id:   aws.String("/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do."),
			Name: aws.String("zone-1.ext-dns-test-2.teapot.zalan.do."),
		},
		"/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.": {
			Id:   aws.String("/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do."),
			Name: aws.String("zone-2.ext-dns-test-2.teapot.zalan.do."),
		},
	}

	privateZones := map[string]*route53types.HostedZone{
		"/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do.": {
			Id:   aws.String("/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do."),
			Name: aws.String("zone-3.ext-dns-test-2.teapot.zalan.do."),
		},
	}

	allZones := map[string]*route53types.HostedZone{}
	maps.Copy(allZones, publicZones)
	maps.Copy(allZones, privateZones)

	noZones := map[string]*route53types.HostedZone{}

	for _, ti := range []struct {
		msg            string
		zoneIDFilter   provider.ZoneIDFilter
		zoneTypeFilter provider.ZoneTypeFilter
		zoneTagFilter  provider.ZoneTagFilter
		expectedZones  map[string]*route53types.HostedZone
	}{
		{"no filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), provider.NewZoneTagFilter([]string{}), allZones},
		{"public filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter("public"), provider.NewZoneTagFilter([]string{}), publicZones},
		{"private filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter("private"), provider.NewZoneTagFilter([]string{}), privateZones},
		{"unknown filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter("unknown"), provider.NewZoneTagFilter([]string{}), noZones},
		{"zone id filter", provider.NewZoneIDFilter([]string{"/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneTypeFilter(""), provider.NewZoneTagFilter([]string{}), privateZones},
		{"tag filter zero zone match", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), provider.NewZoneTagFilter([]string{"zone=not-exists"}), noZones},
		{"tag filter single zone match", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), provider.NewZoneTagFilter([]string{"zone=3"}), privateZones},
	} {
		t.Run(ti.msg, func(t *testing.T) {
			provider, _ := newAWSProviderWithTagFilter(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), ti.zoneIDFilter, ti.zoneTypeFilter, ti.zoneTagFilter, defaultEvaluateTargetHealth, false, false, nil)
			zones, err := provider.Zones(t.Context())
			require.NoError(t, err)
			validateAWSZones(t, zones, ti.expectedZones)
		})
	}
}

func TestAWSZonesWithTagFilterError(t *testing.T) {
	client := NewRoute53APIStub(t)
	provider := &AWSProvider{
		clients:       map[string]Route53API{defaultAWSProfile: client},
		zoneTagFilter: provider.NewZoneTagFilter([]string{"zone=2"}),
		dryRun:        false,
		zonesCache:    blueprint.NewZoneCache[map[string]*profiledZone](1 * time.Minute),
	}
	createAWSZone(t, provider, &route53types.HostedZone{
		Id:     aws.String("/hostedzone/zone-1.ext-dns-test-ok.example.com."),
		Name:   aws.String("zone-1.ext-dns-test-ok.example.com."),
		Config: &route53types.HostedZoneConfig{PrivateZone: false},
	})
	createAWSZone(t, provider, &route53types.HostedZone{
		Id:     aws.String("/hostedzone/zone-2.ext-dns-test-error-on-list-tags.example.com."),
		Name:   aws.String("zone-2.ext-dns-test-error-on-list-tags.example.com."),
		Config: &route53types.HostedZoneConfig{PrivateZone: false},
	})
	_, err := provider.Zones(t.Context())
	require.Error(t, err)
	require.ErrorContains(t, err, "failed to list tags for zones")
}

func TestAWSRecordsFilter(t *testing.T) {
	provider, _ := newAWSProvider(t, &endpoint.DomainFilter{}, provider.ZoneIDFilter{}, provider.ZoneTypeFilter{}, false, false, false, nil)
	domainFilter := provider.GetDomainFilter()
	require.NotNil(t, domainFilter)
	require.IsType(t, &endpoint.DomainFilter{}, domainFilter)
	count := 0
	filters := domainFilter.(*endpoint.DomainFilter).Filters
	for _, tld := range []string{
		"zone-4.ext-dns-test-3.teapot.zalan.do",
		".zone-4.ext-dns-test-3.teapot.zalan.do",
		"zone-2.ext-dns-test-2.teapot.zalan.do",
		".zone-2.ext-dns-test-2.teapot.zalan.do",
		"zone-3.ext-dns-test-2.teapot.zalan.do",
		".zone-3.ext-dns-test-2.teapot.zalan.do",
		"zone-4.ext-dns-test-3.teapot.zalan.do",
		".zone-4.ext-dns-test-3.teapot.zalan.do",
	} {
		assert.Contains(t, filters, tld)
		count++
	}
	assert.Len(t, filters, count)
}

func TestAWSRecords(t *testing.T) {
	provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), false, false, false, []route53types.ResourceRecordSet{
		{
			Name:            aws.String("list-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
		},
		{
			Name:            aws.String("list-test.zone-2.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
		},
		{
			Name:            aws.String(wildcardEscape("*.wildcard-test.zone-2.ext-dns-test-2.teapot.zalan.do.")),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
		},
		{
			Name:            aws.String(specialCharactersEscape("escape-%!s(<nil>)-codes.zone-2.ext-dns-test-2.teapot.zalan.do.")),
			Type:            route53types.RRTypeCname,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("example")}},
		},
		{
			Name:            aws.String(specialCharactersEscape("escape-%!s(<nil>)-codes-a.zone-2.ext-dns-test-2.teapot.zalan.do.")),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
		},
		{
			Name: aws.String(specialCharactersEscape("escape-%!s(<nil>)-codes-alias.zone-2.ext-dns-test-2.teapot.zalan.do.")),
			Type: route53types.RRTypeA,
			TTL:  aws.Int64(defaultTTL),
			AliasTarget: &route53types.AliasTarget{
				DNSName:              aws.String("escape-codes.eu-central-1.elb.amazonaws.com."),
				EvaluateTargetHealth: false,
				HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
			},
		},
		{
			Name: aws.String(specialCharactersEscape("escape-%!s(<nil>)-codes-alias.zone-2.ext-dns-test-2.teapot.zalan.do.")),
			Type: route53types.RRTypeAaaa,
			TTL:  aws.Int64(defaultTTL),
			AliasTarget: &route53types.AliasTarget{
				DNSName:              aws.String("escape-codes.eu-central-1.elb.amazonaws.com."),
				EvaluateTargetHealth: false,
				HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
			},
		},
		{
			Name: aws.String("list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type: route53types.RRTypeA,
			AliasTarget: &route53types.AliasTarget{
				DNSName:              aws.String("foo.eu-central-1.elb.amazonaws.com."),
				EvaluateTargetHealth: false,
				HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
			},
		},
		{
			Name: aws.String("list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type: route53types.RRTypeAaaa,
			AliasTarget: &route53types.AliasTarget{
				DNSName:              aws.String("foo.eu-central-1.elb.amazonaws.com."),
				EvaluateTargetHealth: false,
				HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
			},
		},
		{
			Name: aws.String("*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type: route53types.RRTypeA,
			AliasTarget: &route53types.AliasTarget{
				DNSName:              aws.String("foo.eu-central-1.elb.amazonaws.com."),
				EvaluateTargetHealth: false,
				HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
			},
		},
		{
			Name: aws.String("*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type: route53types.RRTypeAaaa,
			AliasTarget: &route53types.AliasTarget{
				DNSName:              aws.String("foo.eu-central-1.elb.amazonaws.com."),
				EvaluateTargetHealth: false,
				HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
			},
		},
		{
			Name: aws.String("list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type: route53types.RRTypeA,
			AliasTarget: &route53types.AliasTarget{
				DNSName:              aws.String("foo.eu-central-1.elb.amazonaws.com."),
				EvaluateTargetHealth: true,
				HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
			},
		},
		{
			Name: aws.String("list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type: route53types.RRTypeAaaa,
			AliasTarget: &route53types.AliasTarget{
				DNSName:              aws.String("foo.eu-central-1.elb.amazonaws.com."),
				EvaluateTargetHealth: true,
				HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
			},
		},
		{
			Name:            aws.String("list-test-multiple.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}, {Value: aws.String("8.8.4.4")}},
		},
		{
			Name:            aws.String("prefix-*.wildcard.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeTxt,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("random")}},
		},
		{
			Name:            aws.String("weight-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
			SetIdentifier:   aws.String("test-set-1"),
			Weight:          aws.Int64(10),
		},
		{
			Name:            aws.String("weight-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("4.3.2.1")}},
			SetIdentifier:   aws.String("test-set-2"),
			Weight:          aws.Int64(20),
		},
		{
			Name:            aws.String("latency-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
			SetIdentifier:   aws.String("test-set"),
			Region:          route53types.ResourceRecordSetRegionUsEast1,
		},
		{
			Name:            aws.String("failover-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
			SetIdentifier:   aws.String("test-set"),
			Failover:        route53types.ResourceRecordSetFailoverPrimary,
		},
		{
			Name:             aws.String("multi-value-answer-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:             route53types.RRTypeA,
			TTL:              aws.Int64(defaultTTL),
			ResourceRecords:  []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
			SetIdentifier:    aws.String("test-set"),
			MultiValueAnswer: aws.Bool(true),
		},
		{
			Name:            aws.String("geolocation-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
			SetIdentifier:   aws.String("test-set-1"),
			GeoLocation: &route53types.GeoLocation{
				ContinentCode: aws.String("EU"),
			},
		},
		{
			Name:            aws.String("geolocation-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("4.3.2.1")}},
			SetIdentifier:   aws.String("test-set-2"),
			GeoLocation: &route53types.GeoLocation{
				CountryCode: aws.String("DE"),
			},
		},
		{
			Name:            aws.String("geolocation-subdivision-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
			SetIdentifier:   aws.String("test-set-1"),
			GeoLocation: &route53types.GeoLocation{
				SubdivisionCode: aws.String("NY"),
			},
		},
		{
			Name:            aws.String("geoproximitylocation-region.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
			SetIdentifier:   aws.String("test-set-1"),
			GeoProximityLocation: &route53types.GeoProximityLocation{
				AWSRegion: aws.String("us-west-2"),
				Bias:      aws.Int32(10),
			},
		},
		{
			Name:            aws.String("geoproximitylocation-localzone.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
			SetIdentifier:   aws.String("test-set-1"),
			GeoProximityLocation: &route53types.GeoProximityLocation{
				LocalZoneGroup: aws.String("usw2-pdx1-az1"),
				Bias:           aws.Int32(10),
			},
		},
		{
			Name:            aws.String("geoproximitylocation-coordinates.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
			SetIdentifier:   aws.String("test-set-1"),
			GeoProximityLocation: &route53types.GeoProximityLocation{
				Coordinates: &route53types.Coordinates{
					Latitude:  aws.String("90"),
					Longitude: aws.String("90"),
				},
				Bias: aws.Int32(0),
			},
		},
		{
			Name:            aws.String("healthcheck-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeCname,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("foo.example.com")}},
			SetIdentifier:   aws.String("test-set-1"),
			HealthCheckId:   aws.String("foo-bar-healthcheck-id"),
			Weight:          aws.Int64(10),
		},
		{
			Name:            aws.String("healthcheck-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("4.3.2.1")}},
			SetIdentifier:   aws.String("test-set-2"),
			HealthCheckId:   aws.String("abc-def-healthcheck-id"),
			Weight:          aws.Int64(20),
		},
		{
			Name:            aws.String("mail.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeMx,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("10 mailhost1.example.com")}, {Value: aws.String("20 mailhost2.example.com")}},
		},
		{
			Name:            aws.String("naptr.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeNaptr,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String(`10 "U" "SIP+DTU" "" _sip._udp.sip1.example.com`)}, {Value: aws.String(`10 "U" "SIPS+D2T" "" _sips._tcp.sip1.example.com`)}},
		},
	})

	records, err := provider.Records(t.Context())
	require.NoError(t, err)

	validateEndpoints(t, provider, records, []*endpoint.Endpoint{
		endpoint.NewEndpointWithTTL("list-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4"),
		endpoint.NewEndpointWithTTL("list-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "8.8.8.8"),
		endpoint.NewEndpointWithTTL("*.wildcard-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "8.8.8.8"),
		endpoint.NewEndpointWithTTL("escape-%!s(<nil>)-codes.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), "example").WithProviderSpecific(providerSpecificAlias, "false"),
		endpoint.NewEndpointWithTTL("escape-%!s(<nil>)-codes-a.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4"),
		endpoint.NewEndpointWithTTL("escape-%!s(<nil>)-codes-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "escape-codes.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"),
		endpoint.NewEndpointWithTTL("escape-%!s(<nil>)-codes-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, endpoint.TTL(defaultTTL), "escape-codes.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"),
		endpoint.NewEndpointWithTTL("list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"),
		endpoint.NewEndpointWithTTL("list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, endpoint.TTL(defaultTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"),
		endpoint.NewEndpointWithTTL("*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"),
		endpoint.NewEndpointWithTTL("*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, endpoint.TTL(defaultTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"),
		endpoint.NewEndpointWithTTL("list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true").WithProviderSpecific(providerSpecificAlias, "true"),
		endpoint.NewEndpointWithTTL("list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, endpoint.TTL(defaultTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true").WithProviderSpecific(providerSpecificAlias, "true"),
		endpoint.NewEndpointWithTTL("list-test-multiple.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "8.8.8.8", "8.8.4.4"),
		endpoint.NewEndpointWithTTL("prefix-*.wildcard.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeTXT, endpoint.TTL(defaultTTL), "random"),
		endpoint.NewEndpointWithTTL("weight-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificWeight, "10"),
		endpoint.NewEndpointWithTTL("weight-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "4.3.2.1").WithSetIdentifier("test-set-2").WithProviderSpecific(providerSpecificWeight, "20"),
		endpoint.NewEndpointWithTTL("latency-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set").WithProviderSpecific(providerSpecificRegion, "us-east-1"),
		endpoint.NewEndpointWithTTL("failover-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set").WithProviderSpecific(providerSpecificFailover, "PRIMARY"),
		endpoint.NewEndpointWithTTL("multi-value-answer-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set").WithProviderSpecific(providerSpecificMultiValueAnswer, ""),
		endpoint.NewEndpointWithTTL("geolocation-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeolocationContinentCode, "EU"),
		endpoint.NewEndpointWithTTL("geolocation-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "4.3.2.1").WithSetIdentifier("test-set-2").WithProviderSpecific(providerSpecificGeolocationCountryCode, "DE"),
		endpoint.NewEndpointWithTTL("geolocation-subdivision-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeolocationSubdivisionCode, "NY"),
		endpoint.NewEndpointWithTTL("geoproximitylocation-region.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, "us-west-2").WithProviderSpecific(providerSpecificGeoProximityLocationBias, "10"),
		endpoint.NewEndpointWithTTL("geoproximitylocation-localzone.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeoProximityLocationLocalZoneGroup, "usw2-pdx1-az1").WithProviderSpecific(providerSpecificGeoProximityLocationBias, "10"),
		endpoint.NewEndpointWithTTL("geoproximitylocation-coordinates.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeoProximityLocationCoordinates, "90,90").WithProviderSpecific(providerSpecificGeoProximityLocationBias, "0"),
		endpoint.NewEndpointWithTTL("healthcheck-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), "foo.example.com").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificWeight, "10").WithProviderSpecific(providerSpecificHealthCheckID, "foo-bar-healthcheck-id").WithProviderSpecific(providerSpecificAlias, "false"),
		endpoint.NewEndpointWithTTL("healthcheck-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "4.3.2.1").WithSetIdentifier("test-set-2").WithProviderSpecific(providerSpecificWeight, "20").WithProviderSpecific(providerSpecificHealthCheckID, "abc-def-healthcheck-id"),
		endpoint.NewEndpointWithTTL("mail.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, endpoint.TTL(defaultTTL), "10 mailhost1.example.com", "20 mailhost2.example.com"),
		endpoint.NewEndpointWithTTL("naptr.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeNAPTR, endpoint.TTL(defaultTTL), `10 "U" "SIP+DTU" "" _sip._udp.sip1.example.com`, `10 "U" "SIPS+D2T" "" _sips._tcp.sip1.example.com`),
	})
}

func TestAWSRecordsSoftError(t *testing.T) {
	pvd, subClient := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), false, false, false, []route53types.ResourceRecordSet{
		{
			Name:            aws.String("list-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
		},
	})

	subClient.MockMethod("ListResourceRecordSets", mock.Anything).Return(nil, fmt.Errorf("Mock route53 failure"))
	_, err := pvd.Records(t.Context())
	require.Error(t, err)
	require.ErrorIs(t, err, provider.SoftError)
}

func TestAWSAdjustEndpoints(t *testing.T) {
	provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, false, nil)

	records := []*endpoint.Endpoint{
		endpoint.NewEndpoint("a-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"),
		endpoint.NewEndpoint("cname-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.example.com"),
		endpoint.NewEndpointWithTTL("cname-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, 60, "alias-target.zone-2.ext-dns-test-2.teapot.zalan.do").WithProviderSpecific(providerSpecificAlias, "true"),
		endpoint.NewEndpointWithTTL("cname-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, 60, "alias-target.zone-2.ext-dns-test-2.teapot.zalan.do").WithProviderSpecific(providerSpecificAlias, "true"),
		endpoint.NewEndpoint("cname-test-elb.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com"),
		endpoint.NewEndpoint("cname-test-elb-no-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "false"),
		endpoint.NewEndpoint("cname-test-elb-no-eth.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false"), // eth = evaluate target health
		endpoint.NewEndpoint("cname-test-elb-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"),
		endpoint.NewEndpoint("a-test-geoproximity-no-bias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, "us-west-2"),
	}

	records, err := provider.AdjustEndpoints(records)
	require.NoError(t, err)

	validateEndpoints(t, provider, records, []*endpoint.Endpoint{
		endpoint.NewEndpoint("a-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"),
		endpoint.NewEndpoint("cname-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.example.com").WithProviderSpecific(providerSpecificAlias, "false"),
		endpoint.NewEndpointWithTTL("cname-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, 300, "alias-target.zone-2.ext-dns-test-2.teapot.zalan.do").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"),
		endpoint.NewEndpointWithTTL("cname-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, 300, "alias-target.zone-2.ext-dns-test-2.teapot.zalan.do").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"),
		endpoint.NewEndpoint("cname-test-elb.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"),
		endpoint.NewEndpoint("cname-test-elb.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"),
		endpoint.NewEndpoint("cname-test-elb-no-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "false"),
		endpoint.NewEndpoint("cname-test-elb-no-eth.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false"),    // eth = evaluate target health
		endpoint.NewEndpoint("cname-test-elb-no-eth.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false"), // eth = evaluate target health
		endpoint.NewEndpoint("cname-test-elb-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"),
		endpoint.NewEndpoint("cname-test-elb-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"),
		endpoint.NewEndpoint("a-test-geoproximity-no-bias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, "us-west-2").WithProviderSpecific(providerSpecificGeoProximityLocationBias, "0"),
	})
}

func TestAWSApplyChanges(t *testing.T) {
	tests := []struct {
		name       string
		setup      func(p *AWSProvider) context.Context
		listRRSets int
	}{
		{"no cache", func(_ *AWSProvider) context.Context { return t.Context() }, 0},
		{"cached", func(p *AWSProvider) context.Context {
			ctx := t.Context()
			records, err := p.Records(ctx)
			require.NoError(t, err)
			return context.WithValue(ctx, provider.RecordsContextKey, records)
		}, 0},
	}

	for _, tt := range tests {
		provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, false, []route53types.ResourceRecordSet{
			{
				Name:            aws.String("update-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
			},
			{
				Name:            aws.String("update-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeAaaa,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}},
			},
			{
				Name:            aws.String("delete-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
			},
			{
				Name:            aws.String("delete-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeAaaa,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}},
			},
			{
				Name:            aws.String("update-test.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.4.4")}},
			},
			{
				Name:            aws.String("update-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeAaaa,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1001")}},
			},
			{
				Name:            aws.String("delete-test.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.4.4")}},
			},
			{
				Name:            aws.String("delete-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeAaaa,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1001")}},
			},
			{
				Name:            aws.String("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.1.1.1")}},
			},
			{
				Name: aws.String("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type: route53types.RRTypeA,
				AliasTarget: &route53types.AliasTarget{
					DNSName:              aws.String("foo.eu-central-1.elb.amazonaws.com."),
					EvaluateTargetHealth: true,
					HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
				},
			},
			{
				Name: aws.String("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type: route53types.RRTypeAaaa,
				AliasTarget: &route53types.AliasTarget{
					DNSName:              aws.String("foo.eu-central-1.elb.amazonaws.com."),
					EvaluateTargetHealth: true,
					HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
				},
			},
			{
				Name:            aws.String("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeCname,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("bar.elb.amazonaws.com")}},
			},
			{
				Name: aws.String("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type: route53types.RRTypeA,
				AliasTarget: &route53types.AliasTarget{
					DNSName:              aws.String("bar.elb.amazonaws.com."),
					EvaluateTargetHealth: true,
					HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
				},
			},
			{
				Name: aws.String("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type: route53types.RRTypeAaaa,
				AliasTarget: &route53types.AliasTarget{
					DNSName:              aws.String("bar.elb.amazonaws.com."),
					EvaluateTargetHealth: true,
					HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
				},
			},
			{
				Name:            aws.String("delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeCname,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("qux.elb.amazonaws.com")}},
			},
			{
				Name: aws.String("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type: route53types.RRTypeA,
				AliasTarget: &route53types.AliasTarget{
					DNSName:              aws.String("qux.elb.amazonaws.com."),
					EvaluateTargetHealth: true,
					HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
				},
			},
			{
				Name: aws.String("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type: route53types.RRTypeAaaa,
				AliasTarget: &route53types.AliasTarget{
					DNSName:              aws.String("qux.elb.amazonaws.com."),
					EvaluateTargetHealth: true,
					HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
				},
			},
			{
				Name:            aws.String("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}, {Value: aws.String("8.8.4.4")}},
			},
			{
				Name:            aws.String("delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}, {Value: aws.String("4.3.2.1")}},
			},
			{
				Name:            aws.String("delete-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeAaaa,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}, {Value: aws.String("2606:4700:4700::1001")}},
			},
			{
				Name:            aws.String("delete-test-geoproximity.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
				SetIdentifier:   aws.String("geoproximity-delete"),
				GeoProximityLocation: &route53types.GeoProximityLocation{
					AWSRegion: aws.String("us-west-2"),
					Bias:      aws.Int32(10),
				},
			},
			{
				Name:            aws.String("update-test-geoproximity.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
				SetIdentifier:   aws.String("geoproximity-update"),
				GeoProximityLocation: &route53types.GeoProximityLocation{
					LocalZoneGroup: aws.String("usw2-lax1-az2"),
				},
			},
			{
				Name:            aws.String("weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
				SetIdentifier:   aws.String("weighted-to-simple"),
				Weight:          aws.Int64(10),
			},
			{
				Name:            aws.String("simple-to-weighted.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
			},
			{
				Name:            aws.String("policy-change.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
				SetIdentifier:   aws.String("policy-change"),
				Weight:          aws.Int64(10),
			},
			{
				Name:            aws.String("set-identifier-change.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
				SetIdentifier:   aws.String("before"),
				Weight:          aws.Int64(10),
			},
			{
				Name:            aws.String("set-identifier-no-change.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
				SetIdentifier:   aws.String("no-change"),
				Weight:          aws.Int64(10),
			},
			{
				Name:            aws.String("update-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeMx,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("10 mailhost2.bar.elb.amazonaws.com")}},
			},
			{
				Name:            aws.String("delete-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeMx,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("30 mailhost1.foo.elb.amazonaws.com")}},
			},
			{
				Name:            aws.String(specialCharactersEscape("escape-%!s(<nil>)-codes.zone-2.ext-dns-test-2.teapot.zalan.do.")),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
				SetIdentifier:   aws.String("no-change"),
				Weight:          aws.Int64(10),
			},
			{
				Name: aws.String("delete-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type: route53types.RRTypeNaptr,
				TTL:  aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{
					{Value: aws.String(`10 "U" "SIP+DTU" "" _sip._udp.sip1.example.com.`)},
				},
			},
			{
				Name:            aws.String("update-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeNaptr,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String(`10 "U" "SIP+DTU" "" _sip._udp.sip1.example.com.`)}},
			},
		})

		createRecords := []*endpoint.Endpoint{
			endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"),
			endpoint.NewEndpoint("create-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"),
			endpoint.NewEndpoint("create-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111"),
			endpoint.NewEndpoint("create-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1001"),
			endpoint.NewEndpoint("create-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"),
			endpoint.NewEndpoint("create-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"),
			endpoint.NewEndpoint("create-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8", "8.8.4.4"),
			endpoint.NewEndpoint("create-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111", "2606:4700:4700::1001"),
			endpoint.NewEndpoint("create-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "10 mailhost1.foo.elb.amazonaws.com"),
			endpoint.NewEndpoint("create-test-naptr.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeNAPTR, `10 "U" "SIP+DTU" "" _sip._udp.sip1.example.com.`),
			endpoint.NewEndpoint("create-test-geoproximity-region.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8").
				WithSetIdentifier("geoproximity-region").
				WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, "us-west-2").
				WithProviderSpecific(providerSpecificGeoProximityLocationBias, "10"),
			endpoint.NewEndpoint("create-test-geoproximity-coordinates.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8").
				WithSetIdentifier("geoproximity-coordinates").
				WithProviderSpecific(providerSpecificGeoProximityLocationCoordinates, "60,60"),
		}

		currentRecords := []*endpoint.Endpoint{
			endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"),
			endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"),
			endpoint.NewEndpoint("update-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111"),
			endpoint.NewEndpoint("update-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1001"),
			endpoint.NewEndpoint("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.1.1.1"),
			endpoint.NewEndpoint("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
			endpoint.NewEndpoint("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
			endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "bar.elb.amazonaws.com"),
			endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "bar.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
			endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "bar.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
			endpoint.NewEndpoint("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8", "8.8.4.4"),
			endpoint.NewEndpoint("update-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111", "2606:4700:4700::1001"),
			endpoint.NewEndpoint("update-test-geoproximity.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").
				WithSetIdentifier("geoproximity-update").
				WithProviderSpecific(providerSpecificGeoProximityLocationLocalZoneGroup, "usw2-lax1-az2"),
			endpoint.NewEndpoint("weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("weighted-to-simple").WithProviderSpecific(providerSpecificWeight, "10"),
			endpoint.NewEndpoint("simple-to-weighted.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"),
			endpoint.NewEndpoint("policy-change.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("policy-change").WithProviderSpecific(providerSpecificWeight, "10"),
			endpoint.NewEndpoint("set-identifier-change.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("before").WithProviderSpecific(providerSpecificWeight, "10"),
			endpoint.NewEndpoint("set-identifier-no-change.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("no-change").WithProviderSpecific(providerSpecificWeight, "10"),
			endpoint.NewEndpoint("update-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "10 mailhost2.bar.elb.amazonaws.com"),
			endpoint.NewEndpoint("update-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeNAPTR, `10 "U" "SIP+DTU" "" _sip._udp.sip1.example.com.`),
			endpoint.NewEndpoint("escape-%!s(<nil>)-codes.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("policy-change").WithSetIdentifier("no-change").WithProviderSpecific(providerSpecificWeight, "10"),
		}
		updatedRecords := []*endpoint.Endpoint{
			endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"),
			endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "4.3.2.1"),
			endpoint.NewEndpoint("update-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1001"),
			endpoint.NewEndpoint("update-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111"),
			endpoint.NewEndpoint("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
			endpoint.NewEndpoint("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
			endpoint.NewEndpoint("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "my-internal-host.example.com"),
			endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "baz.elb.amazonaws.com"),
			endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "baz.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
			endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "baz.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
			endpoint.NewEndpoint("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4", "4.3.2.1"),
			endpoint.NewEndpoint("update-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1001", "2606:4700:4700::1111"),
			endpoint.NewEndpoint("update-test-geoproximity.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").
				WithSetIdentifier("geoproximity-update").
				WithProviderSpecific(providerSpecificGeoProximityLocationLocalZoneGroup, "usw2-phx2-az1"),
			endpoint.NewEndpoint("weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"),
			endpoint.NewEndpoint("simple-to-weighted.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("simple-to-weighted").WithProviderSpecific(providerSpecificWeight, "10"),
			endpoint.NewEndpoint("policy-change.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("policy-change").WithProviderSpecific(providerSpecificRegion, "us-east-1"),
			endpoint.NewEndpoint("set-identifier-change.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("after").WithProviderSpecific(providerSpecificWeight, "10"),
			endpoint.NewEndpoint("set-identifier-no-change.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("no-change").WithProviderSpecific(providerSpecificWeight, "20"),
			endpoint.NewEndpoint("update-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "20 mailhost3.foo.elb.amazonaws.com"),
			endpoint.NewEndpoint("update-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeNAPTR, `20 "U" "SIP+DTU" "" _sip._udp.sip2.example.com.`),
		}

		deleteRecords := []*endpoint.Endpoint{
			endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"),
			endpoint.NewEndpoint("delete-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"),
			endpoint.NewEndpoint("delete-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111"),
			endpoint.NewEndpoint("delete-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1001"),
			endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"),
			endpoint.NewEndpoint("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "qux.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
			endpoint.NewEndpoint("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "qux.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
			endpoint.NewEndpoint("delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4", "4.3.2.1"),
			endpoint.NewEndpoint("delete-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111", "2606:4700:4700::1001"),
			endpoint.NewEndpoint("delete-test-geoproximity.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("geoproximity-delete").WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, "us-west-2").WithProviderSpecific(providerSpecificGeoProximityLocationBias, "10"),
			endpoint.NewEndpoint("delete-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "30 mailhost1.foo.elb.amazonaws.com"),
			endpoint.NewEndpoint("delete-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeNAPTR, `10 "U" "SIP+DTU" "" _sip._udp.sip1.example.com.`),
		}

		changes := &plan.Changes{
			Create:    createRecords,
			UpdateNew: updatedRecords,
			UpdateOld: currentRecords,
			Delete:    deleteRecords,
		}

		ctx := tt.setup(provider)

		provider.zonesCache = blueprint.NewZoneCache[map[string]*profiledZone](0 * time.Minute)
		counter := NewRoute53APICounter(provider.clients[defaultAWSProfile])
		provider.clients[defaultAWSProfile] = counter
		require.NoError(t, provider.ApplyChanges(ctx, changes))

		assert.Equal(t, 1, counter.calls["ListHostedZonesPages"], tt.name)
		assert.Equal(t, tt.listRRSets, counter.calls["ListResourceRecordSetsPages"], tt.name)

		validateRecords(t, listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do."), []route53types.ResourceRecordSet{
			{
				Name:            aws.String("create-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
			},
			{
				Name:            aws.String("create-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeAaaa,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}},
			},
			{
				Name:            aws.String("update-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
			},
			{
				Name:            aws.String("update-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeAaaa,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1001")}},
			},
			{
				Name: aws.String("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type: route53types.RRTypeA,
				AliasTarget: &route53types.AliasTarget{
					DNSName:              aws.String("foo.elb.amazonaws.com."),
					EvaluateTargetHealth: true,
					HostedZoneId:         aws.String("zone-1.ext-dns-test-2.teapot.zalan.do."),
				},
			},
			{
				Name: aws.String("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type: route53types.RRTypeAaaa,
				AliasTarget: &route53types.AliasTarget{
					DNSName:              aws.String("foo.elb.amazonaws.com."),
					EvaluateTargetHealth: true,
					HostedZoneId:         aws.String("zone-1.ext-dns-test-2.teapot.zalan.do."),
				},
			},
			{
				Name:            aws.String("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeCname,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("my-internal-host.example.com")}},
			},
			{
				Name:            aws.String("create-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeCname,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("foo.elb.amazonaws.com")}},
			},
			{
				Name:            aws.String("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeCname,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("baz.elb.amazonaws.com")}},
			},
			{
				Name:            aws.String("create-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeCname,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("foo.elb.amazonaws.com")}},
			},
			{
				Name: aws.String("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type: route53types.RRTypeA,
				AliasTarget: &route53types.AliasTarget{
					DNSName:              aws.String("baz.elb.amazonaws.com."),
					EvaluateTargetHealth: true,
					HostedZoneId:         aws.String("zone-1.ext-dns-test-2.teapot.zalan.do."),
				},
			},
			{
				Name: aws.String("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type: route53types.RRTypeAaaa,
				AliasTarget: &route53types.AliasTarget{
					DNSName:              aws.String("baz.elb.amazonaws.com."),
					EvaluateTargetHealth: true,
					HostedZoneId:         aws.String("zone-1.ext-dns-test-2.teapot.zalan.do."),
				},
			},
			{
				Name:            aws.String("weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
			},
			{
				Name:            aws.String("simple-to-weighted.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
				SetIdentifier:   aws.String("simple-to-weighted"),
				Weight:          aws.Int64(10),
			},
			{
				Name:            aws.String("policy-change.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
				SetIdentifier:   aws.String("policy-change"),
				Region:          route53types.ResourceRecordSetRegionUsEast1,
			},
			{
				Name:            aws.String("set-identifier-change.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
				SetIdentifier:   aws.String("after"),
				Weight:          aws.Int64(10),
			},
			{
				Name:            aws.String("set-identifier-no-change.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
				SetIdentifier:   aws.String("no-change"),
				Weight:          aws.Int64(20),
			},
			{
				Name:            aws.String("create-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeMx,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("10 mailhost1.foo.elb.amazonaws.com")}},
			},
			{
				Name:            aws.String("create-test-naptr.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeNaptr,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String(`10 "U" "SIP+DTU" "" _sip._udp.sip1.example.com.`)}},
			},
			{
				Name:            aws.String("create-test-geoproximity-region.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
				SetIdentifier:   aws.String("geoproximity-region"),
				GeoProximityLocation: &route53types.GeoProximityLocation{
					AWSRegion: aws.String("us-west-2"),
					Bias:      aws.Int32(10),
				},
			},
			{
				Name:            aws.String("update-test-geoproximity.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
				SetIdentifier:   aws.String("geoproximity-update"),
				GeoProximityLocation: &route53types.GeoProximityLocation{
					LocalZoneGroup: aws.String("usw2-phx2-az1"),
				},
			},
			{
				Name:            aws.String("create-test-geoproximity-coordinates.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
				SetIdentifier:   aws.String("geoproximity-coordinates"),
				GeoProximityLocation: &route53types.GeoProximityLocation{
					Coordinates: &route53types.Coordinates{
						Latitude:  aws.String("60"),
						Longitude: aws.String("60"),
					},
				},
			},
		})
		validateRecords(t, listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do."), []route53types.ResourceRecordSet{
			{
				Name:            aws.String("escape-\\045\\041s\\050\\074nil\\076\\051-codes.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
				SetIdentifier:   aws.String("no-change"),
				Weight:          aws.Int64(10),
			},
			{
				Name:            aws.String("create-test.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.4.4")}},
			},
			{
				Name:            aws.String("create-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeAaaa,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1001")}},
			},
			{
				Name:            aws.String("update-test.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("4.3.2.1")}},
			},
			{
				Name:            aws.String("update-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeAaaa,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}},
			},
			{
				Name:            aws.String("create-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}, {Value: aws.String("8.8.4.4")}},
			},
			{
				Name:            aws.String("create-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeAaaa,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}, {Value: aws.String("2606:4700:4700::1001")}},
			},
			{
				Name:            aws.String("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeA,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}, {Value: aws.String("4.3.2.1")}},
			},
			{
				Name:            aws.String("update-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeAaaa,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1001")}, {Value: aws.String("2606:4700:4700::1111")}},
			},
			{
				Name:            aws.String("update-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeMx,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("20 mailhost3.foo.elb.amazonaws.com")}},
			},
			{
				Name:            aws.String("update-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do."),
				Type:            route53types.RRTypeNaptr,
				TTL:             aws.Int64(defaultTTL),
				ResourceRecords: []route53types.ResourceRecord{{Value: aws.String(`20 "U" "SIP+DTU" "" _sip._udp.sip2.example.com.`)}},
			},
		})
	}
}

func TestAWSApplyChangesDryRun(t *testing.T) {
	originalRecords := []route53types.ResourceRecordSet{
		{
			Name:            aws.String("update-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
		},
		{
			Name:            aws.String("delete-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
		},
		{
			Name:            aws.String("update-test.zone-2.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.4.4")}},
		},
		{
			Name:            aws.String("delete-test.zone-2.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.4.4")}},
		},
		{
			Name:            aws.String("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.1.1.1")}},
		},
		{
			Name:            aws.String("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeCname,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("bar.elb.amazonaws.com")}},
		},
		{
			Name:            aws.String("delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeCname,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("qux.elb.amazonaws.com")}},
		},
		{
			Name:            aws.String("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeCname,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("bar.elb.amazonaws.com")}},
		},
		{
			Name:            aws.String("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeCname,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("qux.elb.amazonaws.com")}},
		},
		{
			Name:            aws.String("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}, {Value: aws.String("8.8.4.4")}},
		},
		{
			Name:            aws.String("delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeA,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}, {Value: aws.String("4.3.2.1")}},
		},
		{
			Name:            aws.String("update-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeMx,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("20 mail.foo.elb.amazonaws.com")}},
		},
		{
			Name:            aws.String("delete-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do."),
			Type:            route53types.RRTypeMx,
			TTL:             aws.Int64(defaultTTL),
			ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("10 mail.bar.elb.amazonaws.com")}},
		},
	}

	provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, true, originalRecords)

	createRecords := []*endpoint.Endpoint{
		endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"),
		endpoint.NewEndpoint("create-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"),
		endpoint.NewEndpoint("create-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"),
		endpoint.NewEndpoint("create-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"),
		endpoint.NewEndpoint("create-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8", "8.8.4.4"),
		endpoint.NewEndpoint("create-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "30 mail.foo.elb.amazonaws.com"),
	}

	currentRecords := []*endpoint.Endpoint{
		endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"),
		endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"),
		endpoint.NewEndpoint("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.1.1.1"),
		endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "bar.elb.amazonaws.com"),
		endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "bar.elb.amazonaws.com"),
		endpoint.NewEndpoint("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8", "8.8.4.4"),
		endpoint.NewEndpoint("update-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "20 mail.foo.elb.amazonaws.com"),
	}
	updatedRecords := []*endpoint.Endpoint{
		endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"),
		endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "4.3.2.1"),
		endpoint.NewEndpoint("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"),
		endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "baz.elb.amazonaws.com"),
		endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "baz.elb.amazonaws.com"),
		endpoint.NewEndpoint("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4", "4.3.2.1"),
		endpoint.NewEndpoint("update-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "10 mail.bar.elb.amazonaws.com"),
	}

	deleteRecords := []*endpoint.Endpoint{
		endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"),
		endpoint.NewEndpoint("delete-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"),
		endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"),
		endpoint.NewEndpoint("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"),
		endpoint.NewEndpoint("delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4", "4.3.2.1"),
		endpoint.NewEndpoint("delete-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "10 mail.bar.elb.amazonaws.com"),
	}

	changes := &plan.Changes{
		Create:    createRecords,
		UpdateNew: updatedRecords,
		UpdateOld: currentRecords,
		Delete:    deleteRecords,
	}

	ctx := t.Context()

	require.NoError(t, provider.ApplyChanges(ctx, changes))

	validateRecords(t,
		append(
			listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do."),
			listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.")...),
		originalRecords)
}

func TestAWSChangesByZones(t *testing.T) {
	changes := Route53Changes{
		{
			Change: route53types.Change{
				Action: route53types.ChangeActionCreate,
				ResourceRecordSet: &route53types.ResourceRecordSet{
					Name: aws.String("qux.foo.example.org"), TTL: aws.Int64(1),
				},
			},
		},
		{
			Change: route53types.Change{
				Action: route53types.ChangeActionCreate,
				ResourceRecordSet: &route53types.ResourceRecordSet{
					Name: aws.String("qux.bar.example.org"), TTL: aws.Int64(2),
				},
			},
		},
		{
			Change: route53types.Change{
				Action: route53types.ChangeActionDelete,
				ResourceRecordSet: &route53types.ResourceRecordSet{
					Name: aws.String("wambo.foo.example.org"), TTL: aws.Int64(10),
				},
			},
		},
		{
			Change: route53types.Change{
				Action: route53types.ChangeActionDelete,
				ResourceRecordSet: &route53types.ResourceRecordSet{
					Name: aws.String("wambo.bar.example.org"), TTL: aws.Int64(20),
				},
			},
		},
	}

	zones := map[string]*profiledZone{
		"foo-example-org": {
			profile: defaultAWSProfile,
			zone: &route53types.HostedZone{
				Id:   aws.String("foo-example-org"),
				Name: aws.String("foo.example.org."),
			},
		},
		"bar-example-org": {
			profile: defaultAWSProfile,
			zone: &route53types.HostedZone{
				Id:   aws.String("bar-example-org"),
				Name: aws.String("bar.example.org."),
			},
		},
		"bar-example-org-private": {
			profile: defaultAWSProfile,
			zone: &route53types.HostedZone{
				Id:     aws.String("bar-example-org-private"),
				Name:   aws.String("bar.example.org."),
				Config: &route53types.HostedZoneConfig{PrivateZone: true},
			},
		},
		"baz-example-org": {
			profile: defaultAWSProfile,
			zone: &route53types.HostedZone{
				Id:   aws.String("baz-example-org"),
				Name: aws.String("baz.example.org."),
			},
		},
	}

	changesByZone := changesByZone(zones, changes)
	require.Len(t, changesByZone, 3)

	validateAWSChangeRecords(t, changesByZone["foo-example-org"], Route53Changes{
		{
			Change: route53types.Change{
				Action: route53types.ChangeActionCreate,
				ResourceRecordSet: &route53types.ResourceRecordSet{
					Name: aws.String("qux.foo.example.org"), TTL: aws.Int64(1),
				},
			},
		},
		{
			Change: route53types.Change{
				Action: route53types.ChangeActionDelete,
				ResourceRecordSet: &route53types.ResourceRecordSet{
					Name: aws.String("wambo.foo.example.org"), TTL: aws.Int64(10),
				},
			},
		},
	})

	validateAWSChangeRecords(t, changesByZone["bar-example-org"], Route53Changes{
		{
			Change: route53types.Change{
				Action: route53types.ChangeActionCreate,
				ResourceRecordSet: &route53types.ResourceRecordSet{
					Name: aws.String("qux.bar.example.org"), TTL: aws.Int64(2),
				},
			},
		},
		{
			Change: route53types.Change{
				Action: route53types.ChangeActionDelete,
				ResourceRecordSet: &route53types.ResourceRecordSet{
					Name: aws.String("wambo.bar.example.org"), TTL: aws.Int64(20),
				},
			},
		},
	})

	validateAWSChangeRecords(t, changesByZone["bar-example-org-private"], Route53Changes{
		{
			Change: route53types.Change{
				Action: route53types.ChangeActionCreate,
				ResourceRecordSet: &route53types.ResourceRecordSet{
					Name: aws.String("qux.bar.example.org"), TTL: aws.Int64(2),
				},
			},
		},
		{
			Change: route53types.Change{
				Action: route53types.ChangeActionDelete,
				ResourceRecordSet: &route53types.ResourceRecordSet{
					Name: aws.String("wambo.bar.example.org"), TTL: aws.Int64(20),
				},
			},
		},
	})
}

func TestAWSsubmitChanges(t *testing.T) {
	provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, false, nil)
	const subnets = 16
	const hosts = defaultBatchChangeSize / subnets

	endpoints := make([]*endpoint.Endpoint, 0)
	for i := range subnets {
		for j := 1; j < (hosts + 1); j++ {
			hostname := fmt.Sprintf("subnet%dhost%d.zone-1.ext-dns-test-2.teapot.zalan.do", i, j)
			ip := fmt.Sprintf("1.1.%d.%d", i, j)
			ep := endpoint.NewEndpointWithTTL(hostname, endpoint.RecordTypeA, endpoint.TTL(defaultTTL), ip)
			endpoints = append(endpoints, ep)
		}
	}

	ctx := t.Context()
	zones, _ := provider.zones(ctx)
	records, _ := provider.Records(ctx)
	cs := make(Route53Changes, 0, len(endpoints))
	cs = append(cs, provider.newChanges(route53types.ChangeActionCreate, endpoints)...)

	require.NoError(t, provider.submitChanges(ctx, cs, zones))

	records, err := provider.Records(ctx)
	require.NoError(t, err)

	validateEndpoints(t, provider, records, endpoints)
}

func TestAWSsubmitChangesError(t *testing.T) {
	provider, clientStub := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, false, nil)
	clientStub.MockMethod("ChangeResourceRecordSets", mock.Anything).Return(nil, fmt.Errorf("Mock route53 failure"))

	ctx := t.Context()
	zones, err := provider.zones(ctx)
	require.NoError(t, err)

	ep := endpoint.NewEndpointWithTTL("fail.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.0.0.1")
	cs := provider.newChanges(route53types.ChangeActionCreate, []*endpoint.Endpoint{ep})

	require.Error(t, provider.submitChanges(ctx, cs, zones))
}

func TestAWSsubmitChangesRetryOnError(t *testing.T) {
	provider, clientStub := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, false, nil)

	ctx := t.Context()
	zones, err := provider.zones(ctx)
	require.NoError(t, err)

	ep1 := endpoint.NewEndpointWithTTL("success.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.0.0.1")
	ep2 := endpoint.NewEndpointWithTTL("fail.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.0.0.2")
	ep3 := endpoint.NewEndpointWithTTL("success2.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.0.0.3")

	ep2txt := endpoint.NewEndpointWithTTL("fail__edns_housekeeping.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeTXT, endpoint.TTL(defaultTTL), "something") // "__edns_housekeeping" is the TXT suffix
	ep2txt.Labels = map[string]string{
		endpoint.OwnedRecordLabelKey: "fail.zone-1.ext-dns-test-2.teapot.zalan.do",
	}

	// "success" and "fail" are created in the first step, both are submitted in the same batch; this should fail
	cs1 := provider.newChanges(route53types.ChangeActionCreate, []*endpoint.Endpoint{ep2, ep2txt, ep1})
	input1 := &route53.ChangeResourceRecordSetsInput{
		HostedZoneId: aws.String("/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do."),
		ChangeBatch: &route53types.ChangeBatch{
			Changes: cs1.Route53Changes(),
		},
	}
	clientStub.MockMethod("ChangeResourceRecordSets", input1).Return(nil, fmt.Errorf("Mock route53 failure"))

	// because of the failure, changes will be retried one by one; make "fail" submitted in its own batch fail as well
	cs2 := provider.newChanges(route53types.ChangeActionCreate, []*endpoint.Endpoint{ep2, ep2txt})
	input2 := &route53.ChangeResourceRecordSetsInput{
		HostedZoneId: aws.String("/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do."),
		ChangeBatch: &route53types.ChangeBatch{
			Changes: cs2.Route53Changes(),
		},
	}
	clientStub.MockMethod("ChangeResourceRecordSets", input2).Return(nil, fmt.Errorf("Mock route53 failure"))

	// "success" should have been created, verify that we still get an error because "fail" failed
	require.Error(t, provider.submitChanges(ctx, cs1, zones))

	// assert that "success" was successfully created and "fail" and its TXT record were not
	records, err := provider.Records(ctx)
	require.NoError(t, err)
	require.True(t, containsRecordWithDNSName(records, "success.zone-1.ext-dns-test-2.teapot.zalan.do"))
	require.False(t, containsRecordWithDNSName(records, "fail.zone-1.ext-dns-test-2.teapot.zalan.do"))
	require.False(t, containsRecordWithDNSName(records, "fail__edns_housekeeping.zone-1.ext-dns-test-2.teapot.zalan.do"))

	// next batch should contain "fail" and "success2", should succeed this time
	cs3 := provider.newChanges(route53types.ChangeActionCreate, []*endpoint.Endpoint{ep2, ep2txt, ep3})
	require.NoError(t, provider.submitChanges(ctx, cs3, zones))

	// verify all records are there
	records, err = provider.Records(ctx)
	require.NoError(t, err)
	require.True(t, containsRecordWithDNSName(records, "success.zone-1.ext-dns-test-2.teapot.zalan.do"))
	require.True(t, containsRecordWithDNSName(records, "fail.zone-1.ext-dns-test-2.teapot.zalan.do"))
	require.True(t, containsRecordWithDNSName(records, "success2.zone-1.ext-dns-test-2.teapot.zalan.do"))
	require.True(t, containsRecordWithDNSName(records, "fail__edns_housekeeping.zone-1.ext-dns-test-2.teapot.zalan.do"))
}

func TestAWSBatchChangeSet(t *testing.T) {
	var cs Route53Changes

	for i := 1; i <= defaultBatchChangeSize; i += 2 {
		cs = append(cs, &Route53Change{
			Change: route53types.Change{
				Action: route53types.ChangeActionCreate,
				ResourceRecordSet: &route53types.ResourceRecordSet{
					Name: aws.String(fmt.Sprintf("host-%d", i)),
					Type: route53types.RRTypeA,
				},
			},
		})
		cs = append(cs, &Route53Change{
			Change: route53types.Change{
				Action: route53types.ChangeActionCreate,
				ResourceRecordSet: &route53types.ResourceRecordSet{
					Name: aws.String(fmt.Sprintf("host-%d", i)),
					Type: route53types.RRTypeTxt,
				},
			},
		})
	}

	batchCs := batchChangeSet(cs, defaultBatchChangeSize, defaultBatchChangeSizeBytes, defaultBatchChangeSizeValues)

	require.Len(t, batchCs, 1)

	// sorting cs not needed as it should be returned as is
	validateAWSChangeRecords(t, batchCs[0], cs)
}

func TestAWSBatchChangeSetExceeding(t *testing.T) {
	var cs Route53Changes
	const testCount = 50
	const testLimit = 11
	const expectedBatchCount = 5
	const expectedChangesCount = 10

	for i := 1; i <= testCount; i += 2 {
		cs = append(cs,
			&Route53Change{
				Change: route53types.Change{
					Action: route53types.ChangeActionCreate,
					ResourceRecordSet: &route53types.ResourceRecordSet{
						Name: aws.String(fmt.Sprintf("host-%d", i)),
						Type: route53types.RRTypeA,
					},
				},
			},
			&Route53Change{
				Change: route53types.Change{
					Action: route53types.ChangeActionCreate,
					ResourceRecordSet: &route53types.ResourceRecordSet{
						Name: aws.String(fmt.Sprintf("host-%d", i)),
						Type: route53types.RRTypeTxt,
					},
				},
			},
		)
	}

	batchCs := batchChangeSet(cs, testLimit, defaultBatchChangeSizeBytes, defaultBatchChangeSizeValues)

	require.Len(t, batchCs, expectedBatchCount)

	// sorting cs needed to match batchCs
	for i, batch := range batchCs {
		validateAWSChangeRecords(t, batch, sortChangesByActionNameType(cs)[i*expectedChangesCount:expectedChangesCount*(i+1)])
	}
}

func TestAWSBatchChangeSetExceedingNameChange(t *testing.T) {
	var cs Route53Changes
	const testCount = 10
	const testLimit = 1

	for i := 1; i <= testCount; i += 2 {
		cs = append(cs,
			&Route53Change{
				Change: route53types.Change{
					Action: route53types.ChangeActionCreate,
					ResourceRecordSet: &route53types.ResourceRecordSet{
						Name: aws.String(fmt.Sprintf("host-%d", i)),
						Type: route53types.RRTypeA,
					},
				},
			},
			&Route53Change{
				Change: route53types.Change{
					Action: route53types.ChangeActionCreate,
					ResourceRecordSet: &route53types.ResourceRecordSet{
						Name: aws.String(fmt.Sprintf("host-%d", i)),
						Type: route53types.RRTypeTxt,
					},
				},
			},
		)
	}

	batchCs := batchChangeSet(cs, testLimit, defaultBatchChangeSizeBytes, defaultBatchChangeSizeValues)

	require.Empty(t, batchCs)
}

func TestAWSBatchChangeSetExceedingBytesLimit(t *testing.T) {
	const (
		testCount = 50
		testLimit = 100
		groupSize = 2
	)

	var (
		cs Route53Changes
		// Bytes for each name
		testBytes = len([]byte("1.2.3.4")) + len([]byte("test-record"))
		// testCount / groupSize / (testLimit // bytes)
		expectedBatchCountFloat = float64(testCount) / float64(groupSize) / float64(testLimit/testBytes)
		// Round up
		expectedBatchCount = int(math.Ceil(expectedBatchCountFloat))
	)

	for i := 1; i <= testCount; i += groupSize {
		cs = append(cs,
			&Route53Change{
				Change: route53types.Change{
					Action: route53types.ChangeActionCreate,
					ResourceRecordSet: &route53types.ResourceRecordSet{
						Name: aws.String(fmt.Sprintf("host-%d", i)),
						Type: route53types.RRTypeA,
						ResourceRecords: []route53types.ResourceRecord{
							{
								Value: aws.String("1.2.3.4"),
							},
						},
					},
				},
				sizeBytes:  len([]byte("1.2.3.4")),
				sizeValues: 1,
			},
			&Route53Change{
				Change: route53types.Change{
					Action: route53types.ChangeActionCreate,
					ResourceRecordSet: &route53types.ResourceRecordSet{
						Name: aws.String(fmt.Sprintf("host-%d", i)),
						Type: route53types.RRTypeTxt,
						ResourceRecords: []route53types.ResourceRecord{
							{
								Value: aws.String("txt-record"),
							},
						},
					},
				},
				sizeBytes:  len([]byte("txt-record")),
				sizeValues: 1,
			},
		)
	}

	batchCs := batchChangeSet(cs, defaultBatchChangeSize, testLimit, defaultBatchChangeSizeValues)

	require.Len(t, batchCs, expectedBatchCount)
}

func TestAWSBatchChangeSetExceedingBytesLimitUpsert(t *testing.T) {
	const (
		testCount = 50
		testLimit = 100
		groupSize = 2
	)

	var (
		cs Route53Changes
		// Bytes for each name multiplied by 2 for Upsert records
		testBytes = (len([]byte("1.2.3.4")) + len([]byte("test-record"))) * 2
		// testCount / groupSize / (testLimit // bytes)
		expectedBatchCountFloat = float64(testCount) / float64(groupSize) / float64(testLimit/testBytes)
		// Round up
		expectedBatchCount = int(math.Ceil(expectedBatchCountFloat))
	)

	for i := 1; i <= testCount; i += groupSize {
		cs = append(cs,
			&Route53Change{
				Change: route53types.Change{
					Action: route53types.ChangeActionUpsert,
					ResourceRecordSet: &route53types.ResourceRecordSet{
						Name: aws.String(fmt.Sprintf("host-%d", i)),
						Type: route53types.RRTypeA,
						ResourceRecords: []route53types.ResourceRecord{
							{
								Value: aws.String("1.2.3.4"),
							},
						},
					},
				},
				sizeBytes:  len([]byte("1.2.3.4")) * 2,
				sizeValues: 1,
			},
			&Route53Change{
				Change: route53types.Change{
					Action: route53types.ChangeActionUpsert,
					ResourceRecordSet: &route53types.ResourceRecordSet{
						Name: aws.String(fmt.Sprintf("host-%d", i)),
						Type: route53types.RRTypeTxt,
						ResourceRecords: []route53types.ResourceRecord{
							{
								Value: aws.String("txt-record"),
							},
						},
					},
				},
				sizeBytes:  len([]byte("txt-record")) * 2,
				sizeValues: 1,
			},
		)
	}

	batchCs := batchChangeSet(cs, defaultBatchChangeSize, testLimit, defaultBatchChangeSizeValues)

	require.Len(t, batchCs, expectedBatchCount)
}

func TestAWSBatchChangeSetExceedingValuesLimit(t *testing.T) {
	const (
		testCount = 50
		testLimit = 100
		groupSize = 2
		// Values for each group
		testValues = 2
	)

	var (
		cs Route53Changes
		// testCount / groupSize / (testLimit // bytes)
		expectedBatchCountFloat = float64(testCount) / float64(groupSize) / float64(testLimit/testValues)
		// Round up
		expectedBatchCount = int(math.Ceil(expectedBatchCountFloat))
	)

	for i := 1; i <= testCount; i += groupSize {
		cs = append(cs,
			&Route53Change{
				Change: route53types.Change{
					Action: route53types.ChangeActionCreate,
					ResourceRecordSet: &route53types.ResourceRecordSet{
						Name: aws.String(fmt.Sprintf("host-%d", i)),
						Type: route53types.RRTypeA,
						ResourceRecords: []route53types.ResourceRecord{
							{
								Value: aws.String("1.2.3.4"),
							},
						},
					},
				},
				sizeBytes:  len([]byte("1.2.3.4")),
				sizeValues: 1,
			},
			&Route53Change{
				Change: route53types.Change{
					Action: route53types.ChangeActionCreate,
					ResourceRecordSet: &route53types.ResourceRecordSet{
						Name: aws.String(fmt.Sprintf("host-%d", i)),
						Type: route53types.RRTypeTxt,
						ResourceRecords: []route53types.ResourceRecord{
							{
								Value: aws.String("txt-record"),
							},
						},
					},
				},
				sizeBytes:  len([]byte("txt-record")),
				sizeValues: 1,
			},
		)
	}

	batchCs := batchChangeSet(cs, defaultBatchChangeSize, defaultBatchChangeSizeBytes, testLimit)

	require.Len(t, batchCs, expectedBatchCount)
}

func TestAWSBatchChangeSetExceedingValuesLimitUpsert(t *testing.T) {
	const (
		testCount = 50
		testLimit = 100
		groupSize = 2
		// Values for each group multiplied by 2 for Upsert records
		testValues = 2 * 2
	)

	var (
		cs Route53Changes
		// testCount / groupSize / (testLimit // bytes)
		expectedBatchCountFloat = float64(testCount) / float64(groupSize) / float64(testLimit/testValues)
		// Round up
		expectedBatchCount = int(math.Ceil(expectedBatchCountFloat))
	)

	for i := 1; i <= testCount; i += groupSize {
		cs = append(cs,
			&Route53Change{
				Change: route53types.Change{
					Action: route53types.ChangeActionUpsert,
					ResourceRecordSet: &route53types.ResourceRecordSet{
						Name: aws.String(fmt.Sprintf("host-%d", i)),
						Type: route53types.RRTypeA,
						ResourceRecords: []route53types.ResourceRecord{
							{
								Value: aws.String("1.2.3.4"),
							},
						},
					},
				},
				sizeBytes:  len([]byte("1.2.3.4")) * 2,
				sizeValues: 1,
			},
			&Route53Change{
				Change: route53types.Change{
					Action: route53types.ChangeActionUpsert,
					ResourceRecordSet: &route53types.ResourceRecordSet{
						Name: aws.String(fmt.Sprintf("host-%d", i)),
						Type: route53types.RRTypeTxt,
						ResourceRecords: []route53types.ResourceRecord{
							{
								Value: aws.String("txt-record"),
							},
						},
					},
				},
				sizeBytes:  len([]byte("txt-record")) * 2,
				sizeValues: 1,
			},
		)
	}

	batchCs := batchChangeSet(cs, defaultBatchChangeSize, defaultBatchChangeSizeBytes, testLimit)

	require.Len(t, batchCs, expectedBatchCount)
}

func validateEndpoints(t *testing.T, provider *AWSProvider, endpoints []*endpoint.Endpoint, expected []*endpoint.Endpoint) {
	assert.True(t, testutils.SameEndpoints(endpoints, expected), "actual and expected endpoints don't match. %+v:%+v", endpoints, expected)

	normalized, err := provider.AdjustEndpoints(endpoints)
	assert.NoError(t, err)
	assert.True(t, testutils.SameEndpoints(normalized, expected), "normalized and expected endpoints don't match. %+v:%+v", normalized, expected)
}

func validateAWSZones(t *testing.T, zones map[string]*route53types.HostedZone, expected map[string]*route53types.HostedZone) {
	require.Len(t, zones, len(expected))

	for i, zone := range zones {
		validateAWSZone(t, zone, expected[i])
	}
}

func validateAWSZone(t *testing.T, zone *route53types.HostedZone, expected *route53types.HostedZone) {
	assert.Equal(t, *expected.Id, *zone.Id)
	assert.Equal(t, *expected.Name, *zone.Name)
}

func validateAWSChangeRecords(t *testing.T, records Route53Changes, expected Route53Changes) {
	require.Len(t, records, len(expected))

	for i := range records {
		validateAWSChangeRecord(t, records[i], expected[i])
	}
}

func validateAWSChangeRecord(t *testing.T, record *Route53Change, expected *Route53Change) {
	assert.Equal(t, expected.Action, record.Action)
	assert.Equal(t, *expected.ResourceRecordSet.Name, *record.ResourceRecordSet.Name)
	assert.Equal(t, expected.ResourceRecordSet.Type, record.ResourceRecordSet.Type)
}

func TestAWSCreateRecordsWithCNAME(t *testing.T) {
	provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, false, nil)

	records := []*endpoint.Endpoint{
		{DNSName: "create-test.zone-1.ext-dns-test-2.teapot.zalan.do", Targets: endpoint.Targets{"foo.example.org"}, RecordType: endpoint.RecordTypeCNAME},
	}

	adjusted, err := provider.AdjustEndpoints(records)
	require.NoError(t, err)
	require.NoError(t, provider.ApplyChanges(t.Context(), &plan.Changes{
		Create: adjusted,
	}))

	recordSets := listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.")

	validateRecords(t, recordSets, []route53types.ResourceRecordSet{
		{
			Name: aws.String("create-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
			Type: route53types.RRTypeCname,
			TTL:  aws.Int64(300),
			ResourceRecords: []route53types.ResourceRecord{
				{
					Value: aws.String("foo.example.org"),
				},
			},
		},
	})
}

func TestAWSCreateRecordsWithALIAS(t *testing.T) {
	for key, evaluateTargetHealth := range map[string]bool{
		"true":  true,
		"false": false,
		"":      false,
	} {
		provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, false, nil)
		records := []*endpoint.Endpoint{
			{
				DNSName:    "create-test.zone-1.ext-dns-test-2.teapot.zalan.do",
				Targets:    endpoint.Targets{"foo.eu-central-1.elb.amazonaws.com"},
				RecordType: endpoint.RecordTypeA,
				ProviderSpecific: endpoint.ProviderSpecific{
					endpoint.ProviderSpecificProperty{
						Name:  providerSpecificAlias,
						Value: "true",
					},
					endpoint.ProviderSpecificProperty{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: key,
					},
				},
			},
			{
				DNSName:    "create-test.zone-1.ext-dns-test-2.teapot.zalan.do",
				Targets:    endpoint.Targets{"foo.eu-central-1.elb.amazonaws.com"},
				RecordType: endpoint.RecordTypeAAAA,
				ProviderSpecific: endpoint.ProviderSpecific{
					endpoint.ProviderSpecificProperty{
						Name:  providerSpecificAlias,
						Value: "true",
					},
					endpoint.ProviderSpecificProperty{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: key,
					},
				},
			},
		}
		adjusted, err := provider.AdjustEndpoints(records)
		require.NoError(t, err)
		require.NoError(t, provider.ApplyChanges(t.Context(), &plan.Changes{
			Create: adjusted,
		}))

		recordSets := listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.")

		validateRecords(t, recordSets, []route53types.ResourceRecordSet{
			{
				AliasTarget: &route53types.AliasTarget{
					DNSName:              aws.String("foo.eu-central-1.elb.amazonaws.com."),
					EvaluateTargetHealth: evaluateTargetHealth,
					HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
				},
				Name: aws.String("create-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type: route53types.RRTypeA,
			},
			{
				AliasTarget: &route53types.AliasTarget{
					DNSName:              aws.String("foo.eu-central-1.elb.amazonaws.com."),
					EvaluateTargetHealth: evaluateTargetHealth,
					HostedZoneId:         aws.String("Z215JYRZR1TBD5"),
				},
				Name: aws.String("create-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
				Type: route53types.RRTypeAaaa,
			},
		})
	}
}

func TestAWSisLoadBalancer(t *testing.T) {
	for _, tc := range []struct {
		target      string
		recordType  string
		preferCNAME bool
		expected    bool
	}{
		{"bar.eu-central-1.elb.amazonaws.com", endpoint.RecordTypeCNAME, false, true},
		{"bar.eu-central-1.elb.amazonaws.com", endpoint.RecordTypeCNAME, true, false},
		{"foo.example.org", endpoint.RecordTypeCNAME, false, false},
		{"foo.example.org", endpoint.RecordTypeCNAME, true, false},
	} {
		ep := &endpoint.Endpoint{
			Targets:    endpoint.Targets{tc.target},
			RecordType: tc.recordType,
		}
		assert.Equal(t, tc.expected, useAlias(ep, tc.preferCNAME))
	}
}

func TestAWSisAWSAlias(t *testing.T) {
	for _, tc := range []struct {
		target     string
		recordType string
		alias      bool
		hz         string
	}{
		{"foo.example.org", endpoint.RecordTypeA, false, ""},                                    // normal A record
		{"foo.example.org", endpoint.RecordTypeAAAA, false, ""},                                 // normal AAAA record
		{"bar.eu-central-1.elb.amazonaws.com", endpoint.RecordTypeA, true, "Z215JYRZR1TBD5"},    // pointing to ELB DNS name (alias A)
		{"bar.eu-central-1.elb.amazonaws.com", endpoint.RecordTypeAAAA, true, "Z215JYRZR1TBD5"}, // pointing to ELB DNS name (alias AAAA)
		{"foobar.example.org", endpoint.RecordTypeA, true, "Z1234567890ABC"},                    // HZID retrieved by Route53 (alias A)
		{"foobar.example.org", endpoint.RecordTypeAAAA, true, "Z1234567890ABC"},                 // HZID retrieved by Route53 (alias AAAA)
		{"baz.example.org", endpoint.RecordTypeA, true, sameZoneAlias},                          // record to be created (alias A)
		{"baz.example.org", endpoint.RecordTypeAAAA, true, sameZoneAlias},                       // record to be created (alias AAAA)
	} {
		ep := &endpoint.Endpoint{
			Targets:    endpoint.Targets{tc.target},
			RecordType: tc.recordType,
		}
		if tc.alias {
			ep = ep.WithProviderSpecific(providerSpecificAlias, "true")
			ep = ep.WithProviderSpecific(providerSpecificTargetHostedZone, tc.hz)
		}
		assert.Equal(t, tc.hz, isAWSAlias(ep), "%v", tc)
	}
}

func TestAWSCanonicalHostedZone(t *testing.T) {
	for suffix, id := range canonicalHostedZones {
		zone := canonicalHostedZone(fmt.Sprintf("foo.%s", suffix))
		assert.Equal(t, id, zone, "zone suffix: %s", suffix)
	}

	zone := canonicalHostedZone("foo.example.org")
	assert.Empty(t, zone, "no canonical zone should be returned for a non-aws hostname")
}

func TestAWSCanonicalHostedZoneNotExist(t *testing.T) {
	var buf bytes.Buffer
	log.SetOutput(&buf)
	host := "foo.elb.eastwest-1.amazonaws.com"
	_ = canonicalHostedZone(host)
	assert.Containsf(t, buf.String(), "Could not find canonical hosted zone for domain", host)
}

func BenchmarkTestAWSCanonicalHostedZone(b *testing.B) {
	for b.Loop() {
		for suffix := range canonicalHostedZones {
			_ = canonicalHostedZone(fmt.Sprintf("foo.%s", suffix))
		}
	}
}

func BenchmarkTestAWSNonCanonicalHostedZone(b *testing.B) {
	for b.Loop() {
		for range canonicalHostedZones {
			_ = canonicalHostedZone("extremely.long.zone-2.ext.dns.test.zone.non.canonical.example.com")
		}
	}
}

func TestAWSSuitableZones(t *testing.T) {
	zones := map[string]*profiledZone{
		// Public domain
		"example-org": {profile: defaultAWSProfile, zone: &route53types.HostedZone{Id: aws.String("example-org"), Name: aws.String("example.org.")}},
		// Public subdomain
		"bar-example-org": {profile: defaultAWSProfile, zone: &route53types.HostedZone{Id: aws.String("bar-example-org"), Name: aws.String("bar.example.org."), Config: &route53types.HostedZoneConfig{PrivateZone: false}}},
		// Public subdomain
		"longfoo-bar-example-org": {profile: defaultAWSProfile, zone: &route53types.HostedZone{Id: aws.String("longfoo-bar-example-org"), Name: aws.String("longfoo.bar.example.org.")}},
		// Private domain
		"example-org-private": {profile: defaultAWSProfile, zone: &route53types.HostedZone{Id: aws.String("example-org-private"), Name: aws.String("example.org."), Config: &route53types.HostedZoneConfig{PrivateZone: true}}},
		// Private subdomain
		"bar-example-org-private": {profile: defaultAWSProfile, zone: &route53types.HostedZone{Id: aws.String("bar-example-org-private"), Name: aws.String("bar.example.org."), Config: &route53types.HostedZoneConfig{PrivateZone: true}}},
	}

	for _, tc := range []struct {
		hostname string
		expected []*profiledZone
	}{
		// bar.example.org is NOT suitable
		{"foobar.example.org.", []*profiledZone{zones["example-org-private"], zones["example-org"]}},

		// all matching private zones are suitable
		// https://github.com/kubernetes-sigs/external-dns/pull/356
		{"bar.example.org.", []*profiledZone{zones["example-org-private"], zones["bar-example-org-private"], zones["bar-example-org"]}},

		{"foo.bar.example.org.", []*profiledZone{zones["example-org-private"], zones["bar-example-org-private"], zones["bar-example-org"]}},
		{"foo.example.org.", []*profiledZone{zones["example-org-private"], zones["example-org"]}},
		{"foo.kubernetes.io.", nil},
	} {
		suitableZones := suitableZones(tc.hostname, zones)
		sort.Slice(suitableZones, func(i, j int) bool {
			return *suitableZones[i].zone.Id < *suitableZones[j].zone.Id
		})
		sort.Slice(tc.expected, func(i, j int) bool {
			return *tc.expected[i].zone.Id < *tc.expected[j].zone.Id
		})
		assert.Equal(t, tc.expected, suitableZones)
	}
}

func createAWSZone(t *testing.T, provider *AWSProvider, zone *route53types.HostedZone) {
	params := &route53.CreateHostedZoneInput{
		CallerReference:  aws.String("external-dns.alpha.kubernetes.io/test-zone"),
		Name:             zone.Name,
		HostedZoneConfig: zone.Config,
	}

	if _, err := provider.clients[defaultAWSProfile].CreateHostedZone(t.Context(), params); err != nil {
		var hzExists *route53types.HostedZoneAlreadyExists
		require.ErrorAs(t, err, &hzExists)
	}
}

func setAWSRecords(t *testing.T, provider *AWSProvider, records []route53types.ResourceRecordSet) {
	dryRun := provider.dryRun
	provider.dryRun = false
	defer func() {
		provider.dryRun = dryRun
	}()

	ctx := t.Context()
	endpoints, err := provider.Records(ctx)
	require.NoError(t, err)

	validateEndpoints(t, provider, endpoints, []*endpoint.Endpoint{})

	var changes Route53Changes
	for _, record := range records {
		changes = append(changes, &Route53Change{
			Change: route53types.Change{
				Action:            route53types.ChangeActionCreate,
				ResourceRecordSet: &record,
			},
		})
	}

	zones, err := provider.zones(ctx)
	require.NoError(t, err)
	err = provider.submitChanges(ctx, changes, zones)
	require.NoError(t, err)

	_, err = provider.Records(ctx)
	require.NoError(t, err)
}

func listAWSRecords(t *testing.T, client Route53API, zone string) []route53types.ResourceRecordSet {
	resp, err := client.ListResourceRecordSets(t.Context(), &route53.ListResourceRecordSetsInput{
		HostedZoneId: aws.String(zone),
		MaxItems:     aws.Int32(route53PageSize),
	})
	require.NoError(t, err)

	return resp.ResourceRecordSets
}

func newAWSProvider(t *testing.T, domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, zoneTypeFilter provider.ZoneTypeFilter, evaluateTargetHealth, preferCNAME, dryRun bool, records []route53types.ResourceRecordSet) (*AWSProvider, *Route53APIStub) {
	return newAWSProviderWithTagFilter(t, domainFilter, zoneIDFilter, zoneTypeFilter, provider.NewZoneTagFilter([]string{}), evaluateTargetHealth, preferCNAME, dryRun, records)
}

func newAWSProviderWithTagFilter(t *testing.T, domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, zoneTypeFilter provider.ZoneTypeFilter, zoneTagFilter provider.ZoneTagFilter, evaluateTargetHealth, preferCNAME, dryRun bool, records []route53types.ResourceRecordSet) (*AWSProvider, *Route53APIStub) {
	client := NewRoute53APIStub(t)

	provider := &AWSProvider{
		clients:               map[string]Route53API{defaultAWSProfile: client},
		batchChangeSize:       defaultBatchChangeSize,
		batchChangeSizeBytes:  defaultBatchChangeSizeBytes,
		batchChangeSizeValues: defaultBatchChangeSizeValues,
		batchChangeInterval:   defaultBatchChangeInterval,
		evaluateTargetHealth:  evaluateTargetHealth,
		domainFilter:          domainFilter,
		zoneIDFilter:          zoneIDFilter,
		zoneTypeFilter:        zoneTypeFilter,
		zoneTagFilter:         zoneTagFilter,
		preferCNAME:           preferCNAME,
		dryRun:                false,
		zonesCache:            blueprint.NewZoneCache[map[string]*profiledZone](1 * time.Minute),
		failedChangesQueue:    make(map[string]Route53Changes),
	}

	createAWSZone(t, provider, &route53types.HostedZone{
		Id:     aws.String("/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do."),
		Name:   aws.String("zone-1.ext-dns-test-2.teapot.zalan.do."),
		Config: &route53types.HostedZoneConfig{PrivateZone: false},
	})

	createAWSZone(t, provider, &route53types.HostedZone{
		Id:     aws.String("/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do."),
		Name:   aws.String("zone-2.ext-dns-test-2.teapot.zalan.do."),
		Config: &route53types.HostedZoneConfig{PrivateZone: false},
	})

	createAWSZone(t, provider, &route53types.HostedZone{
		Id:     aws.String("/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do."),
		Name:   aws.String("zone-3.ext-dns-test-2.teapot.zalan.do."),
		Config: &route53types.HostedZoneConfig{PrivateZone: true},
	})

	// filtered out by domain filter
	createAWSZone(t, provider, &route53types.HostedZone{
		Id:     aws.String("/hostedzone/zone-4.ext-dns-test-3.teapot.zalan.do."),
		Name:   aws.String("zone-4.ext-dns-test-3.teapot.zalan.do."),
		Config: &route53types.HostedZoneConfig{PrivateZone: false},
	})

	setupZoneTags(provider.clients[defaultAWSProfile].(*Route53APIStub))

	setAWSRecords(t, provider, records)

	provider.dryRun = dryRun

	return provider, client
}

func setupZoneTags(client *Route53APIStub) {
	addZoneTags(client.zoneTags, "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.", map[string]string{
		"zone-1-tag-1": "tag-1-value",
		"domain":       "test-2",
		"zone":         "1",
	})
	addZoneTags(client.zoneTags, "/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.", map[string]string{
		"zone-2-tag-1": "tag-1-value",
		"domain":       "test-2",
		"zone":         "2",
	})
	addZoneTags(client.zoneTags, "/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do.", map[string]string{
		"zone-3-tag-1": "tag-1-value",
		"domain":       "test-2",
		"zone":         "3",
	})
	addZoneTags(client.zoneTags, "/hostedzone/zone-4.ext-dns-test-2.teapot.zalan.do.", map[string]string{
		"zone-4-tag-1": "tag-1-value",
		"domain":       "test-3",
		"zone":         "4",
	})
}

func addZoneTags(tagMap map[string][]route53types.Tag, zoneID string, tags map[string]string) {
	tagList := make([]route53types.Tag, 0, len(tags))
	for k, v := range tags {
		tagList = append(tagList, route53types.Tag{
			Key:   aws.String(k),
			Value: aws.String(v),
		})
	}
	tagMap[zoneID] = tagList
}

func validateRecords(t *testing.T, records []route53types.ResourceRecordSet, expected []route53types.ResourceRecordSet) {
	assert.ElementsMatch(t, expected, records)
}

func containsRecordWithDNSName(records []*endpoint.Endpoint, dnsName string) bool {
	for _, record := range records {
		if record.DNSName == dnsName {
			return true
		}
	}
	return false
}

func TestRequiresDeleteCreate(t *testing.T) {
	provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"foo.bar."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, false, nil)

	oldRecordType := endpoint.NewEndpointWithTTL("recordType", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "8.8.8.8")
	newRecordType := endpoint.NewEndpointWithTTL("recordType", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), "bar").WithProviderSpecific(providerSpecificAlias, "false")

	assert.False(t, provider.requiresDeleteCreate(oldRecordType, oldRecordType), "actual and expected endpoints don't match. %+v:%+v", oldRecordType, oldRecordType)
	assert.True(t, provider.requiresDeleteCreate(oldRecordType, newRecordType), "actual and expected endpoints don't match. %+v:%+v", oldRecordType, newRecordType)

	oldAtoAlias := endpoint.NewEndpointWithTTL("AtoAlias", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.1.1.1")
	newAtoAlias := endpoint.NewEndpointWithTTL("AtoAlias", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "bar.us-east-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true")

	assert.False(t, provider.requiresDeleteCreate(oldAtoAlias, oldAtoAlias), "actual and expected endpoints don't match. %+v:%+v", oldAtoAlias, oldAtoAlias.DNSName)
	assert.True(t, provider.requiresDeleteCreate(oldAtoAlias, newAtoAlias), "actual and expected endpoints don't match. %+v:%+v", oldAtoAlias, newAtoAlias)

	oldPolicy := endpoint.NewEndpointWithTTL("policy", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "8.8.8.8").WithSetIdentifier("nochange").WithProviderSpecific(providerSpecificRegion, "us-east-1")
	newPolicy := endpoint.NewEndpointWithTTL("policy", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "8.8.8.8").WithSetIdentifier("nochange").WithProviderSpecific(providerSpecificWeight, "10")

	assert.False(t, provider.requiresDeleteCreate(oldPolicy, oldPolicy), "actual and expected endpoints don't match. %+v:%+v", oldPolicy, oldPolicy)
	assert.True(t, provider.requiresDeleteCreate(oldPolicy, newPolicy), "actual and expected endpoints don't match. %+v:%+v", oldPolicy, newPolicy)

	oldSetIdentifier := endpoint.NewEndpointWithTTL("setIdentifier", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "8.8.8.8").WithSetIdentifier("old")
	newSetIdentifier := endpoint.NewEndpointWithTTL("setIdentifier", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "8.8.8.8").WithSetIdentifier("new")

	assert.False(t, provider.requiresDeleteCreate(oldSetIdentifier, oldSetIdentifier), "actual and expected endpoints don't match. %+v:%+v", oldSetIdentifier, oldSetIdentifier)
	assert.True(t, provider.requiresDeleteCreate(oldSetIdentifier, newSetIdentifier), "actual and expected endpoints don't match. %+v:%+v", oldSetIdentifier, newSetIdentifier)
}

func TestConvertOctalToAscii(t *testing.T) {
	tests := []struct {
		name     string
		input    string
		expected string
	}{
		{
			name:     "Characters escaped !\"#$%&'()*+,-/:;",
			input:    "txt-\\041\\042\\043\\044\\045\\046\\047\\050\\051\\052\\053\\054-\\057\\072\\073-test.example.com",
			expected: "txt-!\"#$%&'()*+,-/:;-test.example.com",
		},
		{
			name:     "Characters escaped <=>?@[\\]^_`{|}~",
			input:    "txt-\\074\\075\\076\\077\\100\\133\\134\\135\\136_\\140\\173\\174\\175\\176-test2.example.com",
			expected: "txt-<=>?@[\\]^_`{|}~-test2.example.com",
		},
		{
			name:     "No escaped characters in domain",
			input:    "txt-awesome-test3.example.com",
			expected: "txt-awesome-test3.example.com",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			actual := convertOctalToAscii(tt.input)
			assert.Equal(t, tt.expected, actual)
		})
	}
}

func TestGeoProximityWithAWSRegion(t *testing.T) {
	tests := []struct {
		name           string
		region         string
		hasRegion      bool
		expectedSet    bool
		expectedRegion string
	}{
		{
			name:           "valid AWS region",
			region:         "us-west-2",
			hasRegion:      true,
			expectedSet:    true,
			expectedRegion: "us-west-2",
		},
		{
			name:           "another valid AWS region",
			region:         "eu-central-1",
			hasRegion:      true,
			expectedSet:    true,
			expectedRegion: "eu-central-1",
		},
		{
			name:           "empty region string",
			region:         "",
			hasRegion:      true,
			expectedSet:    true,
			expectedRegion: "",
		},
		{
			name:           "no region property set",
			region:         "",
			hasRegion:      false,
			expectedSet:    false,
			expectedRegion: "",
		},
		{
			name:           "region with special characters",
			region:         "us-gov-west-1",
			hasRegion:      true,
			expectedSet:    true,
			expectedRegion: "us-gov-west-1",
		},
		{
			name:           "region with numbers",
			region:         "ap-southeast-3",
			hasRegion:      true,
			expectedSet:    true,
			expectedRegion: "ap-southeast-3",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ep := &endpoint.Endpoint{
				DNSName:       "test.example.com",
				SetIdentifier: "test-set",
			}

			if tt.hasRegion {
				ep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationAWSRegion, tt.region)
			}

			gp := newGeoProximity(ep)
			result := gp.withAWSRegion()

			assert.Equal(t, tt.expectedSet, result.isSet)

			if tt.expectedSet {
				assert.NotNil(t, result.location.AWSRegion)
				assert.Equal(t, tt.expectedRegion, *result.location.AWSRegion)
			} else {
				assert.Nil(t, result.location.AWSRegion)
			}

			// Verify the method returns the same instance for chaining
			assert.Equal(t, gp, result)
		})
	}
}

func TestGeoProximityWithLocalZoneGroup(t *testing.T) {
	tests := []struct {
		name                   string
		localZoneGroup         string
		hasLocalZoneGroup      bool
		expectedSet            bool
		expectedLocalZoneGroup string
	}{
		{
			name:                   "valid local zone group",
			localZoneGroup:         "usw2-lax1-az1",
			hasLocalZoneGroup:      true,
			expectedSet:            true,
			expectedLocalZoneGroup: "usw2-lax1-az1",
		},
		{
			name:                   "empty local zone group",
			localZoneGroup:         "",
			hasLocalZoneGroup:      true,
			expectedSet:            true,
			expectedLocalZoneGroup: "",
		},
		{
			name:                   "no local zone group property",
			localZoneGroup:         "",
			hasLocalZoneGroup:      false,
			expectedSet:            false,
			expectedLocalZoneGroup: "",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ep := &endpoint.Endpoint{
				DNSName:       "test.example.com",
				SetIdentifier: "test-set",
			}

			if tt.hasLocalZoneGroup {
				ep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationLocalZoneGroup, tt.localZoneGroup)
			}

			gp := newGeoProximity(ep)
			result := gp.withLocalZoneGroup()

			assert.Equal(t, tt.expectedSet, result.isSet)

			if tt.expectedSet {
				assert.NotNil(t, result.location.LocalZoneGroup)
				assert.Equal(t, tt.expectedLocalZoneGroup, *result.location.LocalZoneGroup)
			} else {
				assert.Nil(t, result.location.LocalZoneGroup)
			}

			// Verify method returns same instance for chaining
			assert.Equal(t, gp, result)
		})
	}
}

func TestGeoProximityWithCoordinates(t *testing.T) {
	tests := []struct {
		name             string
		coordinates      string
		expectedSet      bool
		expectedLat      string
		expectedLong     string
		shouldHaveCoords bool
	}{
		{
			name:             "valid coordinates",
			coordinates:      "45.0,90.0",
			expectedSet:      true,
			expectedLat:      "45.0",
			expectedLong:     "90.0",
			shouldHaveCoords: true,
		},
		{
			name:             "edge case min coordinates",
			coordinates:      "-90.0,-180.0",
			expectedSet:      true,
			expectedLat:      "-90.0",
			expectedLong:     "-180.0",
			shouldHaveCoords: true,
		},
		{
			name:             "edge case max coordinates",
			coordinates:      "90.0,180.0",
			expectedSet:      true,
			expectedLat:      "90.0",
			expectedLong:     "180.0",
			shouldHaveCoords: true,
		},
		{
			name:             "invalid latitude too high",
			coordinates:      "91.0,90.0",
			expectedSet:      false,
			shouldHaveCoords: false,
		},
		{
			name:             "invalid longitude too low",
			coordinates:      "45.0,-181.0",
			expectedSet:      false,
			shouldHaveCoords: false,
		},
		{
			name:             "invalid format - single value",
			coordinates:      "45.0",
			expectedSet:      false,
			shouldHaveCoords: false,
		},
		{
			name:             "invalid format - three values",
			coordinates:      "45.0,90.0,10.0",
			expectedSet:      false,
			shouldHaveCoords: false,
		},
		{
			name:             "invalid format - non-numeric",
			coordinates:      "abc,def",
			expectedSet:      false,
			shouldHaveCoords: false,
		},
		{
			name:             "no coordinates property",
			coordinates:      "",
			expectedSet:      false,
			shouldHaveCoords: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ep := &endpoint.Endpoint{}
			if tt.coordinates != "" {
				ep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationCoordinates, tt.coordinates)
			}

			gp := newGeoProximity(ep)
			result := gp.withCoordinates()

			assert.Equal(t, tt.expectedSet, result.isSet)

			if tt.shouldHaveCoords {
				assert.NotNil(t, result.location.Coordinates)
				assert.Equal(t, tt.expectedLat, *result.location.Coordinates.Latitude)
				assert.Equal(t, tt.expectedLong, *result.location.Coordinates.Longitude)
			} else {
				assert.Nil(t, result.location.Coordinates)
			}
		})
	}
}

func TestGeoProximityWithBias(t *testing.T) {
	tests := []struct {
		name         string
		bias         string
		hasBias      bool
		expectedSet  bool
		expectedBias int32
	}{
		{
			name:         "valid positive bias",
			bias:         "10",
			hasBias:      true,
			expectedSet:  true,
			expectedBias: 10,
		},
		{
			name:         "valid negative bias",
			bias:         "-5",
			hasBias:      true,
			expectedSet:  true,
			expectedBias: -5,
		},
		{
			name:         "zero bias",
			bias:         "0",
			hasBias:      true,
			expectedSet:  true,
			expectedBias: 0,
		},
		{
			name:         "large positive bias",
			bias:         "99",
			hasBias:      true,
			expectedSet:  true,
			expectedBias: 99,
		},
		{
			name:         "large negative bias",
			bias:         "-99",
			hasBias:      true,
			expectedSet:  true,
			expectedBias: -99,
		},
		{
			name:         "invalid bias - non-numeric",
			bias:         "abc",
			hasBias:      true,
			expectedSet:  true,
			expectedBias: 0, // defaults to 0 on error
		},
		{
			name:         "invalid bias - float",
			bias:         "10.5",
			hasBias:      true,
			expectedSet:  true,
			expectedBias: 0, // defaults to 0 on error
		},
		{
			name:         "empty bias string",
			bias:         "",
			hasBias:      true,
			expectedSet:  true,
			expectedBias: 0, // defaults to 0 on error
		},
		{
			name:         "no bias property",
			bias:         "",
			hasBias:      false,
			expectedSet:  false,
			expectedBias: 0,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ep := &endpoint.Endpoint{
				DNSName:       "test.example.com",
				SetIdentifier: "test-set",
			}

			if tt.hasBias {
				ep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationBias, tt.bias)
			}

			gp := newGeoProximity(ep)
			result := gp.withBias()

			assert.Equal(t, tt.expectedSet, result.isSet)

			if tt.expectedSet {
				assert.NotNil(t, result.location.Bias)
				assert.Equal(t, tt.expectedBias, *result.location.Bias)
			} else {
				assert.Nil(t, result.location.Bias)
			}

			// Verify method returns same instance for chaining
			assert.Equal(t, gp, result)
		})
	}
}

func TestAWSProvider_createUpdateChanges_NewMoreThanOld(t *testing.T) {
	provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"foo.bar."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), true, false, false, nil)

	oldEndpoints := []*endpoint.Endpoint{
		endpoint.NewEndpointWithTTL("record1.foo.bar.", endpoint.RecordTypeA, endpoint.TTL(300), "1.1.1.1"),
		nil,
	}
	newEndpoints := []*endpoint.Endpoint{
		endpoint.NewEndpointWithTTL("record1.foo.bar.", endpoint.RecordTypeA, endpoint.TTL(300), "1.1.1.1"),
		endpoint.NewEndpointWithTTL("record2.foo.bar.", endpoint.RecordTypeA, endpoint.TTL(300), "2.2.2.2"),
		endpoint.NewEndpointWithTTL("record3.foo.bar.", endpoint.RecordTypeA, endpoint.TTL(300), "3.3.3.3"),
	}

	changes := provider.createUpdateChanges(newEndpoints, oldEndpoints)

	// record2 should be created, record1 should be upserted
	var creates, upserts, deletes int
	for _, c := range changes {
		switch c.Action {
		case route53types.ChangeActionCreate:
			creates++
		case route53types.ChangeActionUpsert:
			upserts++
		case route53types.ChangeActionDelete:
			deletes++
		}
	}

	require.Equal(t, 0, creates, "should create the extra new endpoint")
	require.Equal(t, 1, upserts, "should upsert the matching endpoint")
	require.Equal(t, 0, deletes, "should not delete anything")
}

func TestAWSProvider_adjustEndpointAndNewAaaaIfNeeded(t *testing.T) {
	tests := []struct {
		name         string
		preferCNAME  bool
		ep           *endpoint.Endpoint
		expected     *endpoint.Endpoint
		expectedAaaa *endpoint.Endpoint
	}{
		// --- A / AAAA ---
		{
			name: "A record without provider specific should not change and not create AAAA",
			ep: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeA,
				Targets:    endpoint.Targets{"1.1.1.1"},
			},
			expected: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeA,
				Targets:    endpoint.Targets{"1.1.1.1"},
			},
			expectedAaaa: nil,
		},
		{
			name: "A record with alias=true should set default ttl, add evaluateTargetHealth and not create AAAA",
			ep: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeA,
				Targets:    endpoint.Targets{"1.1.1.1"},
				RecordTTL:  600,
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
				},
			},
			expected: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeA,
				Targets:    endpoint.Targets{"1.1.1.1"},
				RecordTTL:  defaultTTL,
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "false", // p.evaluateTargetHealth=false in this test
					},
				},
			},
			expectedAaaa: nil,
		},
		{
			name: "A record with alias!=true value should remove alias and evaluateTargetHealth and not create AAAA",
			ep: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeA,
				Targets:    endpoint.Targets{"1.1.1.1"},
				RecordTTL:  600,
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "false",
					},
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "true",
					},
				},
			},
			expected: &endpoint.Endpoint{
				DNSName:          "test.foo.bar.",
				RecordType:       endpoint.RecordTypeA,
				Targets:          endpoint.Targets{"1.1.1.1"},
				RecordTTL:        600,
				ProviderSpecific: endpoint.ProviderSpecific{},
			},
			expectedAaaa: nil,
		},
		{
			name: "A record with alias=true and invalid evaluateTargetHealth should normalize it to false and set default ttl",
			ep: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeA,
				Targets:    endpoint.Targets{"1.1.1.1"},
				RecordTTL:  600,
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "invalid",
					},
				},
			},
			expected: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeA,
				Targets:    endpoint.Targets{"1.1.1.1"},
				RecordTTL:  defaultTTL,
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "false",
					},
				},
			},
			expectedAaaa: nil,
		},
		{
			name: "AAAA record with alias=true should behave like A record",
			ep: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeAAAA,
				Targets:    endpoint.Targets{"2001:db8::1"},
				RecordTTL:  600,
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
				},
			},
			expected: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeAAAA,
				Targets:    endpoint.Targets{"2001:db8::1"},
				RecordTTL:  defaultTTL,
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "false",
					},
				},
			},
			expectedAaaa: nil,
		},

		// --- CNAME ---
		{
			name: "CNAME record with alias=false should keep alias=false, remove evaluateTargetHealth and not create AAAA",
			ep: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeCNAME,
				RecordTTL:  600,
				Targets:    endpoint.Targets{"target.foo.bar."},
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "false",
					},
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "true",
					},
				},
			},
			expected: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeCNAME,
				RecordTTL:  600,
				Targets:    endpoint.Targets{"target.foo.bar."},
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "false",
					},
				},
			},
			expectedAaaa: nil,
		},
		{
			name: "CNAME record with invalid alias value should normalize to alias=false and not create AAAA",
			ep: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeCNAME,
				RecordTTL:  600,
				Targets:    endpoint.Targets{"target.foo.bar."},
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "invalid",
					},
				},
			},
			expected: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeCNAME,
				RecordTTL:  600,
				Targets:    endpoint.Targets{"target.foo.bar."},
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "false",
					},
				},
			},
			expectedAaaa: nil,
		},
		{
			name: "CNAME record with alias=true should set default ttl, add evaluateTargetHealth and create AAAA",
			ep: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeCNAME,
				RecordTTL:  600,
				Targets:    endpoint.Targets{"target.foo.bar."},
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
				},
			},
			expected: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeA,
				RecordTTL:  defaultTTL,
				Targets:    endpoint.Targets{"target.foo.bar."},
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "false", // p.evaluateTargetHealth=false in this test
					},
				},
			},
			expectedAaaa: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeAAAA,
				RecordTTL:  defaultTTL,
				Targets:    endpoint.Targets{"target.foo.bar."},
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "false", // p.evaluateTargetHealth=false in this test
					},
				},
			},
		},
		{
			name: "CNAME record with alias=true and evaluateTargetHealth=true should keep evaluateTargetHealth and create AAAA",
			ep: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeCNAME,
				RecordTTL:  600,
				Targets:    endpoint.Targets{"target.foo.bar."},
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "true",
					},
				},
			},
			expected: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeA,
				RecordTTL:  defaultTTL,
				Targets:    endpoint.Targets{"target.foo.bar."},
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "true",
					},
				},
			},
			expectedAaaa: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeAAAA,
				RecordTTL:  defaultTTL,
				Targets:    endpoint.Targets{"target.foo.bar."},
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "true",
					},
				},
			},
		},
		{
			name: "CNAME record with alias=true and invalid evaluateTargetHealth should normalize it to false and create AAAA",
			ep: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeCNAME,
				RecordTTL:  600,
				Targets:    endpoint.Targets{"target.foo.bar."},
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "invalid",
					},
				},
			},
			expected: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeA,
				RecordTTL:  defaultTTL,
				Targets:    endpoint.Targets{"target.foo.bar."},
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "false",
					},
				},
			},
			expectedAaaa: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeAAAA,
				RecordTTL:  defaultTTL,
				Targets:    endpoint.Targets{"target.foo.bar."},
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "false",
					},
				},
			},
		},
		{
			name: "CNAME without alias to ELB target should enable alias and create AAAA",
			ep: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeCNAME,
				Targets:    endpoint.Targets{"test-123.us-east-1.elb.amazonaws.com"},
			},
			expected: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeA,
				Targets:    endpoint.Targets{"test-123.us-east-1.elb.amazonaws.com"},
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "false",
					},
				},
			},
			expectedAaaa: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeAAAA,
				Targets:    endpoint.Targets{"test-123.us-east-1.elb.amazonaws.com"},
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "false",
					},
				},
			},
		},
		{
			name:        "CNAME with preferCNAME=true should set alias=false and not create AAAA even for ELB target",
			preferCNAME: true,
			ep: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeCNAME,
				Targets:    endpoint.Targets{"test-123.us-east-1.elb.amazonaws.com."},
			},
			expected: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeCNAME,
				Targets:    endpoint.Targets{"test-123.us-east-1.elb.amazonaws.com."},
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "false",
					},
				},
			},
			expectedAaaa: nil,
		},

		// --- MX / other records ---
		{
			name: "MX record without provider specific should not change and not create AAAA",
			ep: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeMX,
				Targets:    endpoint.Targets{"10 mail.example.com."},
			},
			expected: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeMX,
				Targets:    endpoint.Targets{"10 mail.example.com."},
			},
			expectedAaaa: nil,
		},
		// TODO: fix For records other than A, AAAA, and CNAME, if an alias record is set, the alias record processing is not performed. This will be fixed in another PR.
		{
			name: "MX record with alias=true should remove alias and set default ttl, add evaluateTargetHealth and not create AAAA",
			ep: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeMX,
				Targets:    endpoint.Targets{"10 mail.example.com."},
				RecordTTL:  600,
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificAlias,
						Value: "true",
					},
				},
			},
			expected: &endpoint.Endpoint{
				DNSName:    "test.foo.bar.",
				RecordType: endpoint.RecordTypeMX,
				Targets:    endpoint.Targets{"10 mail.example.com."},
				RecordTTL:  defaultTTL,
				ProviderSpecific: endpoint.ProviderSpecific{
					{
						Name:  providerSpecificEvaluateTargetHealth,
						Value: "false",
					},
				},
			},
			expectedAaaa: nil,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			p, _ := newAWSProvider(
				t,
				endpoint.NewDomainFilter([]string{"foo.bar."}),
				provider.NewZoneIDFilter([]string{}),
				provider.NewZoneTypeFilter(""),
				false,
				tt.preferCNAME,
				false,
				nil,
			)

			aaaa := p.adjustEndpointAndNewAaaaIfNeeded(tt.ep)

			assert.True(t, testutils.SameEndpoint(tt.ep, tt.expected),
				"actual and expected endpoints don't match. %+v:%+v", tt.ep, tt.expected)

			assert.True(t, testutils.SameEndpoint(aaaa, tt.expectedAaaa),
				"actual and expected AAAA endpoints don't match. %+v:%+v", aaaa, tt.expectedAaaa)
		})
	}
}
