/*
Copyright 2018 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 etcd

import (
	"context"
	"fmt"
	"reflect"
	"slices"
	"strconv"
	"testing"
	"time"

	pb "go.etcd.io/etcd/api/v3/etcdserverpb"
	clientv3 "go.etcd.io/etcd/client/v3"

	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	clientsetfake "k8s.io/client-go/kubernetes/fake"
	clienttesting "k8s.io/client-go/testing"

	kubeadmapi "k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm"
	"k8s.io/kubernetes/cmd/kubeadm/app/constants"
	"k8s.io/kubernetes/cmd/kubeadm/app/util/errors"
	testresources "k8s.io/kubernetes/cmd/kubeadm/test/resources"
)

var errNotImplemented = errors.New("not implemented")

type fakeEtcdClient struct {
	members   []*pb.Member
	endpoints []string

	memberListFunc    func(context.Context, ...clientv3.OpOption) (*clientv3.MemberListResponse, error)
	memberPromoteFunc func(context.Context, uint64) (*clientv3.MemberPromoteResponse, error)
}

// Close shuts down the client's etcd connections.
func (f *fakeEtcdClient) Close() error {
	f.members = []*pb.Member{}
	return nil
}

// Endpoints lists the registered endpoints for the client.
func (f *fakeEtcdClient) Endpoints() []string {
	return f.endpoints
}

// MemberList lists the current cluster membership.
func (f *fakeEtcdClient) MemberList(ctx context.Context, opts ...clientv3.OpOption) (*clientv3.MemberListResponse, error) {
	if f.memberListFunc != nil {
		return f.memberListFunc(ctx, opts...)
	}
	return &clientv3.MemberListResponse{
		Members: f.members,
	}, nil
}

// MemberAdd adds a new member into the cluster.
func (f *fakeEtcdClient) MemberAdd(_ context.Context, peerAddrs []string) (*clientv3.MemberAddResponse, error) {
	return nil, errNotImplemented
}

// MemberAddAsLearner adds a new learner member into the cluster.
func (f *fakeEtcdClient) MemberAddAsLearner(_ context.Context, peerAddrs []string) (*clientv3.MemberAddResponse, error) {
	return nil, errNotImplemented
}

// MemberRemove removes an existing member from the cluster.
func (f *fakeEtcdClient) MemberRemove(_ context.Context, id uint64) (*clientv3.MemberRemoveResponse, error) {
	return nil, errNotImplemented
}

// MemberPromote promotes a member from raft learner (non-voting) to raft voting member.
func (f *fakeEtcdClient) MemberPromote(ctx context.Context, id uint64) (*clientv3.MemberPromoteResponse, error) {
	if f.memberPromoteFunc != nil {
		return f.memberPromoteFunc(ctx, id)
	}
	return nil, errNotImplemented
}

// Status gets the status of the endpoint.
func (f *fakeEtcdClient) Status(_ context.Context, endpoint string) (*clientv3.StatusResponse, error) {
	return nil, errNotImplemented
}

// Sync synchronizes client's endpoints with the known endpoints from the etcd membership.
func (f *fakeEtcdClient) Sync(_ context.Context) error {
	return errNotImplemented
}

func testGetURL(t *testing.T, getURLFunc func(*kubeadmapi.APIEndpoint) string, port int) {
	portStr := strconv.Itoa(port)
	tests := []struct {
		name             string
		advertiseAddress string
		expectedURL      string
	}{
		{
			name:             "IPv4",
			advertiseAddress: "10.10.10.10",
			expectedURL:      fmt.Sprintf("https://10.10.10.10:%s", portStr),
		},
		{
			name:             "IPv6",
			advertiseAddress: "2001:db8::2",
			expectedURL:      fmt.Sprintf("https://[2001:db8::2]:%s", portStr),
		},
		{
			name:             "IPv4 localhost",
			advertiseAddress: "127.0.0.1",
			expectedURL:      fmt.Sprintf("https://127.0.0.1:%s", portStr),
		},
		{
			name:             "IPv6 localhost",
			advertiseAddress: "::1",
			expectedURL:      fmt.Sprintf("https://[::1]:%s", portStr),
		},
	}

	for _, test := range tests {
		url := getURLFunc(&kubeadmapi.APIEndpoint{AdvertiseAddress: test.advertiseAddress})
		if url != test.expectedURL {
			t.Errorf("expected %s, got %s", test.expectedURL, url)
		}
	}
}

func TestGetClientURL(t *testing.T) {
	testGetURL(t, GetClientURL, constants.EtcdListenClientPort)
}

func TestGetPeerURL(t *testing.T) {
	testGetURL(t, GetPeerURL, constants.EtcdListenPeerPort)
}

func TestGetClientURLByIP(t *testing.T) {
	portStr := strconv.Itoa(constants.EtcdListenClientPort)
	tests := []struct {
		name        string
		ip          string
		expectedURL string
	}{
		{
			name:        "IPv4",
			ip:          "10.10.10.10",
			expectedURL: fmt.Sprintf("https://10.10.10.10:%s", portStr),
		},
		{
			name:        "IPv6",
			ip:          "2001:db8::2",
			expectedURL: fmt.Sprintf("https://[2001:db8::2]:%s", portStr),
		},
		{
			name:        "IPv4 localhost",
			ip:          "127.0.0.1",
			expectedURL: fmt.Sprintf("https://127.0.0.1:%s", portStr),
		},
		{
			name:        "IPv6 localhost",
			ip:          "::1",
			expectedURL: fmt.Sprintf("https://[::1]:%s", portStr),
		},
	}

	for _, test := range tests {
		url := GetClientURLByIP(test.ip)
		if url != test.expectedURL {
			t.Errorf("expected %s, got %s", test.expectedURL, url)
		}
	}
}

func TestGetEtcdEndpointsWithBackoff(t *testing.T) {
	tests := []struct {
		name              string
		pods              []testresources.FakeStaticPod
		expectedEndpoints []string
		expectedErr       bool
	}{
		{
			name:              "no pod annotations",
			expectedEndpoints: []string{},
			expectedErr:       true,
		},
		{
			name: "ipv4 endpoint in pod annotation; port is preserved",
			pods: []testresources.FakeStaticPod{
				{
					Component: constants.Etcd,
					Annotations: map[string]string{
						constants.EtcdAdvertiseClientUrlsAnnotationKey: "https://1.2.3.4:1234",
					},
				},
			},
			expectedEndpoints: []string{"https://1.2.3.4:1234"},
		},
	}
	for _, rt := range tests {
		t.Run(rt.name, func(t *testing.T) {
			client := clientsetfake.NewSimpleClientset()
			for _, pod := range rt.pods {
				if err := pod.Create(client); err != nil {
					t.Errorf("error setting up test creating pod for node %q", pod.NodeName)
				}
			}
			endpoints, err := getEtcdEndpointsWithRetry(client, time.Microsecond*10, time.Millisecond*100)
			if err != nil && !rt.expectedErr {
				t.Errorf("got error %q; was expecting no errors", err)
				return
			} else if err == nil && rt.expectedErr {
				t.Error("got no error; was expecting an error")
				return
			} else if err != nil && rt.expectedErr {
				return
			}

			if !reflect.DeepEqual(endpoints, rt.expectedEndpoints) {
				t.Errorf("expected etcd endpoints: %v; got: %v", rt.expectedEndpoints, endpoints)
			}
		})
	}
}

func TestGetRawEtcdEndpointsFromPodAnnotation(t *testing.T) {
	tests := []struct {
		name              string
		pods              []testresources.FakeStaticPod
		clientSetup       func(*clientsetfake.Clientset)
		expectedEndpoints []string
		expectedErr       bool
	}{
		{
			name: "exactly one pod with annotation",
			pods: []testresources.FakeStaticPod{
				{
					NodeName:    "cp-0",
					Component:   constants.Etcd,
					Annotations: map[string]string{constants.EtcdAdvertiseClientUrlsAnnotationKey: "https://1.2.3.4:2379"},
				},
			},
			expectedEndpoints: []string{"https://1.2.3.4:2379"},
		},
		{
			name: "two pods; one is missing annotation",
			pods: []testresources.FakeStaticPod{
				{
					NodeName:    "cp-0",
					Component:   constants.Etcd,
					Annotations: map[string]string{constants.EtcdAdvertiseClientUrlsAnnotationKey: "https://1.2.3.4:2379"},
				},
				{
					NodeName:  "cp-1",
					Component: constants.Etcd,
				},
			},
			expectedEndpoints: []string{"https://1.2.3.4:2379"},
			expectedErr:       true,
		},
		{
			name:        "no pods with annotation",
			expectedErr: true,
		},
		{
			name: "exactly one pod with annotation; all requests fail",
			pods: []testresources.FakeStaticPod{
				{
					NodeName:    "cp-0",
					Component:   constants.Etcd,
					Annotations: map[string]string{constants.EtcdAdvertiseClientUrlsAnnotationKey: "https://1.2.3.4:2379"},
				},
			},
			clientSetup: func(clientset *clientsetfake.Clientset) {
				clientset.PrependReactor("list", "pods", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) {
					return true, nil, apierrors.NewInternalError(errors.New("API server down"))
				})
			},
			expectedErr: true,
		},
	}
	for _, rt := range tests {
		t.Run(rt.name, func(t *testing.T) {
			client := clientsetfake.NewSimpleClientset()
			for i, pod := range rt.pods {
				if err := pod.CreateWithPodSuffix(client, strconv.Itoa(i)); err != nil {
					t.Errorf("error setting up test creating pod for node %q", pod.NodeName)
				}
			}
			if rt.clientSetup != nil {
				rt.clientSetup(client)
			}
			endpoints, err := getRawEtcdEndpointsFromPodAnnotation(client, time.Microsecond*10, time.Millisecond*100)
			if err != nil && !rt.expectedErr {
				t.Errorf("got error %v, but wasn't expecting any error", err)
				return
			} else if err == nil && rt.expectedErr {
				t.Error("didn't get any error; but was expecting an error")
				return
			} else if err != nil && rt.expectedErr {
				return
			}
			if !reflect.DeepEqual(endpoints, rt.expectedEndpoints) {
				t.Errorf("expected etcd endpoints: %v; got: %v", rt.expectedEndpoints, endpoints)
			}
		})
	}
}

func TestGetRawEtcdEndpointsFromPodAnnotationWithoutRetry(t *testing.T) {
	tests := []struct {
		name              string
		pods              []testresources.FakeStaticPod
		clientSetup       func(*clientsetfake.Clientset)
		expectedEndpoints []string
		expectedErr       bool
	}{
		{
			name:              "no pods",
			expectedEndpoints: []string{},
		},
		{
			name: "exactly one pod with annotation",
			pods: []testresources.FakeStaticPod{
				{
					NodeName:    "cp-0",
					Component:   constants.Etcd,
					Annotations: map[string]string{constants.EtcdAdvertiseClientUrlsAnnotationKey: "https://1.2.3.4:2379"},
				},
			},
			expectedEndpoints: []string{"https://1.2.3.4:2379"},
		},
		{
			name: "two pods; one is missing annotation",
			pods: []testresources.FakeStaticPod{
				{
					NodeName:    "cp-0",
					Component:   constants.Etcd,
					Annotations: map[string]string{constants.EtcdAdvertiseClientUrlsAnnotationKey: "https://1.2.3.4:2379"},
				},
				{
					NodeName:  "cp-1",
					Component: constants.Etcd,
				},
			},
			expectedEndpoints: []string{"https://1.2.3.4:2379"},
		},
		{
			name: "two pods with annotation",
			pods: []testresources.FakeStaticPod{
				{
					NodeName:    "cp-0",
					Component:   constants.Etcd,
					Annotations: map[string]string{constants.EtcdAdvertiseClientUrlsAnnotationKey: "https://1.2.3.4:2379"},
				},
				{
					NodeName:    "cp-1",
					Component:   constants.Etcd,
					Annotations: map[string]string{constants.EtcdAdvertiseClientUrlsAnnotationKey: "https://1.2.3.5:2379"},
				},
			},
			expectedEndpoints: []string{"https://1.2.3.4:2379", "https://1.2.3.5:2379"},
		},
		{
			name: "exactly one pod with annotation; request fails",
			pods: []testresources.FakeStaticPod{
				{
					NodeName:    "cp-0",
					Component:   constants.Etcd,
					Annotations: map[string]string{constants.EtcdAdvertiseClientUrlsAnnotationKey: "https://1.2.3.4:2379"},
				},
			},
			clientSetup: func(clientset *clientsetfake.Clientset) {
				clientset.PrependReactor("list", "pods", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) {
					return true, nil, apierrors.NewInternalError(errors.New("API server down"))
				})
			},
			expectedErr: true,
		},
	}
	for _, rt := range tests {
		t.Run(rt.name, func(t *testing.T) {
			client := clientsetfake.NewSimpleClientset()
			for _, pod := range rt.pods {
				if err := pod.Create(client); err != nil {
					t.Errorf("error setting up test creating pod for node %q", pod.NodeName)
					return
				}
			}
			if rt.clientSetup != nil {
				rt.clientSetup(client)
			}
			endpoints, _, err := getRawEtcdEndpointsFromPodAnnotationWithoutRetry(client)
			if err != nil && !rt.expectedErr {
				t.Errorf("got error %v, but wasn't expecting any error", err)
				return
			} else if err == nil && rt.expectedErr {
				t.Error("didn't get any error; but was expecting an error")
				return
			} else if err != nil && rt.expectedErr {
				return
			}
			if !reflect.DeepEqual(endpoints, rt.expectedEndpoints) {
				t.Errorf("expected etcd endpoints: %v; got: %v", rt.expectedEndpoints, endpoints)
			}
		})
	}
}

func TestClient_GetMemberID(t *testing.T) {
	type fields struct {
		Endpoints     []string
		newEtcdClient func(endpoints []string) (etcdClient, error)
	}
	type args struct {
		peerURL string
	}
	tests := []struct {
		name    string
		fields  fields
		args    args
		want    uint64
		wantErr error
	}{
		{
			name: "member ID found",
			fields: fields{
				Endpoints: []string{},
				newEtcdClient: func(endpoints []string) (etcdClient, error) {
					f := &fakeEtcdClient{
						members: []*pb.Member{
							{
								ID:   1,
								Name: "member1",
								PeerURLs: []string{
									"https://member1:2380",
								},
							},
						},
					}
					return f, nil
				},
			},
			args: args{
				peerURL: "https://member1:2380",
			},
			wantErr: nil,
			want:    1,
		},
		{
			name: "member ID not found",
			fields: fields{
				Endpoints: []string{},
				newEtcdClient: func(endpoints []string) (etcdClient, error) {
					f := &fakeEtcdClient{
						members: []*pb.Member{
							{
								ID:   1,
								Name: "member1",
								PeerURLs: []string{
									"https://member1:2380",
								},
							},
						},
					}
					return f, nil
				},
			},
			args: args{
				peerURL: "https://member2:2380",
			},
			wantErr: ErrNoMemberIDForPeerURL,
			want:    0,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			c := &Client{
				Endpoints:     tt.fields.Endpoints,
				newEtcdClient: tt.fields.newEtcdClient,
			}
			c.listMembersFunc = func(_ time.Duration) (*clientv3.MemberListResponse, error) {
				f, _ := c.newEtcdClient([]string{})
				resp, _ := f.MemberList(context.Background())
				return resp, nil
			}

			got, err := c.GetMemberID(tt.args.peerURL)
			if !errors.Is(tt.wantErr, err) {
				t.Errorf("Client.GetMemberID() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if got != tt.want {
				t.Errorf("Client.GetMemberID() = %v, want %v", got, tt.want)
			}
		})
	}
}

func TestListMembers(t *testing.T) {
	type fields struct {
		Endpoints       []string
		newEtcdClient   func(endpoints []string) (etcdClient, error)
		listMembersFunc func(timeout time.Duration) (*clientv3.MemberListResponse, error)
	}
	tests := []struct {
		name      string
		fields    fields
		want      []Member
		wantError bool
	}{
		{
			name: "PeerURLs are empty",
			fields: fields{
				Endpoints: []string{},
				newEtcdClient: func(endpoints []string) (etcdClient, error) {
					f := &fakeEtcdClient{}
					return f, nil
				},
			},
			want: []Member{},
		},
		{
			name: "PeerURLs are non-empty",
			fields: fields{
				Endpoints: []string{},
				newEtcdClient: func(endpoints []string) (etcdClient, error) {
					f := &fakeEtcdClient{
						members: []*pb.Member{
							{
								ID:   1,
								Name: "member1",
								PeerURLs: []string{
									"https://member1:2380",
								},
							},
							{
								ID:   2,
								Name: "member2",
								PeerURLs: []string{
									"https://member2:2380",
								},
							},
						},
					}
					return f, nil
				},
			},
			want: []Member{
				{
					Name:    "member1",
					PeerURL: "https://member1:2380",
				},
				{
					Name:    "member2",
					PeerURL: "https://member2:2380",
				},
			},
		},
		{
			name: "PeerURLs has multiple urls",
			fields: fields{
				Endpoints: []string{},
				newEtcdClient: func(endpoints []string) (etcdClient, error) {
					f := &fakeEtcdClient{
						members: []*pb.Member{
							{
								ID:   1,
								Name: "member1",
								PeerURLs: []string{
									"https://member1:2380",
									"https://member2:2380",
								},
							},
						},
					}
					return f, nil
				},
			},
			want: []Member{
				{
					Name:    "member1",
					PeerURL: "https://member1:2380",
				},
			},
		},
		{
			name: "ListMembers return error",
			fields: fields{
				Endpoints: []string{},
				newEtcdClient: func(endpoints []string) (etcdClient, error) {
					f := &fakeEtcdClient{
						members: []*pb.Member{
							{
								ID:   1,
								Name: "member1",
								PeerURLs: []string{
									"https://member1:2380",
									"https://member2:2380",
								},
							},
						},
					}
					return f, nil
				},
				listMembersFunc: func(_ time.Duration) (*clientv3.MemberListResponse, error) {
					return nil, errNotImplemented
				},
			},
			want:      nil,
			wantError: true,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			c := &Client{
				Endpoints:       tt.fields.Endpoints,
				newEtcdClient:   tt.fields.newEtcdClient,
				listMembersFunc: tt.fields.listMembersFunc,
			}
			if c.listMembersFunc == nil {
				c.listMembersFunc = func(_ time.Duration) (*clientv3.MemberListResponse, error) {
					return c.listMembers(100 * time.Millisecond)
				}
			}
			got, err := c.ListMembers()
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("ListMembers() = %v, want %v", got, tt.want)
			}
			if (err != nil) != (tt.wantError) {
				t.Errorf("ListMembers() error = %v, wantError %v", err, tt.wantError)
			}
		})
	}
}

func TestGetMemberStatus(t *testing.T) {
	type fields struct {
		Endpoints       []string
		newEtcdClient   func(endpoints []string) (etcdClient, error)
		listMembersFunc func(timeout time.Duration) (*clientv3.MemberListResponse, error)
	}
	tests := []struct {
		name        string
		fields      fields
		memberID    uint64
		wantLearner bool
		wantStarted bool
		wantError   bool
	}{
		{
			name: "The specified member is not a learner",
			fields: fields{
				Endpoints: []string{},
				newEtcdClient: func(endpoints []string) (etcdClient, error) {
					f := &fakeEtcdClient{
						members: []*pb.Member{
							{
								ID:   1,
								Name: "member1",
								PeerURLs: []string{
									"https://member1:2380",
								},
								IsLearner: false,
							},
						},
					}
					return f, nil
				},
			},
			memberID:    1,
			wantLearner: false,
			wantStarted: true,
		},
		{
			name: "The specified member is a learner",
			fields: fields{
				Endpoints: []string{},
				newEtcdClient: func(endpoints []string) (etcdClient, error) {
					f := &fakeEtcdClient{
						members: []*pb.Member{
							{
								ID:   1,
								Name: "member1",
								PeerURLs: []string{
									"https://member1:2380",
								},
								IsLearner: true,
							},
							{
								ID:   2,
								Name: "member2",
								PeerURLs: []string{
									"https://member2:2380",
								},
							},
						},
					}
					return f, nil
				},
			},
			memberID:    1,
			wantLearner: true,
			wantStarted: true,
		},
		{
			name: "The specified member does not exist",
			fields: fields{
				Endpoints: []string{},
				newEtcdClient: func(endpoints []string) (etcdClient, error) {
					f := &fakeEtcdClient{
						members: []*pb.Member{},
					}
					return f, nil
				},
			},
			memberID:    3,
			wantLearner: false,
			wantStarted: false,
			wantError:   true,
		},
		{
			name: "Learner ID is empty",
			fields: fields{
				Endpoints: []string{},
				newEtcdClient: func(endpoints []string) (etcdClient, error) {
					f := &fakeEtcdClient{
						members: []*pb.Member{
							{
								Name: "member2",
								PeerURLs: []string{
									"https://member2:2380",
								},
								IsLearner: true,
							},
						},
					}
					return f, nil
				},
			},
			wantLearner: true,
			wantStarted: true,
		},
		{
			name: "Learner member is not started (no name)",
			fields: fields{
				Endpoints: []string{},
				newEtcdClient: func(endpoints []string) (etcdClient, error) {
					f := &fakeEtcdClient{
						members: []*pb.Member{
							{
								ID:   1,
								Name: "",
								PeerURLs: []string{
									"https://member2:2380",
								},
								IsLearner: true,
							},
						},
					}
					return f, nil
				},
			},
			memberID:    1,
			wantLearner: true,
			wantStarted: false,
		},
		{
			name: "ListMembers returns an error",
			fields: fields{
				Endpoints: []string{},
				newEtcdClient: func(endpoints []string) (etcdClient, error) {
					f := &fakeEtcdClient{
						members: []*pb.Member{
							{
								Name: "member2",
								PeerURLs: []string{
									"https://member2:2380",
								},
								IsLearner: true,
							},
						},
					}
					return f, nil
				},
				listMembersFunc: func(_ time.Duration) (*clientv3.MemberListResponse, error) {
					return nil, errNotImplemented
				},
			},
			memberID:    1,
			wantLearner: false,
			wantStarted: false,
			wantError:   true,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			c := &Client{
				Endpoints:       tt.fields.Endpoints,
				newEtcdClient:   tt.fields.newEtcdClient,
				listMembersFunc: tt.fields.listMembersFunc,
			}
			if c.listMembersFunc == nil {
				c.listMembersFunc = func(t_ time.Duration) (*clientv3.MemberListResponse, error) {
					f, _ := c.newEtcdClient([]string{})
					resp, _ := f.MemberList(context.Background())
					return resp, nil
				}
			}
			gotLearner, gotStarted, err := c.getMemberStatus(tt.memberID)
			if gotLearner != tt.wantLearner {
				t.Errorf("getMemberStatus() isLearner = %v, want %v", gotLearner, tt.wantLearner)
			}
			if gotStarted != tt.wantStarted {
				t.Errorf("getMemberStatus() started = %v, want %v", gotStarted, tt.wantStarted)
			}
			if (err != nil) != tt.wantError {
				t.Errorf("getMemberStatus() error = %v, wantError %v", err, tt.wantError)
			}
		})
	}
}

type fakeEtcdClientWithStatusResponse struct {
	fakeEtcdClient
	statusResponses     map[string]*clientv3.StatusResponse
	statusRequestErrors map[string]error
}

// Status gets the status of the endpoint.
func (f *fakeEtcdClientWithStatusResponse) Status(_ context.Context, ep string) (*clientv3.StatusResponse, error) {
	if f.statusRequestErrors != nil {
		if _, ok := f.statusRequestErrors[ep]; ok {
			return nil, f.statusRequestErrors[ep]
		}
	}
	return f.statusResponses[ep], nil
}

func TestEvaluateClusterStatus(t *testing.T) {
	testCases := []struct {
		name               string
		Endpoints          []string
		newEtcdClient      func(endpoints []string) (etcdClient, error)
		wantClusterHealthy bool
		wantMemberErrors   bool
	}{
		{
			name:      "all the three members are healthy",
			Endpoints: []string{"https://192.168.10.100:2379", "https://192.168.10.200:2379", "https://192.168.10.300:2379"},
			newEtcdClient: func(endpoints []string) (etcdClient, error) {
				f := &fakeEtcdClientWithStatusResponse{
					statusResponses: map[string]*clientv3.StatusResponse{
						"https://192.168.10.100:2379": {},
						"https://192.168.10.200:2379": {},
						"https://192.168.10.300:2379": {},
					},
				}
				return f, nil
			},
			wantClusterHealthy: true,
			wantMemberErrors:   false,
		},
		{
			name:      "one out of three members has errors",
			Endpoints: []string{"https://192.168.10.100:2379", "https://192.168.10.200:2379", "https://192.168.10.300:2379"},
			newEtcdClient: func(endpoints []string) (etcdClient, error) {
				f := &fakeEtcdClientWithStatusResponse{
					statusResponses: map[string]*clientv3.StatusResponse{
						"https://192.168.10.100:2379": {},
						"https://192.168.10.200:2379": {Errors: []string{"etcdserver: mvcc: database space exceeded"}},
						"https://192.168.10.300:2379": {},
					},
				}
				return f, nil
			},
			wantClusterHealthy: true,
			wantMemberErrors:   true,
		},
		{
			name:      "one out of three members is unreachable",
			Endpoints: []string{"https://192.168.10.100:2379", "https://192.168.10.200:2379", "https://192.168.10.300:2379"},
			newEtcdClient: func(endpoints []string) (etcdClient, error) {
				f := &fakeEtcdClientWithStatusResponse{
					statusResponses: map[string]*clientv3.StatusResponse{
						"https://192.168.10.100:2379": {},
						"https://192.168.10.200:2379": {},
						"https://192.168.10.300:2379": {},
					},
					statusRequestErrors: map[string]error{
						"https://192.168.10.200:2379": errors.New("context deadline exceeded"),
					},
				}
				return f, nil
			},
			wantClusterHealthy: true,
			wantMemberErrors:   true,
		},
		{
			name:      "two out of three members has errors",
			Endpoints: []string{"https://192.168.10.100:2379", "https://192.168.10.200:2379", "https://192.168.10.300:2379"},
			newEtcdClient: func(endpoints []string) (etcdClient, error) {
				f := &fakeEtcdClientWithStatusResponse{
					statusResponses: map[string]*clientv3.StatusResponse{
						"https://192.168.10.100:2379": {},
						"https://192.168.10.200:2379": {Errors: []string{"etcdserver: mvcc: database space exceeded"}},
						"https://192.168.10.300:2379": {Errors: []string{"etcdserver: mvcc: data corrupted"}},
					},
				}
				return f, nil
			},
			wantClusterHealthy: false,
			wantMemberErrors:   true,
		},
		{
			name:      "two out of three members are unreachable",
			Endpoints: []string{"https://192.168.10.100:2379", "https://192.168.10.200:2379", "https://192.168.10.300:2379"},
			newEtcdClient: func(endpoints []string) (etcdClient, error) {
				f := &fakeEtcdClientWithStatusResponse{
					statusResponses: map[string]*clientv3.StatusResponse{
						"https://192.168.10.100:2379": {},
						"https://192.168.10.200:2379": {},
						"https://192.168.10.300:2379": {},
					},
					statusRequestErrors: map[string]error{
						"https://192.168.10.200:2379": errors.New("context deadline exceeded"),
						"https://192.168.10.300:2379": errors.New("context deadline exceeded"),
					},
				}
				return f, nil
			},
			wantClusterHealthy: false,
			wantMemberErrors:   true,
		},
	}

	// Temporarily reduce the etcd API call timeout from 2 minutes to 1 second.
	oldActiveTimeout := kubeadmapi.GetActiveTimeouts()
	newActiveTimeout := oldActiveTimeout.DeepCopy()
	newActiveTimeout.EtcdAPICall = &metav1.Duration{Duration: 1 * time.Second}
	kubeadmapi.SetActiveTimeouts(newActiveTimeout)
	defer func() {
		kubeadmapi.SetActiveTimeouts(oldActiveTimeout)
	}()

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			c := &Client{
				Endpoints:     tc.Endpoints,
				newEtcdClient: tc.newEtcdClient,
			}
			_, gotClusterHealthy, err := c.getClusterStatus()

			if gotClusterHealthy != tc.wantClusterHealthy {
				t.Errorf("gotClusterHealthy = %t, want = %t", gotClusterHealthy, tc.wantClusterHealthy)
			}

			if tc.wantMemberErrors != (err != nil) {
				t.Errorf("gotMemberErrors = %v, wantMemberErrors = %t", err, tc.wantMemberErrors)
			}
		})
	}
}

func TestMemberPromote(t *testing.T) {
	learnerID := uint64(12345)

	const (
		initialEndpoint  = "https://192.168.10.100:2379"
		promotedEndpoint = "https://192.168.10.200:2379"
	)

	member := func(isLearner bool) *pb.Member {
		return &pb.Member{
			ID:         learnerID,
			Name:       "cp-1",
			PeerURLs:   []string{"https://192.168.10.200:2380"},
			ClientURLs: []string{promotedEndpoint},
			IsLearner:  isLearner,
		}
	}

	type memberListResult struct {
		members []*pb.Member
		err     error
	}

	type memberPromoteResult struct {
		resp *clientv3.MemberPromoteResponse
		err  error
	}

	tests := []struct {
		name                 string
		memberListResults    []memberListResult
		memberPromoteResults []memberPromoteResult
		wantErr              bool
		wantEndpoint         string
		wantPromoteCalls     int
		minMemberListCalls   int
	}{
		{
			name: "successful promotion adds endpoint",
			memberListResults: []memberListResult{
				{members: []*pb.Member{member(true)}},
				{members: []*pb.Member{member(true)}},
			},
			memberPromoteResults: []memberPromoteResult{
				{
					resp: &clientv3.MemberPromoteResponse{
						Members: []*pb.Member{member(false)},
					},
				},
			},
			wantEndpoint:       promotedEndpoint,
			wantPromoteCalls:   1,
			minMemberListCalls: 2,
		},
		{
			name: "already promoted after transient promote failure adds endpoint",
			memberListResults: []memberListResult{
				{members: []*pb.Member{member(true)}},
				{members: []*pb.Member{member(true)}},
				{members: []*pb.Member{member(false)}},
			},
			memberPromoteResults: []memberPromoteResult{
				{
					err: context.DeadlineExceeded,
				},
			},
			wantEndpoint:       promotedEndpoint,
			wantPromoteCalls:   1,
			minMemberListCalls: 3,
		},
		{
			name: "already promoted before promote attempt adds endpoint",
			memberListResults: []memberListResult{
				{members: []*pb.Member{member(true)}},
				{members: []*pb.Member{member(false)}},
			},
			wantEndpoint:       promotedEndpoint,
			wantPromoteCalls:   0,
			minMemberListCalls: 2,
		},
		{
			name: "member list error before promote is retried",
			memberListResults: []memberListResult{
				{members: []*pb.Member{member(true)}},
				{err: errNotImplemented},
				{members: []*pb.Member{member(true)}},
			},
			memberPromoteResults: []memberPromoteResult{
				{
					resp: &clientv3.MemberPromoteResponse{
						Members: []*pb.Member{member(false)},
					},
				},
			},
			wantEndpoint:       promotedEndpoint,
			wantPromoteCalls:   1,
			minMemberListCalls: 3,
		},
		{
			name: "promotion keeps failing",
			memberListResults: []memberListResult{
				{members: []*pb.Member{member(true)}},
				{members: []*pb.Member{member(true)}},
			},
			memberPromoteResults: []memberPromoteResult{
				{
					err: context.DeadlineExceeded,
				},
			},
			wantErr:            true,
			wantPromoteCalls:   -1,
			minMemberListCalls: 1,
		},
	}

	oldActiveTimeout := kubeadmapi.GetActiveTimeouts()
	newActiveTimeout := oldActiveTimeout.DeepCopy()
	newActiveTimeout.EtcdAPICall = &metav1.Duration{
		Duration: 3 * constants.EtcdAPICallRetryInterval,
	}
	kubeadmapi.SetActiveTimeouts(newActiveTimeout)
	defer kubeadmapi.SetActiveTimeouts(oldActiveTimeout)

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

			fakeClient := &fakeEtcdClient{}

			fakeClient.memberListFunc = func(_ context.Context, _ ...clientv3.OpOption) (*clientv3.MemberListResponse, error) {
				if len(tt.memberListResults) == 0 {
					t.Fatal("MemberList called without configured results")
				}

				resultIndex := memberListCalls
				if resultIndex >= len(tt.memberListResults) {
					resultIndex = len(tt.memberListResults) - 1
				}
				result := tt.memberListResults[resultIndex]
				memberListCalls++

				if result.err != nil {
					return nil, result.err
				}
				return &clientv3.MemberListResponse{
					Members: result.members,
				}, nil
			}

			fakeClient.memberPromoteFunc = func(_ context.Context, _ uint64) (*clientv3.MemberPromoteResponse, error) {
				if len(tt.memberPromoteResults) == 0 {
					t.Fatalf("unexpected MemberPromote call")
				}

				resultIndex := memberPromoteCalls
				if resultIndex >= len(tt.memberPromoteResults) {
					resultIndex = len(tt.memberPromoteResults) - 1
				}
				result := tt.memberPromoteResults[resultIndex]
				memberPromoteCalls++

				return result.resp, result.err
			}

			c := &Client{
				Endpoints: []string{initialEndpoint},
			}
			c.newEtcdClient = func(_ []string) (etcdClient, error) {
				return fakeClient, nil
			}
			c.listMembersFunc = func(_ time.Duration) (*clientv3.MemberListResponse, error) {
				return fakeClient.MemberList(context.Background())
			}

			err := c.MemberPromote(learnerID)
			if (err != nil) != tt.wantErr {
				t.Fatalf("MemberPromote() error = %v, wantErr %v", err, tt.wantErr)
			}

			if tt.wantPromoteCalls >= 0 && memberPromoteCalls != tt.wantPromoteCalls {
				t.Fatalf("MemberPromote calls = %d, want %d", memberPromoteCalls, tt.wantPromoteCalls)
			}

			if memberListCalls < tt.minMemberListCalls {
				t.Fatalf("MemberList calls = %d, want at least %d", memberListCalls, tt.minMemberListCalls)
			}

			if tt.wantEndpoint != "" && !slices.Contains(c.Endpoints, tt.wantEndpoint) {
				t.Fatalf("expected endpoint %q to be added, got %v", tt.wantEndpoint, c.Endpoints)
			}
		})
	}
}
